@linxiraos/pi-ai 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +4 -2
  3. package/THIRD-PARTY-NOTICES.txt +55 -5
  4. package/dist/types/auth/cascade.d.ts +5 -3
  5. package/dist/types/auth/pool.d.ts +3 -3
  6. package/dist/types/auth/select.d.ts +5 -1
  7. package/dist/types/auth/types.d.ts +15 -5
  8. package/dist/types/auth/usage-cache.d.ts +3 -1
  9. package/dist/types/auth-gateway/clients.d.ts +67 -0
  10. package/dist/types/auth-gateway/dispatch.d.ts +28 -10
  11. package/dist/types/auth-gateway/http.d.ts +2 -0
  12. package/dist/types/auth-gateway/index.d.ts +1 -0
  13. package/dist/types/auth-gateway/server.d.ts +9 -4
  14. package/dist/types/auth-gateway/session-state.d.ts +6 -0
  15. package/dist/types/auth-gateway/types.d.ts +16 -2
  16. package/dist/types/dialect/coercion.d.ts +6 -0
  17. package/dist/types/error/classes.d.ts +1 -1
  18. package/dist/types/error/flags.d.ts +15 -0
  19. package/dist/types/error/index.d.ts +1 -0
  20. package/dist/types/error/network-cause.d.ts +40 -0
  21. package/dist/types/images/shared.d.ts +26 -2
  22. package/dist/types/index.d.ts +1 -0
  23. package/dist/types/judgment/native.d.ts +51 -3
  24. package/dist/types/judgment/systemone.d.ts +9 -2
  25. package/dist/types/judgment/text.d.ts +11 -5
  26. package/dist/types/judgment/types.d.ts +5 -1
  27. package/dist/types/judgment/wire.d.ts +16 -3
  28. package/dist/types/provider-session-state.d.ts +2 -2
  29. package/dist/types/providers/anthropic-images.d.ts +7 -0
  30. package/dist/types/providers/anthropic-signature.d.ts +12 -2
  31. package/dist/types/providers/anthropic-state.d.ts +7 -0
  32. package/dist/types/providers/anthropic.d.ts +21 -6
  33. package/dist/types/providers/connection-prewarm.d.ts +15 -0
  34. package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
  35. package/dist/types/providers/cursor-pi-args.d.ts +13 -0
  36. package/dist/types/providers/cursor.d.ts +7 -0
  37. package/dist/types/providers/openai-codex-responses.d.ts +6 -0
  38. package/dist/types/providers/openai-responses.d.ts +9 -0
  39. package/dist/types/providers/openai-shared.d.ts +30 -0
  40. package/dist/types/providers/systemone-server.d.ts +7 -3
  41. package/dist/types/providers/transform-messages.d.ts +26 -5
  42. package/dist/types/registry/oauth/google-antigravity.d.ts +1 -1
  43. package/dist/types/stream.d.ts +0 -2
  44. package/dist/types/types.d.ts +33 -9
  45. package/dist/types/utils/dsml-leak.d.ts +4 -2
  46. package/dist/types/utils/narration.d.ts +9 -0
  47. package/dist/types/utils/openai-http.d.ts +2 -0
  48. package/dist/types/utils/request-body.d.ts +11 -0
  49. package/dist/types/utils/request-compression.d.ts +21 -0
  50. package/dist/types/utils/tool-call-arguments.d.ts +2 -1
  51. package/dist/types/utils/transport-fetch.d.ts +8 -0
  52. package/dist/types/utils.d.ts +1 -1
  53. package/package.json +8 -8
  54. package/src/auth/cascade.ts +23 -13
  55. package/src/auth/pool.ts +3 -3
  56. package/src/auth/refresh.ts +39 -12
  57. package/src/auth/select.ts +8 -2
  58. package/src/auth/types.ts +16 -5
  59. package/src/auth/usage-cache.ts +23 -7
  60. package/src/auth/usage.ts +2 -4
  61. package/src/auth-broker/remote-store.ts +31 -6
  62. package/src/auth-gateway/clients.ts +318 -0
  63. package/src/auth-gateway/dispatch.ts +66 -35
  64. package/src/auth-gateway/http.ts +11 -5
  65. package/src/auth-gateway/index.ts +1 -0
  66. package/src/auth-gateway/routes/embeddings.ts +4 -10
  67. package/src/auth-gateway/routes/images.ts +6 -10
  68. package/src/auth-gateway/routes/rerank.ts +4 -10
  69. package/src/auth-gateway/routes/speech.ts +10 -10
  70. package/src/auth-gateway/routes/systemone.ts +22 -12
  71. package/src/auth-gateway/routes/transcriptions.ts +4 -2
  72. package/src/auth-gateway/routes/video.ts +12 -15
  73. package/src/auth-gateway/server.ts +136 -53
  74. package/src/auth-gateway/session-state.ts +14 -0
  75. package/src/auth-gateway/types.ts +16 -2
  76. package/src/dialect/coercion.ts +16 -0
  77. package/src/dialect/hermes.ts +7 -11
  78. package/src/dialect/qwen3.ts +3 -11
  79. package/src/error/classes.ts +3 -2
  80. package/src/error/flags.ts +24 -3
  81. package/src/error/index.ts +1 -0
  82. package/src/error/network-cause.ts +179 -0
  83. package/src/images/codex-format-directive.md +3 -0
  84. package/src/images/google-antigravity.ts +2 -1
  85. package/src/images/google-generative-ai.ts +4 -2
  86. package/src/images/openai-hosted.ts +25 -2
  87. package/src/images/openai-images.ts +11 -6
  88. package/src/images/shared.ts +77 -3
  89. package/src/index.ts +1 -0
  90. package/src/judgment/chat.ts +17 -1
  91. package/src/judgment/native.ts +182 -12
  92. package/src/judgment/openai-decisions.ts +27 -6
  93. package/src/judgment/systemone.ts +131 -10
  94. package/src/judgment/text.ts +20 -7
  95. package/src/judgment/types.ts +5 -1
  96. package/src/judgment/wire.ts +17 -3
  97. package/src/provider-session-state.ts +2 -2
  98. package/src/providers/amazon-bedrock.ts +4 -1
  99. package/src/providers/anthropic-client.ts +1 -1
  100. package/src/providers/anthropic-images.ts +263 -0
  101. package/src/providers/anthropic-messages-server.ts +84 -1
  102. package/src/providers/anthropic-signature.ts +35 -8
  103. package/src/providers/anthropic-state.ts +13 -0
  104. package/src/providers/anthropic.ts +479 -397
  105. package/src/providers/connection-prewarm.ts +78 -0
  106. package/src/providers/cowork-fetch.ts +5 -1
  107. package/src/providers/cursor/exec-modern.ts +1 -0
  108. package/src/providers/cursor-pi-args.ts +19 -0
  109. package/src/providers/cursor.ts +53 -13
  110. package/src/providers/devin.ts +10 -4
  111. package/src/providers/factory-droid/gemini.ts +9 -3
  112. package/src/providers/google-gemini-cli.ts +11 -1
  113. package/src/providers/google-shared.ts +2 -6
  114. package/src/providers/images-server.ts +6 -1
  115. package/src/providers/ollama.ts +15 -5
  116. package/src/providers/openai-anthropic-shim.ts +1 -0
  117. package/src/providers/openai-chat-server.ts +29 -0
  118. package/src/providers/openai-codex/access-programs.ts +2 -1
  119. package/src/providers/openai-codex-responses.ts +49 -82
  120. package/src/providers/openai-completions.ts +117 -19
  121. package/src/providers/openai-responses.ts +25 -2
  122. package/src/providers/openai-shared.ts +36 -5
  123. package/src/providers/pi-native-server.ts +1 -0
  124. package/src/providers/systemone-server.ts +72 -12
  125. package/src/providers/transform-messages.ts +95 -51
  126. package/src/registry/oauth/google-antigravity.ts +1 -1
  127. package/src/stream.ts +13 -73
  128. package/src/types.ts +33 -9
  129. package/src/usage/claude.ts +2 -9
  130. package/src/usage/github-copilot.ts +26 -4
  131. package/src/usage/openai-codex.ts +13 -7
  132. package/src/usage/opencode-go.ts +7 -8
  133. package/src/usage/xai-oauth.ts +2 -9
  134. package/src/utils/dsml-leak.ts +30 -14
  135. package/src/utils/leaked-thinking-stream.ts +10 -0
  136. package/src/utils/narration.ts +18 -0
  137. package/src/utils/openai-http.ts +27 -18
  138. package/src/utils/request-body.ts +27 -0
  139. package/src/utils/request-compression.ts +202 -0
  140. package/src/utils/request-debug.ts +23 -9
  141. package/src/utils/schema/dereference.ts +51 -18
  142. package/src/utils/schema/draft.ts +10 -2
  143. package/src/utils/schema/json-schema-validator.ts +4 -10
  144. package/src/utils/schema/normalize.ts +6 -9
  145. package/src/utils/tool-call-arguments.ts +2 -1
  146. package/src/utils/transport-fetch.ts +37 -0
  147. package/src/utils/validation.ts +26 -16
  148. package/src/utils.ts +38 -23
