@linxiraos/pi-ai 1.2.1 → 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 +7 -7
  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
@@ -1,7 +1,9 @@
1
1
  import type { JudgmentRequest, JudgmentResult } from "../judgment/types.js";
2
+ import type { JudgmentImageAssets } from "../judgment/wire.js";
2
3
  export interface SystemOneParsedRequest {
3
4
  modelId: string;
4
5
  request: JudgmentRequest;
6
+ imageAssets?: JudgmentImageAssets;
5
7
  }
6
8
  /** `POST /v1/systemone` response body; `cost` (USD) appears only when the route billed one. */
7
9
  export interface SystemOneResponseBody {
@@ -14,10 +16,12 @@ export interface SystemOneResponseBody {
14
16
  };
15
17
  }
16
18
  /**
17
- * Validate the routing-relevant parts of a System One request body.
19
+ * Validate the routing-relevant parts of a System One request body, including
20
+ * top-level embedded images and inline TypeSafe image assets.
18
21
  *
19
- * @throws {AIError.ValidationError} naming the offending field when `model`,
20
- * `state`, or `questions` is missing or malformed.
22
+ * @throws {AIError.ValidationError} when required routing fields are missing
23
+ * or malformed, supplied image data is invalid, or an asset uses multipart
24
+ * `part`, which this JSON ingress cannot read.
21
25
  */
22
26
  export declare function parseRequest(body: unknown): SystemOneParsedRequest;
23
27
  /** Re-encode a judgment result in TypeSafe's response shape. */
@@ -9,6 +9,10 @@ import type { Api, AssistantMessage, Message, Model } from "../types.js";
9
9
  * MUST not push a normalized id past this bound.
10
10
  */
11
11
  export declare const MAX_TOOL_CALL_ID_LENGTH = 64;
12
+ /** Replace unsupported tool-call ID characters and cap the result for strict replay providers. */
13
+ export declare function normalizeToolCallId(id: string, maxLength?: number): string;
14
+ /** Whether `id` already satisfies the strict 1–64 `[a-zA-Z0-9_-]` tool-call ID contract. */
15
+ export declare function isValidToolCallId(id: string): boolean;
12
16
  /**
13
17
  * OpenAI Responses-family APIs mint composite tool ids (`call_id|item_id`);
14
18
  * opaque Chat Completions ids do not (openai-completions preserves same-model
@@ -25,12 +29,29 @@ export declare function isResponsesFamilyApi(api: Api | undefined): boolean;
25
29
  export declare function responsesCallComponent(id: string): string;
26
30
  export declare function appendDuplicateSuffix(originalId: string, suffix: string, maxLength: number): string;
27
31
  /**
28
- * Whether a tool-call name cannot belong to any declared tool: missing, empty,
29
- * longer than OpenAI's 128-character replay limit, or containing whitespace or
30
- * control characters (tool schemas never allow them; their presence means the
31
- * name slot carries invocation text).
32
+ * Whether a tool-call name cannot be replayed to the target: missing, empty,
33
+ * longer than its declaration limit, or containing a character outside its
34
+ * declaration charset. Models emit such names when the name slot carries
35
+ * invocation text (whitespace, NULs) or an invented internal URL (`proc://*`).
36
+ *
37
+ * Without a target, use the OpenAI Responses native-history constraints.
38
+ */
39
+ export declare function isMalformedToolCallName(name: unknown, allowedName?: RegExp, maxLength?: number): boolean;
40
+ /**
41
+ * Whether {@link transformMessages} drops `msg` from every replay: a turn cut
42
+ * off by `length` / `error` / `aborted` that carries no text and no tool call.
43
+ */
44
+ export declare function shouldDropTruncatedThinkingOnlyAssistant(msg: AssistantMessage): boolean;
45
+ /**
46
+ * Whether `msg` reaches a replay as an assistant turn, so it can be the latest
47
+ * surviving one: {@link sanitizeMalformedToolCalls} drops its malformed tool
48
+ * calls, then {@link shouldDropTruncatedThinkingOnlyAssistant} a truncated
49
+ * turn left with no text and no tool call. On an already sanitized list this
50
+ * is `!shouldDropTruncatedThinkingOnlyAssistant(msg)`. Pass the target `model`
51
+ * to apply its tool-name limits; without one, any provider-declarable name survives.
32
52
  */
