@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 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.0",
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.0",
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",
@@ -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 = process.env[config];
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: OAuthCredential): UsageCredential {
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: OAuthCredential,
1423
+ credential: AuthCredential,
1421
1424
  options?: { baseUrl?: string; timeoutMs?: number },
1422
1425
  ): Promise<UsageReport | null> {
1423
1426
  return this.#fetchUsageCached(
1424
- this.#buildUsageRequestForOauth(provider, credential, options?.baseUrl),
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 (sessionCredential.type === "oauth" && this.#rankingStrategyResolver?.(provider)) {
1516
+ if (this.#rankingStrategyResolver?.(provider)) {
1514
1517
  const credential = this.#getCredentialsForProvider(provider)[sessionCredential.index];
1515
- if (credential?.type === "oauth") {
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, Date.now());
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
  }
@@ -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
- // Resolve only on explicit turnEnded. stopReason defaults to "stop"
447
- // and is not a reliable signal for stream completion.
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
- h2Completion.reject(new Error(`gRPC error ${status}: ${decodeURIComponent(String(msg || ""))}`));
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
- resolveH2 = undefined;
487
- if (endStreamError) {
488
- h2Completion.reject(endStreamError);
489
- return;
490
- }
491
- h2Completion.resolve();
498
+ settleH2();
492
499
  });
493
500
 
494
- h2Request!.on("error", h2Completion.reject);
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
- h2Completion.reject(new Error("Request was aborted"));
506
+ settleH2(new Error("Request was aborted"));
500
507
  });
501
508
  }
502
509
  await h2Completion.promise;
@@ -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
- return items.flatMap(item => {
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(