package/src/types.ts CHANGED
@@ -40,7 +40,7 @@ import type { ApiKey } from "./auth-retry";
40
40
  import type { OAuthRequestIdentity } from "./auth/types";
41
41
  import type { BedrockOptions } from "./providers/amazon-bedrock";
42
42
  import type { AnthropicOptions } from "./providers/anthropic";
43
- import type { FallbackParam, StopDetails } from "./providers/anthropic-wire";
43
+ import type { FallbackParam, MessageCreateParamsStreaming, StopDetails } from "./providers/anthropic-wire";
44
44
  import type { AzureOpenAIResponsesOptions } from "./providers/azure-openai-responses";
45
45
  import type { CursorOptions } from "./providers/cursor";
46
46
  import type { AppleFoundationModelsOptions } from "./providers/apple-foundation-models";
@@ -497,6 +497,11 @@ export interface StreamOptions {
497
497
  frequencyPenalty?: number;
498
498
  maxTokens?: number;
499
499
  signal?: AbortSignal;
500
+ /**
501
+ * OpenAI chat-completions HTTP attempt budget. When set, transient provider
502
+ * errors are not replayed above the transport, so retries share this limit.
503
+ */
504
+ maxHttpAttempts?: number;
500
505
  apiKey?: string;
501
506
  /** @internal Stored credential row serving this request, when known. */
502
507
  credentialId?: number;
@@ -628,8 +633,6 @@ export interface StreamOptions {
628
633
  liveSteering?: LiveSteering;
629
634
  /** Canonical Codex compaction classification; ignored by other providers. */
630
635
  codexCompaction?: CodexCompactionRequestContext;
631
- /** Codex Code Mode tool exposure snapshot emitted as `tool_namespaces_info` turn metadata; ignored by other providers. */
632
- toolNamespacesInfo?: unknown;
633
636
  /**
634
637
  * Optional per-provider concurrent request cap for LLM stream calls. Keys are
635
638
  * provider ids (`model.provider`); positive numeric values cap in-flight
@@ -893,6 +896,13 @@ export interface ThinkingContent {
893
896
  * replay natively on another host.
894
897
  */
895
898
  summary?: boolean;
899
+ /**
900
+ * `true`: the block is narration — prose the model addressed to the user
901
+ * between tool calls, which the provider returned as a signed thinking block
902
+ * (Anthropic connector-text summarization). It stays a thinking block for
903
+ * replay; user-facing surfaces present it as answer text.
904
+ */
905
+ narration?: boolean;
896
906
  }
897
907
 
898
908
  export interface RedactedThinkingContent {
@@ -902,11 +912,12 @@ export interface RedactedThinkingContent {
902
912
 
903
913
  /**
904
914
  * Anthropic server-side-fallback boundary marker persisted on assistant
905
- * turns whose provider request opted into
906
- * `AnthropicOptions.fallbacks`. Consumers other than the Anthropic
907
- * provider MUST ignore it — `transformMessages` strips the block on any
908
- * cross-provider hop and on non-official Anthropic replays, so downstream
909
- * converters never see it.
915
+ * turns whose response switched models mid-stream, whether the request
916
+ * opted into `AnthropicOptions.fallbacks` or a gateway opted in on its
917
+ * behalf. Consumers other than the Anthropic provider MUST ignore it —
918
+ * `transformMessages` strips the block on any non-Anthropic hop, and
919
+ * `convertAnthropicMessages` only puts it on the wire for an opted-in
920
+ * request to the official endpoint.
910
921
  */
911
922
  export interface AnthropicFallbackContent {
912
923
  type: "fallback";
@@ -1230,6 +1241,12 @@ export type AssistantRetryRecovery =
1230
1241
  export interface ContextSnapshot {
1231
1242
  promptTokens: number; // authoritative provider prompt/input tokens
1232
1243
  nonMessageTokens: number; // estimated non-message total at send time
1244
+ /**
1245
+ * Estimated tool-schema tokens included in `nonMessageTokens` (0 when the
1246
+ * model defers schemas server-side). Lets a later model whose schema policy
1247
+ * differs rebase the provider anchor instead of inheriting its schemas.
1248
+ */
1249
+ toolSchemaTokens?: number;
1233
1250
  /** Estimated prompt tokens removed by local history rewrites after this provider snapshot was recorded. */
1234
1251
  historyRewriteTokensRemoved?: number;
1235
1252
  /**
@@ -1287,6 +1304,12 @@ export interface AssistantMessage {
1287
1304
  */
1288
1305
  serviceTier?: ServiceTier;
1289
1306
  usage: Usage;
1307
+ /**
1308
+ * Visible output tokens a history rewrite (shake) has since removed from
1309
+ * `content`. `usage.output` still counts them, so a replay estimate subtracts
1310
+ * them to keep the turn's reasoning without recharging the removed text.
1311
+ */
1312
+ elidedOutputTokens?: number;
1290
1313
  stopReason: StopReason;
1291
1314
  stopDetails?: StopDetails | null;
1292
1315
  errorMessage?: string;
@@ -1661,7 +1684,8 @@ export type AssistantMessageEvent =
1661
1684
  export interface AnthropicFallbackCreditHandle {
1662
1685
  token: string;
1663
1686
  prefillClaim?: boolean | null;
1664
- params: unknown;
1687
+ /** The refused request's body, replayed with the credit token on the fallback model. */
1688
+ params: MessageCreateParamsStreaming;
1665
1689
  betas?: readonly string[];
1666
1690
  betaHeader?: string;
1667
1691
  expiresAt: number;
@@ -1,5 +1,6 @@
1
1
  import { classifyModel } from "@linxiraos/pi-catalog/compat/taxonomy";
2
2
  import { isAnthropicOAuthToken, toNumber } from "@linxiraos/pi-catalog/utils";
3
+ import { slugify } from "@linxiraos/pi-utils";
3
4
  import * as AIError from "../error";
4
5
  import {
5
6
  type CredentialRankingContext,
@@ -545,14 +546,6 @@ function buildUsageLimit(args: {
545
546
  };
546
547
  }
547
548
 
548
- function slugifyClaudeLimitDisplayName(displayName: string): string {
549
- return displayName
550
- .trim()
551
- .toLowerCase()
552
- .replace(/[^a-z0-9]+/g, "-")
553
- .replace(/^-+|-+$/g, "");
554
- }
555
-
556
549
  /**
557
550
  * Scoped weekly rows are per-model-family counters, not account-wide windows.
558
551
  * They deliberately leave `scope.shared` unset so credential-wide exhaustion
@@ -564,7 +557,7 @@ function buildScopedWeeklyUsageLimits(entries: readonly ParsedApiLimitEntry[]):
564
557
  const limits: UsageLimit[] = [];
565
558
  for (const entry of entries) {
566
559
  if (entry.kind !== "weekly_scoped" || !entry.displayName) continue;
567
- const slug = slugifyClaudeLimitDisplayName(entry.displayName);
560
+ const slug = slugify(entry.displayName);
568
561
  if (!slug || seenSlugs.has(slug)) continue;
569
562
  seenSlugs.add(slug);
570
563
  const limit = buildUsageLimit({
@@ -9,6 +9,7 @@ import { COPILOT_GITHUB_HEADERS } from "@linxiraos/pi-catalog/wire/github-copilo
9
9
  import * as AIError from "../error";
10
10
  import type {
11
11
  UsageAmount,
12
+ UsageUnit,
12
13
  UsageFetchContext,
13
14
  UsageFetchParams,
14
15
  UsageLimit,
@@ -47,7 +48,7 @@ type BillingUsageItem = {
47
48
  product: string;
48
49
  sku: string;
49
50
  model?: string;
50
- unitType: string;
51
+ unitType?: string;
51
52
  grossQuantity: number;
52
53
  netQuantity: number;
53
54
  limit?: number;
@@ -266,6 +267,23 @@ function normalizeQuotaSnapshots(
266
267
  return { limits, window };
267
268
  }
268
269
 
270
+ /**
271
+ * The unit GitHub's billing API metered one usage item in, plus the label
272
+ * that names it. Only AI credits is recognised: any other value keeps the
273
+ * legacy wording, since an unrecognised unit is not evidence about how the
274
+ * account is billed.
275
+ */
276
+ function resolveBillingUnit(unitType: string | undefined): { unit: UsageUnit; label: string } {
277
+ if (typeof unitType !== "string") return { unit: "requests", label: "Premium Requests" };
278
+ // "AI_CREDITS", "ai-credits" and "Ai Credits" all name the same unit.
279
+ return unitType
280
+ .toUpperCase()
281
+ .replace(/[^A-Z]/g, "")
282
+ .includes("CREDIT")
283
+ ? { unit: "credits", label: "AI Credits" }
284
+ : { unit: "requests", label: "Premium Requests" };
285
+ }
286
+
269
287
  function normalizeBillingUsage(data: BillingUsageResponse): UsageLimit[] {
270
288
  const limits: UsageLimit[] = [];
271
289
  const periodLabel = data.timePeriod.month
@@ -279,12 +297,15 @@ function normalizeBillingUsage(data: BillingUsageResponse): UsageLimit[] {
279
297
  const premiumItems = data.usageItems.filter(
280
298
  item => item.sku === "Copilot Premium Request" || item.sku.includes("Premium"),
281
299
  );
300
+ // The allowance is metered in one unit, and the items name it. Take it from
301
+ // the first item that states a unit rather than assuming the legacy one.
302
+ const allowanceUnit = resolveBillingUnit(premiumItems.find(item => item.unitType !== undefined)?.unitType);
282
303
  const totalUsed = premiumItems.reduce((sum, item) => sum + item.grossQuantity, 0);
283
304
  const totalLimit = premiumItems.reduce((sum, item) => sum + (item.limit ?? 0), 0) || undefined;
284
- const totalAmount = buildAmount(totalUsed, totalLimit, "requests");
305
+ const totalAmount = buildAmount(totalUsed, totalLimit, allowanceUnit.unit);
285
306
  limits.push({
286
307
  id: "copilot:premium",
287
- label: "Premium Requests",
308
+ label: allowanceUnit.label,
288
309
  scope: {
289
310
  provider: "github-copilot",
290
311
  accountId: data.user,
@@ -298,7 +319,8 @@ function normalizeBillingUsage(data: BillingUsageResponse): UsageLimit[] {
298
319
  for (const item of data.usageItems) {
299
320
  if (!item.model) continue;
300
321
  if (item.grossQuantity <= 0) continue;
301
- const amount = buildAmount(item.grossQuantity, item.limit, "requests");
322
+ // Each model row is metered in its own item's unit, not the legacy default.
323
+ const amount = buildAmount(item.grossQuantity, item.limit, resolveBillingUnit(item.unitType).unit);
302
324
  limits.push({
303
325
  id: `copilot:model:${item.model}`,
304
326
  label: `Model ${item.model}`,
@@ -1,7 +1,8 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  import { planRequirementFor, quotaTierFor } from "@linxiraos/pi-catalog/compat/behavior";
3
3
  import { toNumber } from "@linxiraos/pi-catalog/utils";
4
- import { USER_AGENT } from "@linxiraos/pi-utils";
4
+ import { slugify, USER_AGENT } from "@linxiraos/pi-utils";
5
+ import { ProviderHttpError } from "../error";
5
6
  import type {
6
7
  CredentialRankingContext,
7
8
  CredentialRankingStrategy,
@@ -393,12 +394,7 @@ function additionalLimitSlug(args: { limitName?: string; meteredFeature?: string
393
394
  const probe = `${args.limitName ?? ""} ${args.meteredFeature ?? ""}`.toLowerCase();
394
395
  if (probe.includes("spark") || probe.includes("bengalfox")) return "spark";
395
396
  const source = (args.meteredFeature ?? args.limitName ?? "extra").toLowerCase();
396
- return (
397
- source
398
- .replace(/^codex[-_]/, "")
399
- .replace(/[^a-z0-9]+/g, "-")
400
- .replace(/^-+|-+$/g, "") || "extra"
401
- );
397
+ return slugify(source.replace(/^codex[-_]/, "")) || "extra";
402
398
  }
403
399
 
404
400
  function additionalDisplayName(slug: string, limitName?: string): string {
@@ -542,11 +538,21 @@ export const openaiCodexUsageProvider: UsageProvider = {
542
538
  try {
543
539
  const response = await ctx.fetch(url, { headers, signal: params.signal });
544
540
  if (!response.ok) {
541
+ // A rejected token must purge the cached report and fail the health
542
+ // check: only a thrown auth status does that, while `null` is the
543
+ // transient-failure path that keeps serving last-good quota.
544
+ if (response.status === 401 || response.status === 403) {
545
+ throw new ProviderHttpError(
546
+ `Codex usage endpoint returned ${response.status} ${response.statusText}`.trim(),
547
+ response.status,
548
+ );
549
+ }
545
550
  ctx.logger?.warn("Codex usage request failed", { status: response.status, provider: params.provider });
546
551
  return null;
547
552
  }
548
553
  payload = await response.json();
549
554
  } catch (error) {
555
+ if (error instanceof ProviderHttpError) throw error;
550
556
  ctx.logger?.warn("Codex usage request error", { provider: params.provider, error: String(error) });
551
557
  return null;
552
558
  }
@@ -15,13 +15,12 @@ import { DAY_MS, HOUR_MS } from "./shared";
15
15
 
16
16
  const OPENCODE_GO_PROVIDER = "opencode-go";
17
17
  const DEFAULT_ENDPOINT = "https://opencode.ai/zen/go";
18
+ const MODEL_ENDPOINT = "https://opencode.ai/inference/go";
18
19
  const USAGE_PATH = "/v1/usage";
19
20
 
20
21
  /**
21
- * `GET /zen/go/v1/usage` response windows. The route is first-party but
22
- * undocumented (`anomalyco/opencode` `packages/console/app/src/routes/zen/go/v1/usage.ts`)
23
- * and its shape changed once on merge day, so each window is decoded
24
- * defensively and malformed windows are skipped rather than failing the report.
22
+ * `GET /zen/go/v1/usage` response windows. The account endpoint remains on
23
+ * Zen even though inference and model discovery use `/inference/go/v1`.
25
24
  *
26
25
  * Per window: `status` is `"ok" | "rate-limited"`, `percent` is a floored,
27
26
  * clamped integer 0-100, and `resetsAt` is an ISO timestamp computed server
@@ -36,11 +35,11 @@ const OPENCODE_GO_WINDOWS = [
36
35
 
37
36
  function normalizeBaseUrl(baseUrl?: string): string {
38
37
  if (!baseUrl?.trim()) return DEFAULT_ENDPOINT;
39
- // Strip a trailing `/v1` (models.json carries both `zen/go` and
40
- // `zen/go/v1` base URLs) so the usage path doesn't double it, while
41
- // preserving any path-mounted gateway prefix.
38
+ // Preserve custom gateway prefixes while mapping first-party model URLs
39
+ // back to the separate Zen account endpoint.
42
40
  const withoutTrailingSlash = baseUrl.trim().replace(/\/+$/, "");
43
- return withoutTrailingSlash.replace(/\/v1$/i, "") || DEFAULT_ENDPOINT;
41
+ const normalized = withoutTrailingSlash.replace(/\/v1$/i, "");
42
+ return normalized === MODEL_ENDPOINT ? DEFAULT_ENDPOINT : normalized || DEFAULT_ENDPOINT;
44
43
  }
45
44
 
46
45
  function resolveStatus(windowStatus: unknown, usedFraction: number): UsageStatus {
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import { toNumber } from "@linxiraos/pi-catalog/utils";
13
+ import { slugify } from "@linxiraos/pi-utils";
13
14
  import { isUsageLimitExhausted } from "../auth/usage-report";
14
15
  import {
15
16
  buildXAICliBillingUrl,
@@ -95,14 +96,6 @@ function buildPercentAmount(usagePercent: number): UsageAmount {
95
96
  };
96
97
  }
97
98
 
98
- function slugifyProduct(product: string): string {
99
- return product
100
- .trim()
101
- .toLowerCase()
102
- .replace(/[^a-z0-9]+/g, "-")
103
- .replace(/^-+|-+$/g, "");
104
- }
105
-
106
99
  function buildPeriodWindow(period: XaiBillingPeriod): UsageWindow {
107
100
  return {
108
101
  id: "1w",
@@ -262,7 +255,7 @@ function buildLimits(config: XaiBillingConfig, accountId: string | undefined): U
262
255
 
263
256
  for (const item of config.productUsage) {
264
257
  const amount = buildPercentAmount(item.usagePercent);
265
- const slug = slugifyProduct(item.product);
258
+ const slug = slugify(item.product);
266
259
  if (!slug) continue;
267
260
  limits.push({
268
261
  id: `${PROVIDER_ID}:product:${slug}:1w`,
@@ -13,8 +13,8 @@
13
13
  import type { AssistantMessage, Model } from "../types";
14
14
 
15
15
  // Structural DSML tags in either pipe spelling (fullwidth `|` U+FF5C or ASCII),
16
- // tolerating stray whitespace inside the delimiter run.
17
- const DSML_TAG_PATTERN = /<\s*\/?\s*[||]\s*DSML\s*[||]\s*(?:tool_calls|invoke|parameter)\b[^<>]*>?/gu;
16
+ // including doubled delimiters and the observed malformed ` calls` wrapper.
17
+ const DSML_TAG_PATTERN = /<\s*\/?\s*[||]{1,2}\s*DSML\s*[||]{1,2}\s*(?:tool_calls|invoke|parameter|calls)\b[^<>]*>?/gu;
18
18
  // Code the model deliberately quoted (fences, inline spans) is not a leak.
19
19
  const QUOTED_CODE_PATTERN = /(```|~~~)[\s\S]*?(?:\1|$)|`[^`\n]*`/g;
20
20
  // The identifier-only line a degraded model writes in place of
@@ -89,21 +89,37 @@ export function stripDsmlToolMarkup(text: string): string | undefined {
89
89
  }
90
90
 
91
91
  /**
92
- * Remove leaked DSML tool-call markup from `message`'s text blocks in place,
93
- * dropping blocks left empty. Returns true when anything was removed.
92
+ * Remove leaked DSML tool-call markup from an assistant message before commit,
93
+ * including reasoning blocks and turns with valid structured calls. Rewritten
94
+ * thinking with an opaque signature is discarded; OpenAI-compatible reasoning
95
+ * field names are replay markers, not signatures, and survive cleanup.
94
96
  */
95
97
  export function removeDsmlToolMarkupLeak(message: AssistantMessage): boolean {
96
- let removed = false;
97
- const content: AssistantMessage["content"] = [];
98
- for (const block of message.content) {
99
- const stripped = block.type === "text" ? stripDsmlToolMarkup(block.text) : undefined;
100
- if (block.type !== "text" || stripped === undefined) {
101
- content.push(block);
98
+ let content: AssistantMessage["content"] | undefined;
99
+ for (let index = 0; index < message.content.length; index++) {
100
+ const block = message.content[index]!;
101
+ if (block.type !== "text" && block.type !== "thinking") {
102
+ content?.push(block);
102
103
  continue;
103
104
  }
104
- removed = true;
105
- if (stripped.trim().length > 0) content.push({ ...block, text: stripped });
105
+ const stripped = stripDsmlToolMarkup(block.type === "text" ? block.text : block.thinking);
106
+ if (stripped === undefined) {
107
+ content?.push(block);
108
+ continue;
109
+ }
110
+ content ??= message.content.slice(0, index);
111
+ if (block.type === "text") {
112
+ if (stripped.trim().length > 0) content.push({ ...block, text: stripped });
113
+ } else {
114
+ const signature = block.thinkingSignature;
115
+ const reasoningField =
116
+ (message.api === "openai-completions" || message.api === "openrouter") &&
117
+ (signature === "reasoning_content" || signature === "reasoning" || signature === "reasoning_text");
118
+ if ((!signature || reasoningField) && stripped.trim().length > 0) {
119
+ content.push({ ...block, thinking: stripped });
120
+ }
121
+ }
106
122
  }
107
- if (removed) message.content = content;
108
- return removed;
123
+ if (content) message.content = content;
124
+ return content !== undefined;
109
125
  }
@@ -141,6 +141,16 @@ export function wrapLeakedThinkingStream(inner: AssistantMessageEventStream): As
141
141
  return;
142
142
  }
143
143
  case "error": {
144
+ // An error message with empty content means the provider
145
+ // discarded the partial turn; forward it as-is rather than
146
+ // rebuild it from #partial, which would resurrect exactly what
147
+ // was discarded. Non-empty error content is still healed: a
148
+ // provider that reports partial output alongside the error did
149
+ // send that output.
150
+ if (projector !== undefined && event.error.content.length === 0) {
151
+ out.push({ type: "error", reason: event.reason, error: event.error });
152
+ return;
153
+ }
144
154
  projector ??= new LeakedThinkingProjector(out, event.error);
145
155
  const content = projector.finish(event.error);
146
156
  out.push({ type: "error", reason: event.reason, error: { ...event.error, content } });
@@ -0,0 +1,18 @@
1
+ import type { AssistantMessage } from "../types";
2
+
3
+ /**
4
+ * The message as a user-facing surface should present it: every narration
5
+ * thinking block (see `ThinkingContent.narration`) becomes a text block, so it
6
+ * renders as part of the answer instead of folding into reasoning. Returns the
7
+ * input unchanged when it holds no narration. Display-only: replay must keep
8
+ * the original thinking blocks and their signatures.
9
+ */
10
+ export function presentNarrationAsAnswer(message: AssistantMessage): AssistantMessage {
11
+ if (!message.content.some(block => block.type === "thinking" && block.narration)) return message;
12
+ return {
13
+ ...message,
14
+ content: message.content.map(block =>
15
+ block.type === "thinking" && block.narration ? { type: "text", text: block.thinking } : block,
16
+ ),
17
+ };
18
+ }
@@ -68,6 +68,8 @@ export interface OpenAIStreamRequestInit {
68
68
  body: unknown;
69
69
  signal: AbortSignal;
70
70
  fetch?: FetchImpl;
71
+ /** Override the default transport attempt budget for this request. */
72
+ maxAttempts?: number;
71
73
  /** Optional caller-specific gate composed with shared transport retry exclusions. */
72
74
  shouldRetryResponse?: (response: Response, bodyText: string) => boolean | Promise<boolean>;
73
75
  /**
@@ -96,24 +98,31 @@ export interface OpenAIStreamHandle<TEvent> {
96
98
  * watchdog timers and abort-reason bookkeeping.
97
99
  */
98
100
  export async function postOpenAIStream<TEvent>(init: OpenAIStreamRequestInit): Promise<OpenAIStreamHandle<TEvent>> {
99
- const response = await fetchWithRetry(init.url, {
100
- method: "POST",
101
- headers: { "Content-Type": "application/json", Accept: "text/event-stream", ...init.headers },
102
- body: JSON.stringify(init.body),
103
- signal: init.signal,
104
- fetch: init.fetch,
105
- maxAttempts: DEFAULT_MAX_ATTEMPTS,
106
- // A proxy concurrency-admission 429 (`rate_limit_type: max_parallel_requests`)
107
- // surfaces immediately instead of being slept-and-retried here; session
108
- // recovery owns its backoff/fallback (issue #8854).
109
- shouldRetryResponse: async (response, bodyText) =>
110
- !isConcurrencyAdmissionRejection(response, bodyText) &&
111
- (init.shouldRetryResponse === undefined || (await init.shouldRetryResponse(response, bodyText))),
112
- // Bun's native fetch enforces a hard ~300s pre-response timeout (issue #2422).
113
- // Cold large-context streams legitimately exceed it; the caller's
114
- // `firstEventTimeoutMs`/`AbortSignal` already govern stuck requests.
115
- timeout: false,
116
- });
101
+ let response: Response;
102
+ try {
103
+ response = await fetchWithRetry(init.url, {
104
+ method: "POST",
105
+ headers: { "Content-Type": "application/json", Accept: "text/event-stream", ...init.headers },
106
+ body: JSON.stringify(init.body),
107
+ signal: init.signal,
108
+ fetch: init.fetch,
109
+ maxAttempts: init.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
110
+ // A proxy concurrency-admission 429 (`rate_limit_type: max_parallel_requests`)
111
+ // surfaces immediately instead of being slept-and-retried here; session
112
+ // recovery owns its backoff/fallback (issue #8854).
113
+ shouldRetryResponse: async (response, bodyText) =>
114
+ !isConcurrencyAdmissionRejection(response, bodyText) &&
115
+ (init.shouldRetryResponse === undefined || (await init.shouldRetryResponse(response, bodyText))),
116
+ // Bun's native fetch enforces a hard ~300s pre-response timeout (issue #2422).
117
+ // Cold large-context streams legitimately exceed it; the caller's
118
+ // `firstEventTimeoutMs`/`AbortSignal` already govern stuck requests.
119
+ timeout: false,
120
+ });
121
+ } catch (error) {
122
+ // Transport rejection after the retry budget: name the host and the cause
123
+ // instead of surfacing the runtime's bare `getaddrinfo …` text.
124
+ throw AIError.networkFailureError(error, init.url);
125
+ }
117
126
  if (!response.ok) {
118
127
  throw await captureOpenAIHttpError(response);
119
128
  }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Bytes of a request body as the server reads them: the `gzip`/`zstd`
3
+ * `Content-Encoding` applied by request compression is undone, any other
4
+ * encoding is returned untouched.
5
+ */
6
+ export function decodeRequestBodyBytes(bytes: Uint8Array, contentEncoding: string | null): Uint8Array {
7
+ switch (contentEncoding?.trim().toLowerCase()) {
8
+ case "gzip":
9
+ return Bun.gunzipSync(bytes as Uint8Array<ArrayBuffer>);
10
+ case "zstd":
11
+ return Bun.zstdDecompressSync(bytes);
12
+ default:
13
+ return bytes;
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Text of a string or byte request body after undoing its content encoding;
19
+ * `undefined` when the request has no such body (none, stream, form data).
20
+ */
21
+ export function requestBodyText(init: RequestInit | undefined): string | undefined {
22
+ const body = init?.body;
23
+ if (typeof body === "string") return body;
24
+ if (!(body instanceof Uint8Array)) return undefined;
25
+ const encoding = new Headers(init?.headers).get("content-encoding");
26
+ return new TextDecoder().decode(decodeRequestBodyBytes(body, encoding));
27
+ }