33
- export declare function isMalformedToolCallName(name: unknown): boolean;
53
+ export declare function isSurvivingAssistant(msg: AssistantMessage, model?: Model): boolean;
54
+ export declare function isAnthropicMessagesModel(model: Model): model is Model<"anthropic-messages">;
34
55
  /**
35
56
  * Normalize tool call ID for cross-provider compatibility.
36
57
  * OpenAI Responses API generates IDs that are 450+ chars with special characters like `|`.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Antigravity OAuth flow (Gemini 3, Claude, GPT-OSS via Google Cloud)
2
+ * Google Antigravity OAuth flow (Gemini, Claude, GPT-OSS via Google Cloud)
3
3
  * Uses different OAuth credentials than google-gemini-cli for access to additional models.
4
4
  */
5
5
  import type { AfterExchangeHook } from "../hooks/types.js";
@@ -5,8 +5,6 @@ import type { GoogleGeminiCliOptions } from "./providers/google-gemini-cli.js";
5
5
  import type { GoogleVertexOptions } from "./providers/google-vertex.js";
6
6
  import type { Api, AssistantMessage, Context, Model, OptionsForApi, SimpleStreamOptions, ToolChoice } from "./types.js";
7
7
  import { AssistantMessageEventStream } from "./utils/event-stream.js";
8
- /** Strict official-Codex endpoint check; exact origin or a path boundary after {@link CODEX_BASE_URL}. */
9
- export declare function isOfficialCodexApiUrl(baseUrl: string | undefined): boolean;
10
8
  export declare function configureProviderMaxInFlightRequests(limits: Record<string, number> | undefined): void;
11
9
  export declare const __providerInFlightForTesting: {
12
10
  setRoot(root: string | undefined): void;
@@ -9,7 +9,7 @@ import type { ApiKey } from "./auth-retry.js";
9
9
  import type { OAuthRequestIdentity } from "./auth/types.js";
10
10
  import type { BedrockOptions } from "./providers/amazon-bedrock.js";
11
11
  import type { AnthropicOptions } from "./providers/anthropic.js";
12
- import type { FallbackParam, StopDetails } from "./providers/anthropic-wire.js";
12
+ import type { FallbackParam, MessageCreateParamsStreaming, StopDetails } from "./providers/anthropic-wire.js";
13
13
  import type { AzureOpenAIResponsesOptions } from "./providers/azure-openai-responses.js";
14
14
  import type { CursorOptions } from "./providers/cursor.js";
15
15
  import type { AppleFoundationModelsOptions } from "./providers/apple-foundation-models.js";
@@ -267,6 +267,11 @@ export interface StreamOptions {
267
267
  frequencyPenalty?: number;
268
268
  maxTokens?: number;
269
269
  signal?: AbortSignal;
270
+ /**
271
+ * OpenAI chat-completions HTTP attempt budget. When set, transient provider
272
+ * errors are not replayed above the transport, so retries share this limit.
273
+ */
274
+ maxHttpAttempts?: number;
270
275
  apiKey?: string;
271
276
  /** @internal Stored credential row serving this request, when known. */
272
277
  credentialId?: number;
@@ -398,8 +403,6 @@ export interface StreamOptions {
398
403
  liveSteering?: LiveSteering;
399
404
  /** Canonical Codex compaction classification; ignored by other providers. */
400
405
  codexCompaction?: CodexCompactionRequestContext;
401
- /** Codex Code Mode tool exposure snapshot emitted as `tool_namespaces_info` turn metadata; ignored by other providers. */
402
- toolNamespacesInfo?: unknown;
403
406
  /**
404
407
  * Optional per-provider concurrent request cap for LLM stream calls. Keys are
405
408
  * provider ids (`model.provider`); positive numeric values cap in-flight
@@ -645,6 +648,13 @@ export interface ThinkingContent {
645
648
  * replay natively on another host.
646
649
  */
647
650
  summary?: boolean;
651
+ /**
652
+ * `true`: the block is narration — prose the model addressed to the user
653
+ * between tool calls, which the provider returned as a signed thinking block
654
+ * (Anthropic connector-text summarization). It stays a thinking block for
655
+ * replay; user-facing surfaces present it as answer text.
656
+ */
657
+ narration?: boolean;
648
658
  }
649
659
  export interface RedactedThinkingContent {
650
660
  type: "redactedThinking";
@@ -652,11 +662,12 @@ export interface RedactedThinkingContent {
652
662
  }
653
663
  /**
654
664
  * Anthropic server-side-fallback boundary marker persisted on assistant
655
- * turns whose provider request opted into
656
- * `AnthropicOptions.fallbacks`. Consumers other than the Anthropic
657
- * provider MUST ignore it — `transformMessages` strips the block on any
658
- * cross-provider hop and on non-official Anthropic replays, so downstream
659
- * converters never see it.
665
+ * turns whose response switched models mid-stream, whether the request
666
+ * opted into `AnthropicOptions.fallbacks` or a gateway opted in on its
667
+ * behalf. Consumers other than the Anthropic provider MUST ignore it —
668
+ * `transformMessages` strips the block on any non-Anthropic hop, and
669
+ * `convertAnthropicMessages` only puts it on the wire for an opted-in
670
+ * request to the official endpoint.
660
671
  */
661
672
  export interface AnthropicFallbackContent {
662
673
  type: "fallback";
@@ -983,6 +994,12 @@ export type AssistantRetryRecovery = {
983
994
  export interface ContextSnapshot {
984
995
  promptTokens: number;
985
996
  nonMessageTokens: number;
997
+ /**
998
+ * Estimated tool-schema tokens included in `nonMessageTokens` (0 when the
999
+ * model defers schemas server-side). Lets a later model whose schema policy
1000
+ * differs rebase the provider anchor instead of inheriting its schemas.
1001
+ */
1002
+ toolSchemaTokens?: number;
986
1003
  /** Estimated prompt tokens removed by local history rewrites after this provider snapshot was recorded. */
987
1004
  historyRewriteTokensRemoved?: number;
988
1005
  /**
@@ -1031,6 +1048,12 @@ export interface AssistantMessage {
1031
1048
  */
1032
1049
  serviceTier?: ServiceTier;
1033
1050
  usage: Usage;
1051
+ /**
1052
+ * Visible output tokens a history rewrite (shake) has since removed from
1053
+ * `content`. `usage.output` still counts them, so a replay estimate subtracts
1054
+ * them to keep the turn's reasoning without recharging the removed text.
1055
+ */
1056
+ elidedOutputTokens?: number;
1034
1057
  stopReason: StopReason;
1035
1058
  stopDetails?: StopDetails | null;
1036
1059
  errorMessage?: string;
@@ -1418,7 +1441,8 @@ export type AssistantMessageEvent = {
1418
1441
  export interface AnthropicFallbackCreditHandle {
1419
1442
  token: string;
1420
1443
  prefillClaim?: boolean | null;
1421
- params: unknown;
1444
+ /** The refused request's body, replayed with the credit token on the fallback model. */
1445
+ params: MessageCreateParamsStreaming;
1422
1446
  betas?: readonly string[];
1423
1447
  betaHeader?: string;
1424
1448
  expiresAt: number;
@@ -23,7 +23,9 @@ export declare function isDsmlLeakRecoveryTarget(model: Model): boolean;
23
23
  */
24
24
  export declare function stripDsmlToolMarkup(text: string): string | undefined;
25
25
  /**
26
- * Remove leaked DSML tool-call markup from `message`'s text blocks in place,
27
- * dropping blocks left empty. Returns true when anything was removed.
26
+ * Remove leaked DSML tool-call markup from an assistant message before commit,
27
+ * including reasoning blocks and turns with valid structured calls. Rewritten
28
+ * thinking with an opaque signature is discarded; OpenAI-compatible reasoning
29
+ * field names are replay markers, not signatures, and survive cleanup.
28
30
  */
29
31
  export declare function removeDsmlToolMarkupLeak(message: AssistantMessage): boolean;
@@ -0,0 +1,9 @@
1
+ import type { AssistantMessage } from "../types.js";
2
+ /**
3
+ * The message as a user-facing surface should present it: every narration
4
+ * thinking block (see `ThinkingContent.narration`) becomes a text block, so it
5
+ * renders as part of the answer instead of folding into reasoning. Returns the
6
+ * input unchanged when it holds no narration. Display-only: replay must keep
7
+ * the original thinking blocks and their signatures.
8
+ */
9
+ export declare function presentNarrationAsAnswer(message: AssistantMessage): AssistantMessage;
@@ -26,6 +26,8 @@ export interface OpenAIStreamRequestInit {
26
26
  body: unknown;
27
27
  signal: AbortSignal;
28
28
  fetch?: FetchImpl;
29
+ /** Override the default transport attempt budget for this request. */
30
+ maxAttempts?: number;
29
31
  /** Optional caller-specific gate composed with shared transport retry exclusions. */
30
32
  shouldRetryResponse?: (response: Response, bodyText: string) => boolean | Promise<boolean>;
31
33
  /**
@@ -0,0 +1,11 @@
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 declare function decodeRequestBodyBytes(bytes: Uint8Array, contentEncoding: string | null): Uint8Array;
7
+ /**
8
+ * Text of a string or byte request body after undoing its content encoding;
9
+ * `undefined` when the request has no such body (none, stream, form data).
10
+ */
11
+ export declare function requestBodyText(init: RequestInit | undefined): string | undefined;
@@ -0,0 +1,21 @@
1
+ import type { FetchImpl, Model } from "../types.js";
2
+ /**
3
+ * Compresses request bodies from {@link model} to its provider's own endpoint
4
+ * when its catalog rules declare encodings the endpoint decodes
5
+ * (`request-encodings` / `endpoint-hosts` KDL axes). Long sessions resend
6
+ * multi-MB transcripts every turn, so upload time dominates time-to-first-token
7
+ * on ordinary uplinks.
8
+ *
9
+ * Install it beneath any body rewriter or signer (e.g. the cch attestation
10
+ * patch): those must see the uncompressed bytes. Other hosts, small bodies,
11
+ * bodies that already carry `Content-Encoding`, and `PI_REQUEST_COMPRESSION=0`
12
+ * pass through untouched. A possible rejection — the host's decode error, or a
13
+ * non-JSON 400/403/415 from a proxy in front of it — is resent uncompressed
14
+ * once; only when that resend succeeds is the encoding latched off for the
15
+ * host, so a genuine 400 never disables compression. When a non-JSON refusal
16
+ * recurs uncompressed with the same status and a non-JSON body, that status
17
+ * stops being re-checked for the host.
18
+ */
19
+ export declare function wrapFetchForRequestCompression(base: FetchImpl, model: Model): FetchImpl;
20
+ /** Test hook: clears every per-host latch. */
21
+ export declare function resetRequestCompressionForTesting(): void;
@@ -5,7 +5,8 @@ export declare function parseToolCallArguments(json: string | undefined): ToolCa
5
5
  * Native Responses `function_call.arguments` to persist for replay.
6
6
  *
7
7
  * {@link parseToolCallArguments} repairs some invalid JSON (e.g. `"name": ,`) and the tool runs with the
8
- * repaired value; replay drops a call whose stored arguments do not parse, orphaning its output (#14155).
8
+ * repaired value; replay must carry what was executed (#14155), not the {@link invalidToolCallArguments}
9
+ * diagnostic it substitutes for stored arguments that do not parse (#15568).
9
10
  * Returns `raw` when it already parses or the arguments were unrepairable, else the executed arguments.
10
11
  */
11
12
  export declare function replayableToolCallArguments(raw: string, executed: ToolCall["arguments"]): string;
@@ -12,6 +12,14 @@ import type { Api, FetchImpl, Model } from "../types.js";
12
12
  * layer (three PI_REQ_DEBUG dumps for one request).
13
13
  */
14
14
  export declare function transportFetch(model: Model<Api>, fetchImpl: FetchImpl | undefined): FetchImpl;
15
+ /**
16
+ * Origin of the last inference request {@link model} actually sent, after
17
+ * every dispatch-time rewrite (credential endpoints, env overrides, regional
18
+ * hosts); `undefined` before its first request.
19
+ */
20
+ export declare function lastDispatchOrigin(model: Model<Api>): string | undefined;
21
+ /** Test hook: forgets every recorded dispatch origin. */
22
+ export declare function resetDispatchOriginsForTesting(): void;
15
23
  /** Options-bag form of {@link transportFetch}; returns `options` untouched when its fetch is already built. */
16
24
  export declare function withTransportFetch<T extends {
17
25
  fetch?: FetchImpl;
@@ -1,4 +1,5 @@
1
1
  import type { ResponseInput } from "./providers/openai-responses-wire.js";
2
+ export { normalizeToolCallId } from "./providers/transform-messages.js";
2
3
  import type { CacheRetention, OpenAIResponsesHistoryPayload, ProviderPayload } from "./types.js";
3
4
  export { isRecord } from "@linxiraos/pi-utils";
4
5
  /**
@@ -9,7 +10,6 @@ export { isRecord } from "@linxiraos/pi-utils";
9
10
  */
10
11
  export declare function getHeaderCaseInsensitive(headers: Record<string, string> | undefined, headerName: string): string | undefined;
11
12
  export declare function normalizeSystemPrompts(systemPrompt: readonly string[] | string | undefined | null): string[];
12
- export declare function normalizeToolCallId(id: string): string;
13
13
  type ResponsesToolItemIdPrefix = "fc" | "ctc";
14
14
  /** Preserve opaque call IDs for Responses replay while normalizing or generating the separate item ID. */
15
15
  export declare function normalizeResponsesToolCallId(id: string, itemPrefix?: ResponsesToolItemIdPrefix): {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linxiraos/pi-ai",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "keywords": [
6
6
  "ai",
@@ -145,18 +145,18 @@
145
145
  },
146
146
  "scripts": {
147
147
  "check": "oxlint . && oxfmt --check --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts' && bun run check:types",
148
- "check:types": "tsgo -p tsconfig.json --noEmit",
148
+ "check:types": "tsc -p tsconfig.json --noEmit",
149
149
  "lint": "oxlint .",
150
150
  "test": "bun test --parallel",
151
151
  "fix": "oxlint --fix --fix-suggestions . && bun run fmt",
152
152
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
153
153
  },
154
154
  "dependencies": {
155
- "@linxiraos/pi-omptype": "1.2.1",
156
- "@linxiraos/pi-catalog": "1.2.1",
157
- "@linxiraos/pi-natives": "1.2.1",
158
- "@linxiraos/pi-utils": "1.2.1",
159
- "@linxiraos/pi-wire": "1.2.1"
155
+ "@linxiraos/pi-omptype": "1.3.0",
156
+ "@linxiraos/pi-catalog": "1.3.0",
157
+ "@linxiraos/pi-natives": "1.3.0",
158
+ "@linxiraos/pi-utils": "1.3.0",
159
+ "@linxiraos/pi-wire": "1.3.0"
160
160
  },
161
161
  "devDependencies": {
162
162
  "@types/bun": "1.4.3"
@@ -10,6 +10,7 @@ import type { CredentialPool } from "./pool";
10
10
  import type { CredentialSelector, OAuthResolutionResult } from "./select";
11
11
  import type {
12
12
  AuthApiKeyOptions,
13
+ AuthPeekOptions,
13
14
  AuthCredential,
14
15
  AuthSource,
15
16
  AuthSourceOptions,
@@ -275,9 +276,11 @@ export class KeyCascade implements KeysApi {
275
276
  * Used for model discovery where we only need to know if credentials exist
276
277
  * and get a best-effort token. GitHub Copilot's peek must preserve
277
278
  * enterprise routing metadata because discovery needs a structured
278
- * credential to reach the correct host.
279
+ * credential to reach the correct host. With several stored accounts every
280
+ * peek returns the same one, so credential-scoped discovery caches stay on
281
+ * one namespace.
279
282
  */
280
- async peek(provider: string): Promise<string | undefined> {
283
+ async peek(provider: string, options?: AuthPeekOptions): Promise<string | undefined> {
281
284
  const runtimeKey = this.#deps.overrides.runtimeKey(provider);
282
285
  if (runtimeKey) {
283
286
  return runtimeKey;
@@ -292,19 +295,26 @@ export class KeyCascade implements KeysApi {
292
295
 
293
296
  // Precedence: a deliberate OAuth/login credential wins, then an explicit env var,
294
297
  // then a stored static api_key (which may be a stale broker-migrated copy) as a last resort.
295
- const oauthSelection = this.#deps.selector.selectByType(provider, "oauth");
298
+ // Peek never refreshes, so only an unexpired OAuth access token is usable
299
+ // unless the caller only needs the account it names.
300
+ const now = Date.now();
301
+ const oauthSelection =
302
+ this.#deps.selector.selectByType(
303
+ provider,
304
+ "oauth",
305
+ undefined,
306
+ credential =>
307
+ credential.type === "oauth" && Number.isFinite(credential.expires) && credential.expires > now,
308
+ ) ?? (options?.includeExpired ? this.#deps.selector.selectByType(provider, "oauth") : undefined);
296
309
  if (oauthSelection) {
297
- const expiresAt = oauthSelection.credential.expires;
298
- if (Number.isFinite(expiresAt) && expiresAt > Date.now()) {
299
- if (provider === "github-copilot") {
300
- return JSON.stringify({
301
- token: oauthSelection.credential.access,
302
- enterpriseUrl: oauthSelection.credential.enterpriseUrl,
303
- apiEndpoint: oauthSelection.credential.apiEndpoint,
304
- });
305
- }
306
- return oauthSelection.credential.access;
310
+ if (provider === "github-copilot") {
311
+ return JSON.stringify({
312
+ token: oauthSelection.credential.access,
313
+ enterpriseUrl: oauthSelection.credential.enterpriseUrl,
314
+ apiEndpoint: oauthSelection.credential.apiEndpoint,
315
+ });
307
316
  }
317
+ return oauthSelection.credential.access;
308
318
  }
309
319
 
310
320
  const loginApiKeySelection = this.#deps.selector.selectByType(
package/src/auth/pool.ts CHANGED
@@ -182,7 +182,7 @@ export class CredentialPool implements CredentialsApi {
182
182
  * The store is shared across every zeta process, but the pool is an
183
183
  * in-process cache refreshed only by this process's own writes. Without
184
184
  * this a long-running session ranks a stale pool for its whole lifetime:
185
- * `zeta-c auth` in another terminal is invisible, rotation reports no usable
185
+ * `zetacode auth` in another terminal is invisible, rotation reports no usable
186
186
  * sibling while a freshly added account sits unblocked in SQLite, and the
187
187
  * turn degrades to the fallback chain. The auth-broker path already polls;
188
188
  * direct-store sessions had no equivalent.
@@ -719,7 +719,7 @@ export class CredentialPool implements CredentialsApi {
719
719
  }
720
720
 
721
721
  /**
722
- * Disabled credential tombstones for display surfaces (`zeta-c usage`,
722
+ * Disabled credential tombstones for display surfaces (`zetacode usage`,
723
723
  * broker `GET /v1/credentials/disabled`). Empty when the backing store
724
724
  * keeps no tombstones or the remote broker predates the endpoint.
725
725
  */
@@ -732,7 +732,7 @@ export class CredentialPool implements CredentialsApi {
732
732
  * Force the backing store to revalidate its credential snapshot, then
733
733
  * reload. Remote broker stores re-fetch the snapshot; local stores are
734
734
  * always current, so only the reload runs. Callers that pair live
735
- * per-credential data with stored identities (`zeta-c usage`) use this so a
735
+ * per-credential data with stored identities (`zetacode usage`) use this so a
736
736
  * disk-cached snapshot cannot misattribute fresh reports.
737
737
  */
738
738
  async revalidate(): Promise<void> {
@@ -1,4 +1,4 @@
1
- import { logger } from "@linxiraos/pi-utils";
1
+ import { logger, postmortem } from "@linxiraos/pi-utils";
2
2
  import * as AIError from "../error";
3
3
  import { getOAuthProvider, normalizeOAuthCredentialExpiry, refreshOAuthToken } from "../registry/oauth";
4
4
  import type { OAuthCredentials, OAuthProvider } from "../registry/oauth/types";
@@ -101,6 +101,9 @@ export class OAuthRefresher {
101
101
  #oauthCredentialRefreshInFlight: Map<number, Promise<OAuthCredentials>> = new Map();
102
102
  /** Access token this process last minted per credential id. */
103
103
  #recentMints = new Map<number, { provider: string; access: string; at: number }>();
104
+ #pendingRefreshes = new Set<Promise<unknown>>();
105
+ #cancelRefreshCleanup?: () => void;
106
+ #shuttingDown = false;
104
107
 
105
108
  constructor(deps: OAuthRefresherDeps) {
106
109
  this.#deps = deps;
@@ -148,7 +151,33 @@ export class OAuthRefresher {
148
151
  /**
149
152
  * Refresh one stored OAuth credential under durable row ownership.
150
153
  */
151
- async refreshStored<T extends OAuthCredential = OAuthCredential>(
154
+ refreshStored<T extends OAuthCredential = OAuthCredential>(
155
+ provider: string,
156
+ options: StoredOAuthRefreshOptions<T>,
157
+ ): Promise<StoredOAuthRefreshResult<T>> {
158
+ if (this.#shuttingDown) return Promise.reject(new AIError.AbortError("OAuth refresh stopped during shutdown"));
159
+ this.#cancelRefreshCleanup ??= postmortem.register("oauth-refresh", () => this.#drainRefreshes(), {
160
+ exitOnly: true,
161
+ });
162
+ const pending = this.#refreshStored(provider, options);
163
+ this.#pendingRefreshes.add(pending);
164
+ const settled = () => {
165
+ this.#pendingRefreshes.delete(pending);
166
+ if (this.#pendingRefreshes.size === 0 && !this.#shuttingDown) {
167
+ this.#cancelRefreshCleanup?.();
168
+ this.#cancelRefreshCleanup = undefined;
169
+ }
170
+ };
171
+ void pending.then(settled, settled);
172
+ return pending;
173
+ }
174
+
175
+ async #drainRefreshes(): Promise<void> {
176
+ this.#shuttingDown = true;
177
+ await Promise.allSettled(this.#pendingRefreshes);
178
+ }
179
+
180
+ async #refreshStored<T extends OAuthCredential = OAuthCredential>(
152
181
  provider: string,
153
182
  options: StoredOAuthRefreshOptions<T>,
154
183
  ): Promise<StoredOAuthRefreshResult<T>> {
@@ -158,6 +187,7 @@ export class OAuthRefresher {
158
187
  let leasedCredentialId: number | undefined;
159
188
 
160
189
  while (leaseStore) {
190
+ if (this.#shuttingDown) throw new AIError.AbortError("OAuth refresh stopped during shutdown");
161
191
  if (options.signal?.aborted) throw new AIError.AbortError("OAuth refresh ownership aborted by caller");
162
192
  const rows = this.#deps.store.listAuthCredentials(provider);
163
193
  this.#deps.pool.replace(
@@ -240,6 +270,9 @@ export class OAuthRefresher {
240
270
  if (options.canRefresh && !options.canRefresh(current)) {
241
271
  return { credential: current, refreshed: false, removed: false };
242
272
  }
273
+ if (this.#shuttingDown || options.signal?.aborted) {
274
+ throw new AIError.AbortError("OAuth refresh stopped before token exchange");
275
+ }
243
276
  const serialized = serializeCredential(provider, current);
244
277
  if (!serialized) return { credential: current, refreshed: false, removed: false };
245
278
 
@@ -495,13 +528,7 @@ export class OAuthRefresher {
495
528
  forceRefresh,
496
529
  signal,
497
530
  refresh: (current, refreshSignal) =>
498
- this.#requestOAuthCredentialRefresh(
499
- provider,
500
- current,
501
- credentialId,
502
- signal && refreshSignal ? AbortSignal.any([signal, refreshSignal]) : (signal ?? refreshSignal),
503
- reason,
504
- ),
531
+ this.#requestOAuthCredentialRefresh(provider, current, credentialId, refreshSignal, reason),
505
532
  isDefinitiveFailure: error => AIError.isDefinitiveOAuthFailure(String(error)),
506
533
  disabledCause: error => `oauth refresh failed: ${String(error)}`,
507
534
  });
@@ -564,9 +591,9 @@ export class OAuthRefresher {
564
591
  refreshPromise = refreshOAuthToken(provider as OAuthProvider, credential, signal);
565
592
  }
566
593
  }
567
- // Bound the refresh so a slow/hanging token endpoint cannot stall credential selection.
568
- // Caller-driven abort jumps the gun on the timeout — the agent's ESC must
569
- // take priority over the floor timeout.
594
+ // The caller may stop awaiting a refresh, but the leased token exchange
595
+ // must finish and persist any rotated token. Only the operation timeout
596
+ // aborts the exchange itself.
570
597
  const cancellation = Promise.withResolvers<never>();
571
598
  let onAbort: (() => void) | undefined;
572
599
  const timeout = setTimeout(
@@ -144,7 +144,11 @@ export class CredentialSelector {
144
144
  /**
145
145
  * Selects a credential of the specified type for a provider.
146
146
  * Returns both the credential and its index in the original array (for updates/removal).
147
- * Uses deterministic hashing for session stickiness and skips blocked credentials when possible.
147
+ * With `sessionId` the scan starts at the session's hashed index; without one it starts at the
148
+ * first stored credential and never advances round-robin. Model discovery peeks through this,
149
+ * and credential-scoped catalogs (e.g. GitHub Copilot) key their cache rows on the credential
150
+ * picked, so a rotating pick would read another account's cache namespace on every pass.
151
+ * Blocked credentials are skipped when possible.
148
152
  */
149
153
  selectByType<T extends AuthCredential["type"]>(
150
154
  provider: string,
@@ -171,7 +175,9 @@ export class CredentialSelector {
171
175
  if (credentials.length === 1) return credentials[0];
172
176
 
173
177
  const providerKey = providerTypeKey(provider, type);
174
- const order = this.#getCredentialOrder(providerKey, sessionId, credentials.length);
178
+ const order = sessionId
179
+ ? this.#getCredentialOrder(providerKey, sessionId, credentials.length)
180
+ : credentials.map((_, index) => index);
175
181
  const fallback = credentials[order[0]];
176
182
 
177
183
  for (const idx of order) {
package/src/auth/types.ts CHANGED
@@ -112,7 +112,7 @@ export interface StoredCredentialBlock {
112
112
  /**
113
113
  * Identity slice of a disabled (soft-deleted) credential tombstone — cause and
114
114
  * account identity only, never token material. Surfaced so auto-disabled
115
- * accounts (e.g. an expired Anthropic OAuth grant) stay visible in `zeta-c usage`
115
+ * accounts (e.g. an expired Anthropic OAuth grant) stay visible in `zetacode usage`
116
116
  * instead of silently vanishing until the user notices missing quota.
117
117
  */
118
118
  export interface DisabledCredentialSummary {
@@ -707,6 +707,16 @@ export interface AuthSourceOptions {
707
707
  env?: "dedicated" | "aliases" | "none";
708
708
  }
709
709
 
710
+ /** Options for {@link KeysApi.peek}. */
711
+ export interface AuthPeekOptions {
712
+ /**
713
+ * Fall back to an expired OAuth access token when no stored account holds an
714
+ * unexpired one. Only for reads that never send the token, e.g. keying an
715
+ * offline model-cache lookup on the account it names.
716
+ */
717
+ includeExpired?: boolean;
718
+ }
719
+
710
720
  /** Where a provider's auth comes from ({@link CredentialOrigin}) plus whether any leg holds a concrete key. */
711
721
  export interface AuthSource extends CredentialOrigin {
712
722
  /** False when the only auth is an ambient AUTHENTICATED_SENTINEL source (AWS profile, ADC). */
@@ -830,7 +840,7 @@ export interface CredentialsApi {
830
840
  * Force the backing store to revalidate its credential snapshot, then
831
841
  * reload. Remote broker stores re-fetch the snapshot; local stores are
832
842
  * always current, so only the reload runs. Callers that pair live
833
- * per-credential data with stored identities (`zeta-c usage`) use this so a
843
+ * per-credential data with stored identities (`zetacode usage`) use this so a
834
844
  * disk-cached snapshot cannot misattribute fresh reports.
835
845
  */
836
846
  revalidate(): Promise<void>;
@@ -889,7 +899,7 @@ export interface CredentialsApi {
889
899
  */
890
900
  disable(id: number, disabledCause: string): Promise<boolean>;
891
901
  /**
892
- * Disabled credential tombstones for display surfaces (`zeta-c usage`,
902
+ * Disabled credential tombstones for display surfaces (`zetacode usage`,
893
903
  * broker `GET /v1/credentials/disabled`). Empty when the backing store
894
904
  * keeps no tombstones or the remote broker predates the endpoint.
895
905
  */
@@ -941,9 +951,10 @@ export interface KeysApi {
941
951
  * Peek at API key for a provider without refreshing OAuth tokens.
942
952
  * Used for model discovery where we only need to know if credentials exist
943
953
  * and get a best-effort token. For GitHub Copilot we preserve enterprise
944
- * routing metadata so discovery can hit the correct host.
954
+ * routing metadata so discovery can hit the correct host. Repeated peeks
955
+ * return the same stored credential; they never rotate accounts.
945
956
  */
946
- peek(provider: string): Promise<string | undefined>;
957
+ peek(provider: string, options?: AuthPeekOptions): Promise<string | undefined>;
947
958
  /** Resolve the first active auth source by precedence without refreshing credentials. */
948
959
  source(provider: string, options?: AuthSourceOptions): AuthSource | undefined;
949
960
  /**