@fgv/ts-extras 5.1.0-55 → 5.1.0-57

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 (184) hide show
  1. package/CAPABILITIES.md +96 -0
  2. package/dist/packlets/ai-assist/cacheRequest.js +118 -0
  3. package/dist/packlets/ai-assist/cacheRequest.js.map +1 -0
  4. package/dist/packlets/ai-assist/chatRequestBuilders.js +72 -7
  5. package/dist/packlets/ai-assist/chatRequestBuilders.js.map +1 -1
  6. package/dist/packlets/ai-assist/completionClient.js +195 -58
  7. package/dist/packlets/ai-assist/completionClient.js.map +1 -1
  8. package/dist/packlets/ai-assist/converters.js +54 -0
  9. package/dist/packlets/ai-assist/converters.js.map +1 -1
  10. package/dist/packlets/ai-assist/embeddingClient.js +16 -1
  11. package/dist/packlets/ai-assist/embeddingClient.js.map +1 -1
  12. package/dist/packlets/ai-assist/imageGenerationClient.js +17 -2
  13. package/dist/packlets/ai-assist/imageGenerationClient.js.map +1 -1
  14. package/dist/packlets/ai-assist/index.js +3 -1
  15. package/dist/packlets/ai-assist/index.js.map +1 -1
  16. package/dist/packlets/ai-assist/listModelsClient.js +19 -1
  17. package/dist/packlets/ai-assist/listModelsClient.js.map +1 -1
  18. package/dist/packlets/ai-assist/model.js.map +1 -1
  19. package/dist/packlets/ai-assist/promptCacheAnthropicBreakpointHarness.js +200 -0
  20. package/dist/packlets/ai-assist/promptCacheAnthropicBreakpointHarness.js.map +1 -0
  21. package/dist/packlets/ai-assist/promptCacheObservabilityHarness.js +137 -0
  22. package/dist/packlets/ai-assist/promptCacheObservabilityHarness.js.map +1 -0
  23. package/dist/packlets/ai-assist/promptCacheRoutingAbHarness.js +156 -0
  24. package/dist/packlets/ai-assist/promptCacheRoutingAbHarness.js.map +1 -0
  25. package/dist/packlets/ai-assist/promptCacheUsageMath.js +124 -0
  26. package/dist/packlets/ai-assist/promptCacheUsageMath.js.map +1 -0
  27. package/dist/packlets/ai-assist/registry.js +9 -11
  28. package/dist/packlets/ai-assist/registry.js.map +1 -1
  29. package/dist/packlets/ai-assist/streamUsageCapability.js +134 -0
  30. package/dist/packlets/ai-assist/streamUsageCapability.js.map +1 -0
  31. package/dist/packlets/ai-assist/streamingAdapters/anthropic.js +32 -9
  32. package/dist/packlets/ai-assist/streamingAdapters/anthropic.js.map +1 -1
  33. package/dist/packlets/ai-assist/streamingAdapters/clientToolContinuationBuilder.js +81 -35
  34. package/dist/packlets/ai-assist/streamingAdapters/clientToolContinuationBuilder.js.map +1 -1
  35. package/dist/packlets/ai-assist/streamingAdapters/common.js +25 -1
  36. package/dist/packlets/ai-assist/streamingAdapters/common.js.map +1 -1
  37. package/dist/packlets/ai-assist/streamingAdapters/gemini.js +12 -5
  38. package/dist/packlets/ai-assist/streamingAdapters/gemini.js.map +1 -1
  39. package/dist/packlets/ai-assist/streamingAdapters/openaiChat.js +45 -11
  40. package/dist/packlets/ai-assist/streamingAdapters/openaiChat.js.map +1 -1
  41. package/dist/packlets/ai-assist/streamingAdapters/openaiResponses.js +33 -22
  42. package/dist/packlets/ai-assist/streamingAdapters/openaiResponses.js.map +1 -1
  43. package/dist/packlets/ai-assist/streamingAdapters/proxy.js +62 -4
  44. package/dist/packlets/ai-assist/streamingAdapters/proxy.js.map +1 -1
  45. package/dist/packlets/ai-assist/streamingClient.js +20 -5
  46. package/dist/packlets/ai-assist/streamingClient.js.map +1 -1
  47. package/dist/packlets/ai-assist/thinkingOptionsResolver.js +23 -5
  48. package/dist/packlets/ai-assist/thinkingOptionsResolver.js.map +1 -1
  49. package/dist/packlets/ai-assist/toolFormats.js +80 -0
  50. package/dist/packlets/ai-assist/toolFormats.js.map +1 -1
  51. package/dist/packlets/ai-assist/toolTypes.js +21 -0
  52. package/dist/packlets/ai-assist/toolTypes.js.map +1 -0
  53. package/dist/packlets/ai-assist/usageNormalization.js +178 -0
  54. package/dist/packlets/ai-assist/usageNormalization.js.map +1 -0
  55. package/dist/packlets/ai-assist/usageTypes.js +21 -0
  56. package/dist/packlets/ai-assist/usageTypes.js.map +1 -0
  57. package/dist/packlets/mustache/index.js.map +1 -1
  58. package/dist/packlets/mustache/interfaces.js.map +1 -1
  59. package/dist/packlets/mustache/mustacheTemplate.js +91 -0
  60. package/dist/packlets/mustache/mustacheTemplate.js.map +1 -1
  61. package/dist/ts-extras.d.ts +764 -48
  62. package/lib/packlets/ai-assist/cacheRequest.d.ts +100 -0
  63. package/lib/packlets/ai-assist/cacheRequest.d.ts.map +1 -0
  64. package/lib/packlets/ai-assist/cacheRequest.js +123 -0
  65. package/lib/packlets/ai-assist/cacheRequest.js.map +1 -0
  66. package/lib/packlets/ai-assist/chatRequestBuilders.d.ts +36 -6
  67. package/lib/packlets/ai-assist/chatRequestBuilders.d.ts.map +1 -1
  68. package/lib/packlets/ai-assist/chatRequestBuilders.js +75 -7
  69. package/lib/packlets/ai-assist/chatRequestBuilders.js.map +1 -1
  70. package/lib/packlets/ai-assist/completionClient.d.ts +19 -0
  71. package/lib/packlets/ai-assist/completionClient.d.ts.map +1 -1
  72. package/lib/packlets/ai-assist/completionClient.js +194 -57
  73. package/lib/packlets/ai-assist/completionClient.js.map +1 -1
  74. package/lib/packlets/ai-assist/converters.d.ts +46 -0
  75. package/lib/packlets/ai-assist/converters.d.ts.map +1 -1
  76. package/lib/packlets/ai-assist/converters.js +55 -1
  77. package/lib/packlets/ai-assist/converters.js.map +1 -1
  78. package/lib/packlets/ai-assist/embeddingClient.d.ts +9 -0
  79. package/lib/packlets/ai-assist/embeddingClient.d.ts.map +1 -1
  80. package/lib/packlets/ai-assist/embeddingClient.js +16 -1
  81. package/lib/packlets/ai-assist/embeddingClient.js.map +1 -1
  82. package/lib/packlets/ai-assist/imageGenerationClient.d.ts +9 -0
  83. package/lib/packlets/ai-assist/imageGenerationClient.d.ts.map +1 -1
  84. package/lib/packlets/ai-assist/imageGenerationClient.js +17 -2
  85. package/lib/packlets/ai-assist/imageGenerationClient.js.map +1 -1
  86. package/lib/packlets/ai-assist/index.d.ts +7 -2
  87. package/lib/packlets/ai-assist/index.d.ts.map +1 -1
  88. package/lib/packlets/ai-assist/index.js +11 -1
  89. package/lib/packlets/ai-assist/index.js.map +1 -1
  90. package/lib/packlets/ai-assist/listModelsClient.d.ts +12 -0
  91. package/lib/packlets/ai-assist/listModelsClient.d.ts.map +1 -1
  92. package/lib/packlets/ai-assist/listModelsClient.js +19 -1
  93. package/lib/packlets/ai-assist/listModelsClient.js.map +1 -1
  94. package/lib/packlets/ai-assist/model.d.ts +81 -295
  95. package/lib/packlets/ai-assist/model.d.ts.map +1 -1
  96. package/lib/packlets/ai-assist/model.js.map +1 -1
  97. package/lib/packlets/ai-assist/promptCacheAnthropicBreakpointHarness.d.ts +124 -0
  98. package/lib/packlets/ai-assist/promptCacheAnthropicBreakpointHarness.d.ts.map +1 -0
  99. package/lib/packlets/ai-assist/promptCacheAnthropicBreakpointHarness.js +208 -0
  100. package/lib/packlets/ai-assist/promptCacheAnthropicBreakpointHarness.js.map +1 -0
  101. package/lib/packlets/ai-assist/promptCacheObservabilityHarness.d.ts +77 -0
  102. package/lib/packlets/ai-assist/promptCacheObservabilityHarness.d.ts.map +1 -0
  103. package/lib/packlets/ai-assist/promptCacheObservabilityHarness.js +143 -0
  104. package/lib/packlets/ai-assist/promptCacheObservabilityHarness.js.map +1 -0
  105. package/lib/packlets/ai-assist/promptCacheRoutingAbHarness.d.ts +84 -0
  106. package/lib/packlets/ai-assist/promptCacheRoutingAbHarness.d.ts.map +1 -0
  107. package/lib/packlets/ai-assist/promptCacheRoutingAbHarness.js +163 -0
  108. package/lib/packlets/ai-assist/promptCacheRoutingAbHarness.js.map +1 -0
  109. package/lib/packlets/ai-assist/promptCacheUsageMath.d.ts +105 -0
  110. package/lib/packlets/ai-assist/promptCacheUsageMath.d.ts.map +1 -0
  111. package/lib/packlets/ai-assist/promptCacheUsageMath.js +130 -0
  112. package/lib/packlets/ai-assist/promptCacheUsageMath.js.map +1 -0
  113. package/lib/packlets/ai-assist/registry.d.ts.map +1 -1
  114. package/lib/packlets/ai-assist/registry.js +9 -11
  115. package/lib/packlets/ai-assist/registry.js.map +1 -1
  116. package/lib/packlets/ai-assist/streamUsageCapability.d.ts +129 -0
  117. package/lib/packlets/ai-assist/streamUsageCapability.d.ts.map +1 -0
  118. package/lib/packlets/ai-assist/streamUsageCapability.js +140 -0
  119. package/lib/packlets/ai-assist/streamUsageCapability.js.map +1 -0
  120. package/lib/packlets/ai-assist/streamingAdapters/anthropic.d.ts +2 -1
  121. package/lib/packlets/ai-assist/streamingAdapters/anthropic.d.ts.map +1 -1
  122. package/lib/packlets/ai-assist/streamingAdapters/anthropic.js +29 -6
  123. package/lib/packlets/ai-assist/streamingAdapters/anthropic.js.map +1 -1
  124. package/lib/packlets/ai-assist/streamingAdapters/clientToolContinuationBuilder.d.ts +58 -14
  125. package/lib/packlets/ai-assist/streamingAdapters/clientToolContinuationBuilder.d.ts.map +1 -1
  126. package/lib/packlets/ai-assist/streamingAdapters/clientToolContinuationBuilder.js +81 -36
  127. package/lib/packlets/ai-assist/streamingAdapters/clientToolContinuationBuilder.js.map +1 -1
  128. package/lib/packlets/ai-assist/streamingAdapters/common.d.ts +38 -3
  129. package/lib/packlets/ai-assist/streamingAdapters/common.d.ts.map +1 -1
  130. package/lib/packlets/ai-assist/streamingAdapters/common.js +25 -1
  131. package/lib/packlets/ai-assist/streamingAdapters/common.js.map +1 -1
  132. package/lib/packlets/ai-assist/streamingAdapters/gemini.d.ts.map +1 -1
  133. package/lib/packlets/ai-assist/streamingAdapters/gemini.js +12 -5
  134. package/lib/packlets/ai-assist/streamingAdapters/gemini.js.map +1 -1
  135. package/lib/packlets/ai-assist/streamingAdapters/openaiChat.d.ts +2 -1
  136. package/lib/packlets/ai-assist/streamingAdapters/openaiChat.d.ts.map +1 -1
  137. package/lib/packlets/ai-assist/streamingAdapters/openaiChat.js +42 -8
  138. package/lib/packlets/ai-assist/streamingAdapters/openaiChat.js.map +1 -1
  139. package/lib/packlets/ai-assist/streamingAdapters/openaiResponses.d.ts +2 -1
  140. package/lib/packlets/ai-assist/streamingAdapters/openaiResponses.d.ts.map +1 -1
  141. package/lib/packlets/ai-assist/streamingAdapters/openaiResponses.js +30 -19
  142. package/lib/packlets/ai-assist/streamingAdapters/openaiResponses.js.map +1 -1
  143. package/lib/packlets/ai-assist/streamingAdapters/proxy.d.ts +22 -0
  144. package/lib/packlets/ai-assist/streamingAdapters/proxy.d.ts.map +1 -1
  145. package/lib/packlets/ai-assist/streamingAdapters/proxy.js +62 -4
  146. package/lib/packlets/ai-assist/streamingAdapters/proxy.js.map +1 -1
  147. package/lib/packlets/ai-assist/streamingClient.d.ts +5 -0
  148. package/lib/packlets/ai-assist/streamingClient.d.ts.map +1 -1
  149. package/lib/packlets/ai-assist/streamingClient.js +20 -5
  150. package/lib/packlets/ai-assist/streamingClient.js.map +1 -1
  151. package/lib/packlets/ai-assist/thinkingOptionsResolver.d.ts +11 -3
  152. package/lib/packlets/ai-assist/thinkingOptionsResolver.d.ts.map +1 -1
  153. package/lib/packlets/ai-assist/thinkingOptionsResolver.js +23 -5
  154. package/lib/packlets/ai-assist/thinkingOptionsResolver.js.map +1 -1
  155. package/lib/packlets/ai-assist/toolFormats.d.ts +55 -1
  156. package/lib/packlets/ai-assist/toolFormats.d.ts.map +1 -1
  157. package/lib/packlets/ai-assist/toolFormats.js +82 -0
  158. package/lib/packlets/ai-assist/toolFormats.js.map +1 -1
  159. package/lib/packlets/ai-assist/toolTypes.d.ts +386 -0
  160. package/lib/packlets/ai-assist/toolTypes.d.ts.map +1 -0
  161. package/lib/packlets/ai-assist/toolTypes.js +22 -0
  162. package/lib/packlets/ai-assist/toolTypes.js.map +1 -0
  163. package/lib/packlets/ai-assist/usageNormalization.d.ts +58 -0
  164. package/lib/packlets/ai-assist/usageNormalization.d.ts.map +1 -0
  165. package/lib/packlets/ai-assist/usageNormalization.js +184 -0
  166. package/lib/packlets/ai-assist/usageNormalization.js.map +1 -0
  167. package/lib/packlets/ai-assist/usageTypes.d.ts +84 -0
  168. package/lib/packlets/ai-assist/usageTypes.d.ts.map +1 -0
  169. package/lib/packlets/ai-assist/usageTypes.js +22 -0
  170. package/lib/packlets/ai-assist/usageTypes.js.map +1 -0
  171. package/lib/packlets/mustache/index.d.ts +1 -1
  172. package/lib/packlets/mustache/index.d.ts.map +1 -1
  173. package/lib/packlets/mustache/index.js.map +1 -1
  174. package/lib/packlets/mustache/interfaces.d.ts +30 -0
  175. package/lib/packlets/mustache/interfaces.d.ts.map +1 -1
  176. package/lib/packlets/mustache/interfaces.js.map +1 -1
  177. package/lib/packlets/mustache/mustacheTemplate.d.ts +56 -1
  178. package/lib/packlets/mustache/mustacheTemplate.d.ts.map +1 -1
  179. package/lib/packlets/mustache/mustacheTemplate.js +91 -0
  180. package/lib/packlets/mustache/mustacheTemplate.js.map +1 -1
  181. package/package.json +16 -16
  182. package/perf/promptCacheAnthropicBreakpoint.js +135 -0
  183. package/perf/promptCacheObservability.js +107 -0
  184. package/perf/promptCacheRoutingAb.js +105 -0
@@ -0,0 +1,96 @@
1
+ # `@fgv/ts-extras` — Node-flavored extras
2
+
3
+ > **This file is authoritative for what ``@fgv/ts-extras`` provides and what not to hand-roll.**
4
+ > `README.md`, where present, is getting-started material. The always-loaded index at
5
+ > [`.ai/instructions/LIBRARY_CAPABILITIES.md`](../../.ai/instructions/LIBRARY_CAPABILITIES.md)
6
+ > routes here; it never duplicates this content.
7
+
8
+
9
+ ---
10
+
11
+ [libraries/ts-extras](https://github.com/ErikFortune/fgv/tree/release/libraries/ts-extras)
12
+
13
+ | Packlet | Use for |
14
+ |---|---|
15
+ | [`crypto-utils`](https://github.com/ErikFortune/fgv/tree/release/libraries/ts-extras/src/packlets/crypto-utils) | Cross-runtime crypto surface. **`ICryptoProvider`** interface with operations: `encrypt`/`decrypt` (AES-256-GCM), `deriveKey` (PBKDF2), `sha256`, `generateRandomBytes`, `generateUuid`, `toBase64`/`fromBase64`, `generateKeyPair`/`exportPublicKeyJwk`/`importPublicKeyJwk` (asymmetric keypairs), `wrapBytes`/`unwrapBytes` (ECIES), `sign`/`verify` (digital signatures — algorithm inferred from key; supports Ed25519 and ECDSA-P256), `hmacSha256`/`verifyHmacSha256` (HMAC-SHA256 MAC computation and constant-time verification), `timingSafeEqual` (constant-time byte comparison — browser impl uses XOR-walk accumulator, Node impl delegates to `crypto.timingSafeEqual`); **`NodeCryptoProvider`** (Node implementation — pair with `BrowserCryptoProvider` from `ts-web-extras` on the browser side). **`KeyPairAlgorithm`** union: `'ecdsa-p256'`, `'rsa-oaep-2048'`, `'ecdh-p256'`, `'ed25519'`, `'x25519'` (X25519 Diffie-Hellman key agreement — use `deriveBits`/`deriveKey` to produce a shared secret). **`IArgon2idProvider`** / **`IArgon2idParams`** — interface for Argon2id key derivation; **`ARGON2ID_OWASP_MIN`** and **`ARGON2ID_PASSPHRASE`** — OWASP-recommended parameter presets. Obtain an implementation from `@fgv/ts-extras-argon2` (Node) or `@fgv/ts-web-extras-argon2` (browser/WASM). **`IKeyDerivationParams`** — discriminated union (`'pbkdf2'` | `'argon2id'`) for serializable key-derivation metadata. **`KeyStore`** — **the answer for password hashing and key storage**: password-protected vault for symmetric keys, API keys, and asymmetric keypairs; `addSecretFromPassword` derives + stores a key from a password via PBKDF2; `verifySecretFromPassword` does constant-time verification; `addSecretFromPasswordArgon2id` / `verifySecretFromPasswordArgon2id` — same workflow via Argon2id (pass an `IArgon2idProvider`). Also supports `addSecret`, `addKeyPair`, lock/unlock/save/changePassword, with optional `IPrivateKeyStorage` backend for private keys. Two concrete backends ship: **`EncryptedFilePrivateKeyStorage`** (here, Node — one AES-256-GCM-encrypted JWK file per key via FileTree I/O; consumer-supplied 32-byte encryption key; `supportsNonExtractable: false`) and **`IdbPrivateKeyStorage`** (`@fgv/ts-web-extras` — IndexedDB, stores `CryptoKey` objects directly; `supportsNonExtractable: true`). **Multibase/SPKI helpers** (new): `exportPublicKeyAsMultibaseSpki`/`importPublicKeyFromMultibaseSpki` — export/import any `ICryptoProvider` public key as a multibase base64url-encoded SPKI blob (algorithm-agnostic, suitable for storage/transport); `multibaseBase64UrlEncode`/`multibaseBase64UrlDecode` — pure base64url helpers with the `'m'` multibase prefix. **`toBase64`/`fromBase64`/`fromBase64Strict`** — standard-alphabet base64. **The two decoders differ in failure mode, and that is the point**: `fromBase64` is `Buffer`-first where a `Buffer` exists, and `Buffer.from(s, 'base64')` *silently discards* out-of-alphabet characters, so a corrupt payload decodes to plausible garbage; **`fromBase64Strict` returns `Result<Uint8Array>` and fails loudly** (`atob`-based; it does inherit the WHATWG forgiving-base64 leniency toward embedded ASCII whitespace, which is deliberate — line-wrapped base64 is benign). Reach for the strict one whenever the base64 came from somewhere you don't control (a wire response, a file, user input) and malformed means *something is wrong* rather than *do your best*. `DirectEncryptionProvider`, `createEncryptedFile`/`decryptFile`/`tryDecryptFile` for one-off encrypted JSON artifacts. **`HpkeProvider`** — HPKE base mode (RFC 9180): `DHKEM(X25519, HKDF-SHA256) + HKDF-SHA256 + AES-256-GCM`; `create(subtle)` factory; `sealBase(recipientPublicKey, info, aad, plaintext)` / `openBase(recipientPrivateKey, info, aad, enc, ciphertext)`; `HpkeProvider.encodeEnvelope` / `decodeEnvelope` for wire transport; `hkdf(secret, salt, info, length)` for standalone HKDF-SHA256. Works identically on Node.js 20+ and modern browsers via `SubtleCrypto` injection; re-exported from `@fgv/ts-web-extras` for browser callers. **Runtime requirements**: Node 20+; Chrome 113+, Safari 16.4+, Firefox 118+ (X25519 in Web Crypto). **Use this for any hybrid public-key encryption; do not roll your own KEM or HKDF.** **Use this for any password hashing, password-derived key, encrypted-at-rest artifact, named-key lookup, public-key export, digital signing/verification, or constant-time byte comparison; do not roll your own PBKDF2 / timing-safe compare / base64url.** |
16
+ | [`safer-fetch`](https://github.com/ErikFortune/fgv/tree/release/libraries/ts-extras/src/packlets/safer-fetch) | **An HTTP fetch primitive with an explicit threat model.** Not a Result-integration boundary and deliberately not thin — `fetch` is a platform global, there is no upstream to wrap, and the opinion *is* the deliverable. Three entry points — **`saferFetchBytes` / `saferFetchText` / `saferFetchJson`** (the JSON one takes an optional `converter`, so `T` is runtime-evidenced and never caller-asserted) — each returning `DetailedResult<ISaferFetchResponse<T>, FetchFailureReason>`. **`addressGuard` is required and has no default**, so omitting it is a *compile* error: the one mechanism that reliably prevents a guarantee being implied but not delivered, and it makes every call site's posture greppable in one search. **`blockPrivateNetworks(options?)`** is the recommended Node guard — resolves each hop's host through `node:dns` `lookup` and requires **every** resolved address to be globally routable public unicast (reject-if-any), with `allowLoopback` / `allowHosts` / `allowPorts` / `allowInsecureHttp` relaxations that each name themselves in the guard's own name so a `'blocked-by-guard'` failure says which posture was in force; `https:` is required unless `allowInsecureHttp` is set. **`allowAnyAddress()`** is the named, deliberately uncomfortable opt-out (and the only honest choice in a browser). Four guard **seams**, not one: address (the SSRF boundary), request, response-headers (**`allowContentTypes([...])`** gates on `Content-Type` *before* the body is read), and response-body. `redirectPolicy` defaults to `'reject'`; **`'validate-each-hop'`** follows redirects with `redirect: 'manual'` and runs the full address guard on **every** hop before any connection — redirect following and the SSRF guard are one mechanism, since a guard that validated only the caller's URL is defeated by a single `302` to `http://169.254.169.254/`. Credential stripping on a cross-origin hop is **monotonic** (`A`→`B`→`A` never hands the token back), loop detection compares **guard-cleared** URLs on both sides, and the hop cap defaults to 5. Two deadlines, one budget: `timeoutMs` (overall, 30 s) and `headersTimeoutMs` (per attempt, 10 s). `maxResponseBytes` (5 MiB) is counted during the read over **decoded** bytes, so a lying `Content-Length` and a gzip bomb both trip it. **`retry`** is opt-in (`IRetryPolicy`): retryable set + never-retryable set, `GET`/`HEAD` only unless `retryNonIdempotent`, `Retry-After` honored on 429/503 **clamped to `maxDelayMs`** (it is attacker-controlled), exponential backoff with full jitter, retries consume the overall deadline rather than resetting it, and **every attempt re-runs the guard from hop 0 — a full re-walk, never a resume and never a cached verdict**, because a cached verdict would make retry its own DNS-rebinding amplifier. **Stated limits (read them):** DNS rebinding is open — the guard validates a resolved address and the transport re-resolves; the pinning seam (`IGuardVerdict.pinnedAddress` + `IFetchTransport`) exists and `platformFetchTransport` **fails** rather than ignoring a pin it cannot honor. `FetchFailureReason` is an internal-network scanning oracle — log it, never echo it. No egress accounting, no cookie jar/cache/conditional requests. **Use this for any server-side fetch of a URL you do not fully control; do not hand-roll a timeout + size cap + redirect loop.** Browser callers use `@fgv/ts-web-extras`'s `SaferFetch` — see the guarantee table in both READMEs for what the browser cannot offer. |
17
+ | [`experimental`](https://github.com/ErikFortune/fgv/tree/release/libraries/ts-extras/src/packlets/experimental) | `ExtendedArray<T>`, `RangeOf<T>` (open/closed orderable ranges), `Formattable<T>` (mustache-printable wrappers). |
18
+ | [`csv`](https://github.com/ErikFortune/fgv/tree/release/libraries/ts-extras/src/packlets/csv) | CSV parse/format helpers (file + in-memory). |
19
+ | [`record-jar`](https://github.com/ErikFortune/fgv/tree/release/libraries/ts-extras/src/packlets/record-jar) | Record-jar (RFC 5646 appendix-style) parsing. |
20
+ | [`hash`](https://github.com/ErikFortune/fgv/tree/release/libraries/ts-extras/src/packlets/hash) | `Md5Normalizer` (Node + browser variants). |
21
+ | [`zip-file-tree`](https://github.com/ErikFortune/fgv/tree/release/libraries/ts-extras/src/packlets/zip-file-tree) | `FileTree` accessors backed by a ZIP archive. Read-only, and ZIP entries are byte-native, so `ZipFileTreeAccessors`/`ZipFileItem` support the read half of the optional binary capability (`getFileBytes`/`getRawBytes`) alongside the usual text read. `createZipFromFiles({ path, contents: string \| Uint8Array }[])` (sibling to `createZipFromTextFiles`) writes archive entries whose contents are raw bytes — string entries are UTF-8 encoded, `Uint8Array` entries are stored verbatim. Byte-native custody also means both types support the optional **strict-text** capability (`getFileTextStrict`/`getTextStrict`) — a `ZipFileItem` constructed directly from already-decoded text is the one case that refuses, since its bytes are a re-encode. |
22
+ | [`conversion`](https://github.com/ErikFortune/fgv/tree/release/libraries/ts-extras/src/packlets/conversion) | Extra converters beyond ts-utils. |
23
+ | [`ai-assist`](https://github.com/ErikFortune/fgv/tree/release/libraries/ts-extras/src/packlets/ai-assist) | LLM provider client + JSON-tolerant response handling. `callProviderCompletion`/`callProviderCompletionStream` (OpenAI, xAI, Anthropic, Gemini), provider/model registry, `AiPrompt`. **Model aliases (`@<provider>:<role>`).** A model string that begins with the `@` sigil (`MODEL_ALIAS_SIGIL`) is an fgv-owned alias resolved through the owning descriptor's `aliases` map (`IModelAliasMap`) to the current concrete provider id; **any string without a leading `@` is a raw provider id and passes through verbatim** (back-compat is structural — Ollama `model:tag` ids, dated snapshots, and every existing `modelOverride` are unaffected). Resolution is `Result`-returning and registry-gated: an unregistered `@alias` fails loudly (typo/unknown) rather than hitting the wire. `resolveModelAlias(descriptor, model)` resolves one string (following a chain of `@` aliases until a concrete provider id is reached — one hop is the canonical case, not a limit; cycle-guarded); `resolveProviderModel(descriptor, modelOverride, context?)` is the call-time chokepoint (ModelSpecKey walk THEN alias resolution) used by completion / streaming / image / embedding / client-tool paths. `resolveImageCapability`/`resolveEmbeddingCapability` (look up a model's `IAiImageModelCapability`/`IAiEmbeddingModelCapability` by id) also resolve the alias first — pass either an `@alias` or a concrete provider id and both prefix-match against the correct capability entry; before this, an unresolved alias silently fell through to the provider's catch-all `modelPrefix: ''` rule and returned a confidently wrong capability rather than failing or returning `undefined`. **Quality-tier axis (`base`/`advanced`/`frontier`).** On top of the alias layer, one cross-provider quality axis selects the *completion* model: `ModelSpecKey = 'base' \| 'advanced' \| 'frontier' \| 'image' \| 'embedding'`, and a completion/streaming call takes an optional `tier?: 'advanced' \| 'frontier'` request param (omit → `base`). The three tier keys are the **only** completion-model selectors; `image`/`embedding` select the non-completion modalities. **Cascade:** a tier request walks an ordered fallback list and takes the first key present — `frontier → advanced → base`, `advanced → base`, `base` — so a provider only declares the tiers it differentiates (OpenAI wires all three — `frontier` → `gpt-5.6-sol`, which works on Chat Completions; Anthropic, Gemini, and xAI omit `frontier` and cascade to their `advanced` model; the previous frontier target `gpt-5.5-pro` is Responses-API-only and stays `modelOverride`-only). `base` is the required floor and universal fallback; `image`/`embedding` keep flat `modality → base` behavior. **Composition, not competition:** thinking (reasoning effort) and tools (server-side tools) are orthogonal request/capability params — they ride on top of whatever model the tier picked and **never select a model** (`ModelSpecKey` carries no `thinking`/`tools` keys — they were removed when the tier axis landed). `tier: 'base'` + a thinking config resolves the cheap base model and sends the thinking param to it; every base model is thinking-capable, so thinking composes with any tier with zero capability checks. A `frontier + thinking` request is just a frontier-tier completion with the thinking param set. **Per-provider tier maps:** OpenAI `base @openai:mini → gpt-5.6-luna`, `advanced @openai:flagship → gpt-5.6-terra`, `frontier @openai:pro → gpt-5.6-sol` (the 5.6 family works on chat completions; the previous frontier target `gpt-5.5-pro` remains Responses-API-only and `modelOverride`-only, routed via `responsesOnlyModelPrefixes`); Anthropic `base @anthropic:sonnet → claude-sonnet-5`, `advanced @anthropic:opus → claude-opus-5` (frontier unset → cascades to opus); Gemini `base @google-gemini:flash → gemini-3.5-flash`, `advanced @google-gemini:pro → gemini-3.1-pro-preview` (frontier unset → cascades to pro), `image @google-gemini:flash-image → gemini-3.1-flash-image`, `embedding @google-gemini:embedding → gemini-embedding-001`; xAI `base @xai-grok:standard → grok-4.3`, `advanced @xai-grok:flagship → grok-4.5` (frontier unset → cascades to flagship), `image @xai-grok:imagine → grok-imagine-image-quality` (the cheaper `@google-gemini:flash-lite → gemini-3.1-flash-lite` and the non-tier `@openai:nano`/`@anthropic:haiku`/`@anthropic:fable` aliases are `modelOverride`-only). **Maintenance loop = one edit + a testbed run:** on a provider line rotation edit the single alias value in that descriptor's `aliases` map (a tier re-slot edits the one `defaultModel` slot → alias mapping, no alias rename so no `modelOverride` ripple), then run the per-provider testbed scenario against the live API to confirm the new id answers — consumers and `defaultModel` reference the stable alias/tier slot and never change. The alias layer covers **selection/default churn only** — capability-detection `idPattern` rules and the typed `*ModelNames` unions remain manual axes (see TECH_DEBT). **Every turn entry point takes the unified `{ system?, messages: IChatMessage[] }` request shape (`IChatRequest`)** — `messages` is the ordered conversation, the last entry is the current user turn and the preceding entries are history; history is always linearized before the current turn (no head/tail asymmetry). `AiPrompt.toRequest()` lowers a system+user prompt to this shape. The proxy wire body serializes `{ system?, messages }`. **For JSON-shaped completions use `generateJsonCompletion<T>({ converter, ... })`** — it appends a smart "raw JSON only" prompt hint and runs the response through `fencedStringifiedJson`, which strips Markdown fences, leading preamble, and trailing prose before validating with the caller's `Converter<T>` or `Validator<T>`. The bare `extractJsonText` helper is exported for callers that just need wrapper-stripping; `fencedStringifiedJson({ extractor, inner })` is the extension point for custom extractors. **Don't reimplement fence-stripping in consumers.** **Native structured output (`structuredOutput` on every **non-streaming** completion request; the streaming paths deliberately do not support it).** Rather than only *asking* for JSON in the prompt, a caller can **tell the provider** — `structuredOutput: { mode: 'schema', schema }` (a `JsonSchema.ISchemaValidator`, **the same object you validate the reply with**, so the wire schema and the check cannot drift — the cloud sibling of `@fgv/ts-extras-ollama`'s `chatStructured`) or `{ mode: 'json-object' }` (syntactic validity only, arbitrary shape). Four wire formats behind it, declared per model family on `IAiProviderDescriptor.structuredOutput: IAiStructuredOutputCapability[]` and longest-prefix matched **after** alias resolution (`resolveStructuredOutputCapability`): `openai-json-schema` (`response_format`), `openai-responses-format` (`text.format`), `gemini-response-schema` (`generationConfig.responseMimeType` + `responseSchema`, run through the same draft-07 sanitizer the Gemini tool path uses), and `anthropic-tool-forced` — Anthropic has **no** response-format field, so its mechanism is a forced synthetic tool, which is why `'tool-forced'` is a distinct enforcement value and not a spelling of `'schema'`. **`IAiCompletionResponse.structuredOutput: StructuredOutputEnforcement` is REQUIRED, not optional** (`'none'` \| `'json-mode'` \| `'schema'` \| `'tool-forced'`): an optional field would make absence three-ways ambiguous — no capability / not requested / a build predating the feature — and disambiguating exactly that is what it is for. It reports what was **sent**, never whether *this* response conforms; that is your converter's answer and re-deriving it here would be a second source of truth. **An optional property is unsendable to the OpenAI strict formats** (every property must be in `required`), and that is refused rather than silently repaired — *unless* the property's node **already admits `null`**, in which case **`adaptOptionalToNullable: true`** hoists it into `required` instead of refusing the schema. The opt-in is not the caller asserting its validator tolerates `null`; the condition is read off the schema, so it cannot be asserted falsely. `optional(string({ nullable: true }))` accepts absent-or-null-or-value, so listing the key in `required` only removes the model's option to omit it and every permitted reply still satisfies the supplied schema — whereas a plain `optional(string())` rejects `null`, is not hoisted, and still refuses. Verification is `hasOptionalProperties` re-run on the rewritten schema, so a partly-hoistable schema degrades **whole** rather than going out half-adapted; the hoist is also gated on the format, so it is inert on Gemini and Anthropic, which have no such rule. **Prefer `optional(x({ nullable: true })) + adaptOptionalToNullable` to authoring a bare `nullable`** when absence is genuinely meaningful to you: the schema then states what your validator does rather than what one provider demands, and the provider accommodation stays in `resolveStructuredOutput` where it can be kept correct as providers change. **`onUnsupported` defaults to `'degrade'`, and that is only safe because the report is required** — degrade-and-tell-me is safe, degrade-silently is the failure this exists to remove, so the two are one decision. Pass `'fail'` for output that is persisted or put on a wire, where an unconstrained generation that happens to parse is worse than an error; leave it at `'degrade'` on paths *designed* to degrade. Structured output **conflicts with server-side tools on Anthropic and Gemini, for different reasons** — Anthropic's mechanism *is* `tools` + `tool_choice` (a wire-level clash), Gemini's lives in `generationConfig` and the exclusion is API-enforced — and that conflict **fails rather than degrading** (`onUnsupported` speaks to capability, not to a caller asking for two incompatible things). Under `'tool-forced'` the reply arrives in a `tool_use` block and is **re-serialized** into `content`, so `content` stays a JSON string on every provider and a converter never branches on which enforcement it got. **`generateJsonCompletion` adopts this for free**: `ISchemaValidator<T> extends Validator<T>`, so if you already author `converter` as `JsonSchema.object({...})` the library now sends it — **no new parameter** (which would reintroduce the converter/schema drift `JsonSchema` exists to remove), and a plain `Converter` carries no schema so existing callers are byte-for-byte unchanged and report `'none'`. Self-hosted providers (`ollama`, `openai-compat`) and `groq` declare **no** capability on purpose — any model can sit behind them, and a wrong capability claim is worse than none; they report `'none'` and the report makes that visible. **To find out *why* a JSON-shaped response failed to parse** (not just that it did), use `classifyJsonParseFailure(text)` → `JsonParseFailureReason` — a structural classifier (walks the JSON grammar; never regex-matches the engine's `JSON.parse` error string, so verdicts don't drift across Node/V8 versions) that names four property-name-position faults with `token` + `offset` (0-based into the original `text`, not the extracted substring): `unquoted-property-name`, `single-quoted-property-name`, `unterminated-property-name`, `elided-member`; everything else — including truncation, missing colons, and bad number literals — falls to the conservative `'unknown'` rather than guessing. **Total**: non-string input returns `'unknown'` instead of throwing, matching `extractJsonText`'s guard. **Both proxied completion paths honor `tier`**, resolving it client-side into a concrete `modelOverride` so a proxy needs no new vocabulary to serve a frontier request, and **every proxied entry point refuses `endpoint`** — completion, completion stream, embedding, image generation and list-models — rather than forwarding or dropping it: no proxy can confirm it honored it, and silently reaching the provider's default upstream would send the request somewhere the caller excluded. Use the direct entry point, or point the proxy itself at the intended upstream. It matters most on the embedding path, where aiming `endpoint` at `http://localhost:11434/v1` is the documented way to reach a local Ollama. `callProxiedListModels`'s `capabilityConfig` stays a **disclosed no-op** instead — the proxy returns models already classified, so an override could only arrive too late, and a worse classification is a degraded answer rather than a misrouted request. `callProxiedCompletion` additionally honors `structuredOutput` (including `adaptOptionalToNullable`) and returns `usage`, validated on arrival rather than cast — an unrecognized extra field is tolerated, since the proxy may be a newer build, but a block missing the required `reports` level yields `undefined` rather than a partially-trusted object. The proxied *stream* carries only `text-delta` / `tool-event` / `done` / `error`; the three `client-tool-*` variants are discarded, and client-tool turns are not proxiable. It validates only that type discriminator and forwards each event as-is, so a `usage` block on `done` reaches the caller typed but unchecked — the same terms as every other field on every forwarded event. **For image generation use `callProviderImageGeneration`/`callProxiedImageGeneration`** — supports the OpenAI `gpt-image-*` family (`openai-images`; DALL-E 3 was EOL 2026-05-12 and its options block no longer exists), xAI grok-imagine generations (`xai-images`) and edits (`xai-images-edits`), and Gemini image-output via generateContent (`gemini-image-out`). The layered options system provides top-level keys (`size`, `quality`, `seed`, `count`) alongside per-provider `models` blocks for provider-specific params (e.g. `{ provider: 'openai', family: 'gpt-image', config: { outputFormat, background, moderation, outputCompression } }`). Provider capability is declared on `IAiProviderDescriptor.imageGeneration: IAiImageModelCapability[]`; `imageOptionsResolver` translates the layered options into a wire-ready `IResolvedImageOptions`. **For harness-side client tools (tools the caller implements, not the provider) use `executeClientToolTurn`** — the single-turn helper for tool-use conversations. Supply `clientTools: IAiClientTool[]` alongside any `tools: IAiServerToolConfig[]`; the helper returns `{ events: AsyncIterable<IAiStreamEvent>, nextTurn: Promise<Result<IAiClientToolTurnResult>> }` where `events` forwards **every** `IAiStreamEvent` the underlying stream emits — `text-delta`, `tool-event`, and the terminal `done` (carrying `usage`) and `error` — *plus* the three `client-tool-*` dispatch events this layer adds, and `nextTurn` awaits the complete round-trip result (`continuation`, `truncated`, `fullText`, `toolConflicts`, and `usage` — one provider call per invocation, so one usage; the round loop is the caller re-invoking with `continuation.messages` — the provider-format wire messages to re-supply on the next turn). `IAiClientTool` carries `config: IAiClientToolConfig` (name, description, `parametersSchema: ISchemaValidator<TParams>` — use `JsonSchema.object(...)` from `@fgv/ts-json-base`) and `execute: (args: unknown) => Promise<Result<unknown>>`. Type the tool as `IAiClientTool` (unparameterized, default `unknown`) and validate args inside `execute` via `parametersSchema.validate(args)` — the `execute` callback is contravariant in `TParams` so a typed `IAiClientTool<{key:string}>` is not assignable to `IAiClientTool<unknown>`. For multi-turn conversations pass `continuationMessages: continuation.messages` (type `ReadonlyArray<JsonObject>`) — `continuation.messages` is **cumulative** (replace each round, do not manually concatenate; the library prepends the inbound tail automatically so the array always covers all rounds so far) — these are provider-format wire objects appended after the current user turn. The exact wire-shape handling is per-provider: **Anthropic** projects each entry to `{ role, content }` (extra fields dropped; invalid entries skipped); **Gemini** projects to `{ role, parts }` (same shape guard); **OpenAI Responses / xAI** pass entries through verbatim with a `JsonObject` guard (since OpenAI Responses items are a `type`-keyed discriminated union whose fields differ per item). Consumers should not assume arbitrary fields round-trip on every provider — the high-fidelity round-trip is on the OpenAI/xAI path; Anthropic and Gemini preserve only the projected fields. Cross-provider continuation works on **all four providers**. **Do not put continuation wire objects in `messages`** (the unified `IChatRequest` conversation turns) — `continuationMessages` is a distinct post-current-turn axis whose provider-native fields the normalized `messages` path would strip. `IResolvedThinkingConfig` is exported from the packlet for callers that pre-resolve thinking options (e.g. `{ anthropicEffort: 'low' }`) and pass them directly to `executeClientToolTurn`. **Tool parameters authored as `JsonSchema.object(...)` work transparently on Gemini** — the adapter sanitizes draft-07-only keywords (`additionalProperties`, `$schema`) recursively before submission, so strict-by-default schemas don't need consumer pre-processing. **Gemini API constraint:** Gemini's `generateContent` forbids combining built-in grounding (`web_search`) with function calling in the same request — pick one. The library **pre-empts this with a named `Result.fail` before any wire call**, so you get a legible refusal rather than the provider's `INVALID_ARGUMENT` HTTP 400. **Completion diagnostic:** `IAiStreamDone.incompleteReason?: string` (`@public`) carries the OpenAI/xAI Responses `incomplete_details.reason` (`'max_output_tokens'`, `'content_filter'`, …) when `truncated === true`; absent when the stream completed normally. **Provider-drift instrumentation:** Anthropic and OpenAI Responses adapters maintain a `RECOGNIZED_*_EVENTS` allowlist and log a one-time `logger.warn` per stream per unrecognized SSE event name. Warning starts with the stable `ai-assist:unrecognized-event` tag (filter on this prefix to alert on provider-API evolution); includes a **default-safe structural payload preview** (top-level JSON keys + length — never field values) for triage; names the allowlist constant so a developer can find the right list to update. Production deployments wire the `ILogger` via the streaming-call params (`IProviderCompletionStreamParams.logger` / `IExecuteClientToolTurnParams.logger` — NOT via the per-adapter `IStreamApiConfig`, which carries only `{ baseUrl, apiKey, model }`); deployments that don't (default `NoOpLogger`) get a silent no-op. Ops triaging an active drift signal can opt in to the **raw payload preview** by setting `AI_ASSIST_UNRECOGNIZED_EVENT_FULL_PAYLOAD=1` in the environment — keep this off in production by default since SSE payloads can carry tool args / user-conversation text / other sensitive content. Gemini-side drift instrumentation is queued (JSON-chunk wire shape doesn't fit the SSE-event-name allowlist concept). **For embeddings (`text → vector`) use `callProviderEmbedding`/`callProxiedEmbedding`** — the cross-provider embedding primitive (third modality alongside completion and image generation). Batch in (`input: string \| string[]`), `number[][]` out, `Result`-wrapped. Two wire formats behind one dispatcher: `openai-embeddings` (`POST /embeddings`) serves **OpenAI** (`text-embedding-3-*`), **Ollama** (point `endpoint` at `http://localhost:11434/v1`), **openai-compat** self-hosted servers, and **Mistral** (`mistral-embed`); `gemini-embeddings` (`POST /models/{model}:batchEmbedContents`) serves **Google Gemini** (`gemini-embedding-001`) with its `taskType` retrieval-asymmetry knob. Capability is declared on `IAiProviderDescriptor.embedding: IAiEmbeddingModelCapability[]` (model-prefix matched, longest wins) and selects the model via the `embedding` `ModelSpecKey`. Knob handling (`dimensions`, `taskType`) is **no-op-where-unsupported, not a failure** — a caller passes `taskType: 'retrieval-query'` once and it maps to Gemini's wire `RETRIEVAL_QUERY` while being a logged no-op on the (symmetric) OpenAI-format providers, so the cross-provider call site never branches. Empty input short-circuits to an empty result with no wire call; a batch over the capability's `maxBatchSize` fails fast (no auto-chunking). xAI and Anthropic declare no embedding capability (no first-party endpoint) and reject up front. Vectors are `number[]` (not `Float32Array`) for JSON-wire fidelity — call `Float32Array.from(vector)` at the vector-store boundary; the library does not L2-normalize (Gemini MRL-truncated vectors may need it). **Prompt-cache token accounting (`IAiCompletionResponse.usage?: IAiCompletionUsage`, also on `IAiStreamDone`).** Sibling to `IAiEmbeddingResult.usage` — present when the provider reports it, absent otherwise, never guessed. `AiCacheReportingLevel` (`'reads' \| 'reads-and-writes'` — no `'none'` member; "nothing reported" is `usage` itself being absent) is **required, not optional** on `IAiCompletionUsage`, for the same reason `structuredOutput` is required: an absent `cacheWriteTokens` is three-ways ambiguous (no write happened / this API cannot report writes / a build predating the field) and `reports` disambiguates it by construction. Normalized per wire shape: Anthropic Messages and OpenAI Responses report `'reads-and-writes'`; OpenAI/xAI Chat Completions and Gemini `generateContent` report `'reads'` only — Chat Completions has no `cache_write_tokens` field at all (never defaulted to `0`), and Gemini has no write concept in a `generateContent` call (writes are the out-of-band `cachedContents` resource, out of scope). The OpenAI/xAI Responses route is shared code with diverging behavior: `reports` is derived from **field presence** (`input_tokens_details.cache_write_tokens` present, even `0` → `'reads-and-writes'`; absent entirely → `'reads'`), not from provider identity — but only once a descriptor has cleared `AiAssist.supportsCacheUsageReporting`, the gate applied at the four completion sites it governs (non-streaming Chat Completions and Responses, Responses streaming, and `executeClientToolTurn`'s direct Responses call — streaming Chat Completions is gated by `supportsStreamUsageOption` instead, which is OpenAI-only, so xAI gets no usage on that one path) before any normalization runs, since those sites are shared by every `apiFormat: 'openai'` descriptor (Groq, Mistral, Ollama, self-hosted `openai-compat`), not just OpenAI and xAI Grok; an unconfirmed descriptor — including a future one — gets `usage: undefined` rather than a guess derived from whatever ordinary usage shape its wire happens to send. Gemini's footgun: `cachedContentTokenCount` sits **inside** `promptTokenCount`, unlike OpenAI where it sits in a sibling `…_details` object next to a separate total — `uncachedInputTokens` subtracts to correct for it, but only when the cached figure is itself known (never assumed zero from a merely-absent field). Streaming is covered from the same commit, not a follow-on: Anthropic merges `message_start`'s initial reads/writes with `message_delta`'s final `output_tokens`; OpenAI Chat Completions streaming requires opting in via `stream_options: { include_usage: true }` — sent only for `descriptor.id === 'openai'` (`AiAssist.supportsStreamUsageOption`), since the shared adapter also carries providers whose tolerance for an unrecognized request field is unverified; usage stays silently absent for every other provider on this path, and the populated usage arrives on a terminal `choices: []` chunk, with `usage: null` on every chunk before it once the option is sent; OpenAI/xAI Responses streaming reads `response.completed`'s `response.usage`; Gemini repeats `usageMetadata` on every chunk with running totals; the adapter keeps the last one. `raw?: JsonObject` on `IAiCompletionUsage` carries the provider's own unnormalized usage block for anything the normalized shape drops (e.g. xAI's `cost_in_usd_ticks`). **Prompt-cache breakpoint emission (`IProviderCompletionParams.cache?: IAiCacheRequest`).** `IAiCacheRequest` carries `systemBreakpoints?: ReadonlyArray<number>` — strictly ascending UTF-16 offsets into `system`, each marking the end of a cacheable prefix — and `cacheKey?: string`, an opaque cache-routing key. Both are optional and additive: a request with no `cache` is byte-for-byte the same request a build predating this feature would send. `AiAssist.validateAiCacheRequest`/`validateCacheBreakpoints` **fail loudly and never clamp** — a non-ascending, out-of-range (not `> 0` and `< system.length`), or over-cap (when a `maxBreakpointWrites` cap is supplied) offset returns `Result.fail`, never a silently-trimmed plan. On **Anthropic**, `system` is split into content blocks at each breakpoint, with `cache_control: { type: 'ephemeral' }` on the block that ends each cacheable prefix (was a plain string). On **OpenAI Chat Completions and Responses**, the leading system message/item is split into content parts carrying `prompt_cache_breakpoint: { mode: 'explicit' }` — `prompt_cache_options` is never touched, so OpenAI's own implicit breakpoint and default caching mode are undisturbed; `prompt_cache_key` is sent (both routes) when `cacheKey` is supplied. **Breakpoints and the routing key are gated separately, because they are different capabilities with different support sets.** Breakpoint splitting is gated to `AiAssist.supportsPromptCacheBreakpoints(descriptor)` (`true` only for `descriptor.id === 'openai'`), the same write-side-tolerance reasoning as `supportsStreamUsageOption` — the shared adapter also carries xAI/Groq/Mistral/Ollama/`openai-compat`, none confirmed to tolerate a restructured `system` content array. The routing key is gated to `AiAssist.supportsPromptCacheRouting(descriptor)`, which returns an `IAiPromptCacheRoutingSupport` describing the **per-route transport** rather than a boolean: OpenAI takes `prompt_cache_key` as a body field on both routes; **xAI takes an `x-grok-conv-id` header on Chat Completions and `prompt_cache_key` on Responses**. xAI needs it — its cache matches byte-for-byte from the start of the messages array but is **per-server and evictable**, so two requests sharing a prefix can miss when routed to different servers. Measured on `grok-4.3`: a varying-tail warm call reads **1.9%** cached without the key and **99.7%** with it. A descriptor supporting neither still gets a request — body *and headers* — byte-identical to one a caller omitting `cache` would have sent. **Gemini has no breakpoint mechanism and ignores `cache` entirely.** There is no model-keyed capability/cap table (`IAiCacheCapability`) backing `maxBreakpointWrites` — none was built in any slice of this feature, and a caller building breakpoints from `@fgv/ts-prompt-assist`'s three-level stability vocabulary never needs one, since that vocabulary provably never emits more than two breakpoints. **Streaming carries `cache` too** (`IProviderCompletionStreamParams.cache?` / `IExecuteClientToolTurnParams.cache?: IAiCacheRequest`) — the same plan, the same `validateAiCacheRequest` contract, and the identical per-descriptor gating reused verbatim from the non-streaming dispatch (Anthropic ungated, OpenAI/xAI breakpoints+routing gated by `supportsPromptCacheBreakpoints`/`supportsPromptCacheRouting`, Gemini ignores it). `executeClientToolTurn` represents one round of a multi-round tool loop, so `cache` is validated and applied fresh on **every** call against that call's own `system` — never cached across rounds, since a later round's system can differ from an earlier one's. `callProxiedCompletionStream` forwards `cache` as a plain `body.cache` field exactly as `callProxiedCompletion` does; unlike `endpoint`, a proxy that ignores it degrades to the no-cache request it would have sent anyway rather than misdirecting the request, so it is forwarded rather than refused. See `@fgv/ts-prompt-assist`'s `toCacheRequest` for deriving a plan from a resolved composition rather than hand-computing offsets. |
24
+
25
+ ---
26
+
27
+ ## Decision shortcuts
28
+
29
+ - **Symmetric encryption / decryption (AES-GCM)?** → `DirectEncryptionProvider` or `KeyStore.encryptByName` from `@fgv/ts-extras/crypto-utils`, backed by `NodeCryptoProvider` (Node) or `BrowserCryptoProvider` (browser).
30
+ - **Need AES-256-GCM encrypt/decrypt with a key you already hold (PBKDF2/HKDF output, raw bytes)?** → `ICryptoProvider.encrypt(plaintext, key)` / `ICryptoProvider.decrypt(data, key, iv, authTag)` from `@fgv/ts-extras/crypto-utils`. The higher-level `DirectEncryptionProvider` and `KeyStore.encryptByName` paths wrap these for named-key lookup workflows.
31
+ - **Password-protected vault for keys / API keys / keypairs?** → `KeyStore` from `@fgv/ts-extras/crypto-utils`. Use `addSecretFromPassword` to derive + store, `verifySecretFromPassword` for constant-time password verification.
32
+ - **Need a private-key storage backend so `KeyStore.addKeyPair` works?** → **Node:** `CryptoUtils.KeyStore.EncryptedFilePrivateKeyStorage.create({ directory, encryptionKey, cryptoProvider })` — import `CryptoUtils` from `@fgv/ts-extras` (or the `@fgv/ts-extras/crypto` subpath); one AES-256-GCM-encrypted JWK file per key via FileTree; you supply the 32-byte `encryptionKey`; `supportsNonExtractable: false`. **Browser:** `CryptoUtils.IdbPrivateKeyStorage.create({ databaseName?, storeName? })` — import `CryptoUtils` from `@fgv/ts-web-extras` (IndexedDB; stores `CryptoKey` directly; `supportsNonExtractable: true`). Pass the instance as `KeyStore.create({ ..., privateKeyStorage })`. Don't roll your own `IPrivateKeyStorage`.
33
+ - **PBKDF2 key derivation, ECIES wrap/unwrap, asymmetric keypairs (Ed25519, ECDSA-P256, ECDH-P256, RSA-OAEP, X25519)?** → `ICryptoProvider` from `@fgv/ts-extras/crypto-utils`. Never call `node:crypto` or `crypto.subtle` directly.
34
+ - **Need to hash / verify a password (PBKDF2)?** → `KeyStore.addSecretFromPassword` / `verifySecretFromPassword` from `@fgv/ts-extras/crypto-utils`. Never roll PBKDF2 directly.
35
+ - **Need to hash / verify a password with Argon2id?** → `KeyStore.addSecretFromPasswordArgon2id` / `verifySecretFromPasswordArgon2id` from `@fgv/ts-extras/crypto-utils`, passing a `NodeArgon2Provider` (`@fgv/ts-extras-argon2`) or `BrowserArgon2Provider` (`@fgv/ts-web-extras-argon2`). Use `ARGON2ID_OWASP_MIN` or `ARGON2ID_PASSPHRASE` presets from `CryptoUtils` for OWASP-compliant parameters.
36
+ - **Decoding base64 that arrived from somewhere you don't control?** → `CryptoUtils.fromBase64Strict(s)` from `@fgv/ts-extras/crypto-utils` — `Result<Uint8Array>`, fails on malformed input. **Not `CryptoUtils.fromBase64`, and not `Buffer.from(s, 'base64')`**: both silently drop out-of-alphabet characters, so a corrupt payload decodes to plausible garbage and the corruption surfaces (if ever) far downstream. Use the lenient one only for base64 you produced yourself. **Don't hand-roll `atob` + a `charCodeAt` loop** — that recipe was independently reimplemented three times in this repo before being collapsed onto this primitive.
37
+ - **Need to encode/decode a public key as multibase SPKI?** → `exportPublicKeyAsMultibaseSpki(key, provider)` / `importPublicKeyFromMultibaseSpki(encoded, algorithm, provider)` from `@fgv/ts-extras/crypto-utils`. Pass the `ICryptoProvider` instance so the underlying SPKI export/import routes through the correct platform implementation.
38
+ - **Need hybrid public-key encryption (encrypt to a recipient's public key, one-shot)?** → `HpkeProvider` from `@fgv/ts-extras/crypto-utils` (Node) or `@fgv/ts-web-extras` (browser). `HpkeProvider.create(subtle).orThrow()` then `sealBase(recipientPub, info, aad, plaintext)` / `openBase(recipientPriv, info, aad, enc, ciphertext)`. Suite: DHKEM(X25519, HKDF-SHA256) + HKDF-SHA256 + AES-256-GCM (RFC 9180 base mode). `info` is mandatory and must be application-specific context bytes — empty `info` provides no context binding. Use `HpkeProvider.encodeEnvelope` / `decodeEnvelope` for wire transport. Never roll your own KEM.
39
+ - **Need standalone HKDF-SHA256 key derivation?** → `HpkeProvider.create(subtle).orThrow().hkdf(secret, salt, info, length)` from `@fgv/ts-extras/crypto-utils` (or `@fgv/ts-web-extras` for browser). Raw RFC 5869 HKDF — unlabeled, suitable for application-level key derivation.
40
+ - **Need X25519 key agreement?** → `generateKeyPair('x25519', ...)` from `ICryptoProvider` (`@fgv/ts-extras/crypto-utils`) to produce the keypair; use `ICryptoProvider.exportPublicKeySpki` / `importPublicKeySpki` to exchange public keys. Raw `deriveBits` for the shared secret is not yet wrapped by `ICryptoProvider` — do not call `crypto.subtle` directly.
41
+ - **Need to sign data or verify a signature (Ed25519, ECDSA-P256)?** → `ICryptoProvider.sign(privateKey, data)` / `ICryptoProvider.verify(publicKey, signature, data)` from `@fgv/ts-extras/crypto-utils`. Algorithm is inferred from the key's `algorithm` property — generate keys with `generateKeyPair('ed25519', ...)` or `generateKeyPair('ecdsa-p256', ...)`. Both return `Promise<Result<...>>`.
42
+ - **Need HMAC-SHA256 authentication / MAC verification?** → `ICryptoProvider.hmacSha256(key, data)` / `ICryptoProvider.verifyHmacSha256(key, signature, data)` from `@fgv/ts-extras/crypto-utils`. The verify variant uses `timingSafeEqual` internally for constant-time comparison. Both return `Promise<Result<...>>`.
43
+ - **Need constant-time byte comparison?** → `ICryptoProvider.timingSafeEqual(a, b)` from `@fgv/ts-extras/crypto-utils`. Returns `boolean` directly (not `Result`). Node impl delegates to `crypto.timingSafeEqual`; browser impl uses XOR-walk accumulator. Never use `===` or loop with early return for security-sensitive comparisons.
44
+ - **Asking an LLM for JSON and getting a validated `T`?** → `AiAssist.generateJsonCompletion<T>({ converter, ... })` from `@fgv/ts-extras/ai-assist`. Tolerates Markdown fences, preamble, and trailing prose by default. Don't reimplement fence-stripping in consumers. **Author `converter` as `JsonSchema.object({...})`** (from `@fgv/ts-json-base`) rather than `Converters.object({...})` and the library also *tells the provider* — one object is the wire schema and the reply validator, so they cannot drift, and there is no second parameter to keep in sync. A plain `Converter` still works and sends nothing.
45
+ - **Need the provider to constrain its output, not just be asked to?** → `structuredOutput` on any `callProviderCompletion` / `callProxiedCompletion` request: `{ mode: 'schema', schema }` for shape, `{ mode: 'json-object' }` for syntactic validity only. Read **`response.structuredOutput`** to learn what was actually enforced (`'none'` | `'json-mode'` | `'schema'` | `'tool-forced'`) — it is **required on every response**, because you cannot know up front which concrete model will serve you (a `tier` request cascades, aliases resolve at call time), so the report rides on the response rather than being a lookup you do yourself. It says what was **sent**, never whether *this* reply conforms — that is your converter's job and the library will not re-derive it. Unsupported combinations **degrade by default and say so**; pass `onUnsupported: 'fail'` when the output is persisted or put on a wire. **Don't hand-roll a `response_format` per provider** — the four wire shapes differ in more than field names, and Anthropic has none at all (it forces a tool, and the library re-serializes the tool input back into `content` so your converter does not branch).
46
+ - **Running a tool-use conversation with harness-side client tools?** → `AiAssist.executeClientToolTurn({ descriptor, apiKey, system?, messages, clientTools, tools?, model?, resolvedThinking? })` from `@fgv/ts-extras/ai-assist`. Like every turn entry point it takes the unified `{ system?, messages }` request shape (the last `messages` entry is the current user turn; preceding entries are history). Returns `{ events, nextTurn }` — consumer drives the outer multi-turn loop. **`nextTurn` only resolves once `events` has been iterated to completion** — it is settled from inside the generator backing `events`, so awaiting it alone hangs and never drains the response body. **Token usage is on both surfaces:** `events` forwards the terminal `done` event with its `usage?: IAiCompletionUsage`, and the same object is on the turn result as `IAiClientToolTurnResult.usage`, so a host accumulating per-turn cost never has to scrape the stream. One provider call per invocation means each result's `usage` already *is* one round's — there is no `perRound` array because it could only ever have one entry. Optional, not required like `toolConflicts`: the library always knows what it did to your tools, but cannot always know what a turn cost. Absent means **no normalized usage is exposed** — either the provider reported nothing, or it reported something the library declines to normalize because the descriptor is outside `supportsCacheUsageReporting` (groq/mistral/ollama/openai-compat share the OpenAI-format adapter with unverified usage shapes). Never a fabricated zero. Pass `continuationMessages: continuation.messages` on subsequent turns — `continuation.messages` is **cumulative** (replace each round, do not manually concatenate); do NOT put continuation wire objects in `messages` (they are a distinct post-current-turn axis). Works on **all four providers** (Anthropic, OpenAI Responses, Gemini, xAI via OpenAI) — a statement about provider coverage, not about runtime: `executeClientToolTurn` does not pre-flight `streamingCorsRestricted` and has no proxied entry point, so a **browser** client-tool turn against a CORS-restricted provider (today, xAI) reaches a direct fetch and fails there rather than with a `Result.fail`. Type client tools as `IAiClientTool` (unparameterized) and validate args inside `execute` — the callback is contravariant in `TParams`. Author the `parametersSchema` with `JsonSchema.object(...)` from `@fgv/ts-json-base`; `schema.validate(args)` is the round-trip verify step. **Model errors continue the turn; host-machinery errors end it.** An unknown tool name, an arg-validation failure, an `execute` failure and a gate `deny` all yield a `client-tool-result` with `isError: true` and let the model react on the next round; only a gate `fail`/reject terminates. So a `nextTurn` that succeeds does not mean every tool call succeeded — check `continuation.toolCallsSummary[].isError` if you need to know. (The unknown-tool case terminated the turn until 2026-09; a model that read a prose trailer as a tool call ended a turn that had every chance to recover.) **Provider tool exclusions are declared, not branched on.** `IAiProviderDescriptor.serverToolsExclusiveWithClientTools` names the server tools a provider refuses to accept alongside client tools — only Gemini declares anything (`['web_search']`, because `generateContent` HTTP-400s on grounding + `function_declarations`). `resolveToolConflicts(descriptor, serverTools, clientTools, policy?)` applies it generically, so **a host never writes `if (descriptor.apiFormat === 'gemini')` in its tool assembly** and the day a provider lifts its limit only the descriptor changes. `toolConflictPolicy` on the turn defaults to `'drop-server-tools'` (keep the client tools the host registered for this turn, drop the grounding); `'prefer-server-tools'` is the mirror; `'fail'` refuses up front, before any wire call. The turn result's **required** `toolConflicts: IAiToolConflictReport` names what was dropped and under which policy — a degraded turn is never a silent one, so a host can tell the person web search was unavailable. `resolveEffectiveTools` is the sibling that filters server tools against `supportedTools` and is unchanged; it cannot see client tools, which is why the exclusion needed its own resolver.
47
+ - **Diagnosing an OpenAI/xAI Responses stream that completes empty?** → Check `IAiStreamDone.incompleteReason?: string` on the `done` event. `'max_output_tokens'` → reasoning + tools consumed the output budget (raise the cap or shorten the prompt); `'content_filter'` → safety filter blocked the response. Absent → stream completed normally; if you still see no text, pass an `ILogger` to the streaming call (`callProviderCompletionStream({ ..., logger })` or `executeClientToolTurn({ ..., logger })`) and look for `ai-assist:unrecognized-event` warns — the streaming adapter probably saw an event family it doesn't handle yet (provider-API evolution).
48
+ - **Alerting on provider-API evolution affecting `@fgv/ts-extras/ai-assist`?** → Pass an `ILogger` via the higher-level streaming params (`IProviderCompletionStreamParams.logger`, `IExecuteClientToolTurnParams.logger`, etc.) and filter on the stable `ai-assist:unrecognized-event` warn prefix. The streaming helpers thread the logger down to the per-adapter unknown-event sites; the per-adapter `IStreamApiConfig` itself is just `{ baseUrl, apiKey, model }` and is not where consumers wire the logger. The Anthropic and OpenAI Responses adapters maintain per-stream-dedup'd allowlists of recognized SSE event names; an unrecognized event triggers one warn per stream per name including a length-capped structural payload preview (top-level JSON keys + length by default; raw payload via the `AI_ASSIST_UNRECOGNIZED_EVENT_FULL_PAYLOAD` env var). Use this in production to alert when a provider's wire shape changes (likely class of issue: adapter parse miss → silent feature degradation). Gemini's drift instrumentation is queued separately (different wire shape).
49
+ - **Stripping LLM Markdown fences from raw text?** → `AiAssist.extractJsonText` (default extractor) or `AiAssist.fencedStringifiedJson({ extractor, inner })` (extension point) from `@fgv/ts-extras/ai-assist`.
50
+ - **Diagnosing *why* an LLM's JSON response failed to parse (not just that it did)?** → `AiAssist.classifyJsonParseFailure(text)` from `@fgv/ts-extras/ai-assist` — returns a typed `JsonParseFailureReason` (four named property-name-position faults with `token`/`offset`, else `'unknown'`); structural, not regex-on-engine-message; total (non-string input → `'unknown'`, never throws). Branch on the reason to repair the cheap cases, re-prompt the expensive ones, or fail outright — don't regex-match `JSON.parse`'s message.
51
+ - **Generating images from an LLM provider?** → `AiAssist.callProviderImageGeneration({ descriptor, apiKey, params })` from `@fgv/ts-extras/ai-assist`. Use `callProxiedImageGeneration(proxyUrl, ...)` to route through a proxy when direct browser calls are CORS-restricted. Pass top-level options (`size`, `quality`, `seed`, `count`) and a `models` array for provider-specific config blocks.
52
+ - **Declaring image generation capability on a provider?** → Add `imageGeneration: IAiImageModelCapability[]` to `IAiProviderDescriptor`. Each capability entry carries `format` (`'openai-images' | 'xai-images' | 'xai-images-edits' | 'gemini-image-out'`), `modelPrefix`, and optional flags like `acceptsImageReferenceInput`, `defaultOutputMimeType`, `outputParamStyle`, `supportsQualityParam`, `maxCount`.
53
+ - **Looking up a model's image/embedding capability by id?** → `AiAssist.resolveImageCapability`/`resolveEmbeddingCapability` from `@fgv/ts-extras/ai-assist`. Both resolve an `@alias` before prefix-matching, so pass either an alias or a concrete provider id — both resolve to the correct capability entry rather than an alias silently matching the provider's catch-all rule.
54
+ - **Enabling extended thinking / reasoning on LLM completions?** → Pass `thinking: IThinkingConfig` to `callProviderCompletion` or `callProviderCompletionStream`. `IThinkingConfig` supports a cross-provider generic `effort?: 'none' | 'low' | 'medium' | 'high'` shorthand and an optional `providers[]` array for per-provider config blocks. `'none'` is the one cross-provider spelling for "thinking off" — it maps to `effort: 'none'` (OpenAI, xAI), `thinkingBudget: 0` (Gemini), and omission of the `thinking` param entirely on Anthropic, whose own vocabulary has no off value. Provider-specific types: `IAnthropicThinkingConfig` (effort), `IOpenAiThinkingConfig` (effort, including `'none'`), `IGeminiThinkingConfig` (thinkingBudget integer), `IXAiThinkingConfig` (effort). Model-specific and `'other'` escape-hatch blocks let you override for individual models without affecting others. Merge precedence: generic effort → provider-generic blocks → model-specific/other blocks (later declaration wins within a tier). **Temperature + thinking conflicts**: Anthropic and OpenAI (non-`'none'` effort) and xAI return `Result.fail`; Gemini accepts both. `effort: 'none'` re-enables temperature on every provider. **Gemini caveat**: generic `'none'` maps to `thinkingBudget: 0`, which is valid only on Flash/Flash-Lite and errors on Pro-family models — the mapping is not model-aware (same caveat as requesting `thinkingBudget: 0` through an explicit `providers` block; tracked as P3 in `docs/TECH_DEBT.md`). Thinking content is discarded in this phase; caller-visible thinking-event surfacing is the scope of the followup stream `ai-assist-thinking-events`. Unknown/unsupported providers silently ignore the `thinking` field.
55
+ - **Embedding text via a cloud or self-hosted provider (OpenAI / Gemini / Ollama / openai-compat / Mistral)?** → `AiAssist.callProviderEmbedding({ descriptor, apiKey, params: { input, dimensions?, taskType? }, modelOverride?, endpoint? })` from `@fgv/ts-extras/ai-assist`; `callProxiedEmbedding(proxyUrl, ...)` to route through a backend proxy. `input` is `string | string[]`; the result's `vectors[i]` aligns to `input[i]`. Pass `dimensions` (honored by OpenAI `text-embedding-3-*` and Gemini) and/or `taskType` (Gemini retrieval asymmetry, e.g. `'retrieval-query'` vs `'retrieval-document'`) — both are a **logged no-op** on providers/models that don't support them, never a failure, so a cross-provider call site passes them once. Self-hosted (`'ollama'`/`'openai-compat'`) needs an explicit `modelOverride` (no default embedding model). xAI / Anthropic have no embeddings endpoint and reject up front. **Don't reach for `@fgv/ts-extras-transformers` for this** — that's the in-process path; this is the distant-HTTP path (see the three-way decision shortcut above).
56
+ - **Calling a self-hosted Ollama or OpenAI-compatible model?** → Use the `'ollama'` or `'openai-compat'` provider descriptor from `AiAssist.getProviderDescriptor`. Both are `needsSecret: false` — pass `apiKey: ''` and the library omits the `Authorization` header automatically. The `'ollama'` descriptor defaults to `http://localhost:11434/v1`; `'openai-compat'` has `baseUrl: ''` and requires an explicit `endpoint`. **`defaultModel` is `''` for both** — always supply `model` explicitly (e.g. `model: 'llama3.2'`). Pass `endpoint` to override `baseUrl` per call for `callProviderCompletion`, `callProviderCompletionStream`, and `callProviderListModels`. (`executeClientToolTurn` takes the same `endpoint` override, resolved through `resolveEffectiveBaseUrl`.) **Browser CORS:** Ollama only allows requests from `localhost` by default; set `OLLAMA_ORIGINS=*` (or a specific origin) in the Ollama server environment before starting it, otherwise browser calls will be blocked by the CORS preflight.
57
+ - **Fetching a URL you do not fully control (server-side)?** → `saferFetchJson` / `saferFetchText` / `saferFetchBytes` from **`@fgv/ts-extras/safer-fetch`**, with `addressGuard: blockPrivateNetworks()` (the option is **required** — omitting it is a compile error, which is the point). Add `responseHeadersGuard: allowContentTypes([...])` to reject on the header rather than after a body transfer, `redirectPolicy: 'validate-each-hop'` when ingesting real-world URLs (every hop is fully address-guarded before any connection, and credential stripping is monotonic), and `retry: { attempts: n }` when you want retry — it is off by default, consumes the overall deadline rather than resetting it, and re-runs the guard from hop 0 on every attempt. For a local sidecar, name every deviation: `blockPrivateNetworks({ allowLoopback: true, allowInsecureHttp: true, allowHosts: ['localhost'], allowPorts: [11434] })`. **Do not hand-roll a timeout + size cap + redirect loop, and do not echo `FetchFailureReason` to an untrusted caller — it is an internal-network scanning oracle.** DNS rebinding remains a documented open limit; a strict `allowHosts` list is the recommended mitigation.
58
+ - **Mustache rendering for LLM prompts (verbatim, no HTML escaping)?** → `MustacheTemplate.create(template, { escape: 'none' })` from `@fgv/ts-extras` (mustache packlet). The template string is the first argument; the options bag is second. Uses a per-instance `Mustache.Writer` — no mutation of the global `Mustache.escape`, so concurrent renderers with different escape strategies coexist. Default `'html'` preserves the existing back-compat behaviour; pass a `(value) => string` callback for a custom strategy. `@fgv/ts-prompt-assist` consumes the `'none'` strategy internally.
59
+ - **Knowing what a rendered template is *made of* — which part of the output came from which interpolation, and how big each part is?** → `MustacheTemplate.renderWithSegments(context)` from `@fgv/ts-extras` (mustache packlet). Returns `IRenderedTemplate` — the same `text` `render()` produces, plus `segments`: a document-ordered, contiguous, gapless attribution of every code unit to either literal template text or a named interpolation (`kind`, `name`, `start`, `length`, `escaped`). **Offsets are computed during the render, never recovered by searching the output** — searching is unsound for a value used twice, one that renders empty, one that is a substring of the surrounding literal text, and one that escaping alters, and it fails *silently* with plausible offsets. **Sections, inverted sections and partials are refused, not approximated** (they render non-linearly, so a map walked from the token order would mis-describe the result); comments and set-delimiter tags emit nothing and are skipped. Each interpolation is rendered from its already-parsed token via the same `Writer` primitives `render()` reaches through `renderTokens`, so value semantics — dotted paths, lambdas, `null` rendering empty, non-string coercion — are mustache.js's, and a mid-template delimiter change is invisible here rather than a hazard. **Every context value is evaluated exactly once, as `render()` evaluates it** — a lambda is invoked the same number of times either way, and a lambda that answers differently per call is rendered rather than rejected (`segments` always describes the `text` returned, whatever that text was). Reach for `render()` when you only want the string.
60
+ - **CSV / record-jar?** → `@fgv/ts-extras` (`csv` / `record-jar`).
61
+ - **Numeric / date / orderable range?** → `RangeOf<T>` from `@fgv/ts-extras/experimental`.
62
+
63
+ ---
64
+
65
+ ## Recent additions
66
+
67
+ *Newest first. **Generated** — see the repo index; do not hand-edit inside the markers.*
68
+
69
+ <!-- BEGIN GENERATED: recent-additions -->
70
+
71
+ - **2026-09-23** — A tool-augmented or streamed chat turn can now carry prompt-cache breakpoints and a routing key, closing the one remaining hole (streaming) in ai-assist's prompt-cache emission surface. ([#688](https://github.com/ErikFortune/fgv/pull/688))
72
+ - **2026-09-07** — ai-assist now places explicit prompt-cache breakpoints on Anthropic and OpenAI requests built from a ts-prompt-assist resolve, reports cache usage across every provider that supplies it, and diagnoses a cache-hostile composition before any request is sent. ([#671](https://github.com/ErikFortune/fgv/pull/671))
73
+ - **2026-09-07** — Thinking config loses a required provider field that gated nothing, and gains 'none' — the cross-provider spelling for off that every provider had except the generic vocabulary. ([#667](https://github.com/ErikFortune/fgv/pull/667))
74
+ - **2026-09-02** — Shipped: a rendered template, and a resolved prompt, can now say what they are made of — order, absolute size and binding provenance per section, with offsets computed during the render because recovering them by search is silently wrong. ([#663](https://github.com/ErikFortune/fgv/pull/663))
75
+ - **2026-08-23** — **Shipped:** an opt-in that hoists the optionals a schema already proves safe to hoist, rather than a boolean asserting they are. ([#659](https://github.com/ErikFortune/fgv/pull/659))
76
+ - **2026-08-22** — **Shipped:** `nullable: true` on every factory — the spelling OpenAI strict mode accepts for an absent-able field, where `optional(...)` is unsendable. ([#655](https://github.com/ErikFortune/fgv/pull/655))
77
+ - **2026-08-21** — A caller that has already declared the shape it wants can now **tell the provider**, and learn **which enforcement was actually applied** — on all four providers plus the proxy. ([#652](https://github.com/ErikFortune/fgv/pull/652))
78
+ - **2026-08** — Closed out the safer-fetch series. ([#601](https://github.com/ErikFortune/fgv/pull/601))
79
+ - **2026-07** — **Status:** complete. P3 / opportunistic; no consumer was blocked. ([#579](https://github.com/ErikFortune/fgv/pull/579))
80
+ - **2026-07** — base64url decoding now rejects what it cannot round-trip, and a public key carries its encoding in its type rather than in a comment. ([#519](https://github.com/ErikFortune/fgv/pull/519))
81
+ - **2026-06-06** — **Status:** complete, gates green, Copilot loop converged. Awaiting merge of PR #478 into the `ai-assist-message-ordering` integration branch. ([#478](https://github.com/ErikFortune/fgv/pull/478))
82
+ - **2026-06-06** — Shipped first-class Ollama support across two activities. ([#468](https://github.com/ErikFortune/fgv/pull/468))
83
+ - **2026-06-05** — **Status:** ready to merge — gates green, Copilot loop converged at 3 rounds, all threads resolved ([#457](https://github.com/ErikFortune/fgv/pull/457))
84
+ - **2026-06-04** — **Completed:** 2026-06-04 ([#454](https://github.com/ErikFortune/fgv/pull/454))
85
+ - **2026-06-04** — **Shipped:** 2026-06-05 via PR #458 (closeout) + the preceding three sub-stream PRs squash-merged into the integration branch. Cluster-close PR squash-merges `per-provider-testbed-scenarios` → `release`. ([#453](https://github.com/ErikFortune/fgv/pull/453))
86
+ - **2026-06** — **Type:** bug-fix + hardening + root-cause. No public API surface change (api-extractor report unchanged). ([#504](https://github.com/ErikFortune/fgv/pull/504))
87
+ - **2026-06** — **Status:** Complete — all gates green, PR open. ([#436](https://github.com/ErikFortune/fgv/pull/436))
88
+ - **2026-06** — **Status:** **cluster ready for close-out PR** ([#458](https://github.com/ErikFortune/fgv/pull/458))
89
+ - **2026-06** — Made `IAiClientToolContinuation.messages` **cumulative** across `executeClientToolTurn` rounds, so the natural consumer pattern — `continuationMessages = outcome.continuation.messages` (replace) — is correct for all N rounds. Kills the multi-round footgun where replace silently dropped earlier tool results and the model looped to the round cap. ([#488](https://github.com/ErikFortune/fgv/pull/488))
90
+ - **2026-05** — **PR:** [#329](https://github.com/ErikFortune/fgv/pull/329) — `feat(ai-assist): layered image generation options architecture (Phase B)` ([#327](https://github.com/ErikFortune/fgv/pull/327))
91
+ - **2026-05** — **PR:** [#334](https://github.com/ErikFortune/fgv/pull/334) — `feat(ai-assist): thinking-config Phase B — layered options for extended thinking/reasoning` ([#330](https://github.com/ErikFortune/fgv/pull/330))
92
+ - **2026-05** — **PR:** [#322](https://github.com/ErikFortune/fgv/pull/322) — `feat(auth-primitives-batch1): X25519 keypair, SPKI helpers, RFC 8785 canonicalize` ([#322](https://github.com/ErikFortune/fgv/pull/322))
93
+ - **2026-05** — Both implement `CryptoUtils.IArgon2idProvider` from `@fgv/ts-extras`. Output is byte-identical for the same inputs, verified by RFC 9106 §B.3 parameter set test vector and a 7-case parameter sweep. ([#344](https://github.com/ErikFortune/fgv/pull/344))
94
+ - **2026-05** — **Status:** Completed 2026-05 ([#343](https://github.com/ErikFortune/fgv/pull/343))
95
+
96
+ <!-- END GENERATED: recent-additions -->
@@ -0,0 +1,118 @@
1
+ // Copyright (c) 2026 Erik Fortune
2
+ //
3
+ // Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ // of this software and associated documentation files (the "Software"), to deal
5
+ // in the Software without restriction, including without limitation the rights
6
+ // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ // copies of the Software, and to permit persons to whom the Software is
8
+ // furnished to do so, subject to the following conditions:
9
+ //
10
+ // The above copyright notice and this permission notice shall be included in all
11
+ // copies or substantial portions of the Software.
12
+ //
13
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ // SOFTWARE.
20
+ /**
21
+ * Request-level prompt-caching plan (design.md §6.1) and the shared validation + splitting logic
22
+ * every adapter that emits cache breakpoints (Anthropic, OpenAI) uses, so the ascending/range/cap
23
+ * rules live in exactly one place rather than being re-derived per adapter.
24
+ *
25
+ * @packageDocumentation
26
+ */
27
+ import { fail, succeed } from '@fgv/ts-utils';
28
+ /**
29
+ * Validates an {@link IAiCacheRequest}'s `systemBreakpoints` against the length of the `system`
30
+ * string they index into, and optionally against a per-model write cap.
31
+ *
32
+ * @remarks
33
+ * **Fails loudly, never clamps** (design.md §6.1): a non-ascending, out-of-range, or over-cap
34
+ * offset returns `fail()`. A silently-clamped breakpoint is precisely the silent, non-caching
35
+ * failure this stream exists to remove. `systemLength` and `maxBreakpointWrites` are themselves
36
+ * validated as non-negative integers — checked before `systemBreakpoints` even, so a malformed
37
+ * value (`NaN`, a negative number, a fraction) fails loudly rather than silently passing every
38
+ * offset check it should have rejected (`offset >= NaN` is always `false`) or, for the cap,
39
+ * passing unnoticed on an empty offset list. `maxBreakpointWrites` is left to the caller to supply —
40
+ * there is no model-keyed cap table in this package yet (design.md §7's `IAiCacheCapability` is
41
+ * out of this slice's scope), and design.md §5.2 establishes that a caller producing breakpoints
42
+ * from the three-level `PromptCacheStability` vocabulary never emits more than two, so an
43
+ * omitted cap is not a soundness gap for that caller — only a direct caller supplying more than a
44
+ * provider's cap would ever need one enforced here.
45
+ *
46
+ * Takes a length rather than {@link validateAiCacheRequest}'s `system` string because a caller
47
+ * building a plan from an `@fgv/ts-prompt-assist` `IPromptComposition` (`totalChars`) has the
48
+ * length without holding the resolved body text itself.
49
+ * @public
50
+ */
51
+ export function validateCacheBreakpoints(systemLength, cache, maxBreakpointWrites) {
52
+ if (!Number.isInteger(systemLength) || systemLength < 0) {
53
+ return fail(`systemLength (${systemLength}) must be a non-negative integer`);
54
+ }
55
+ if (maxBreakpointWrites !== undefined &&
56
+ (!Number.isInteger(maxBreakpointWrites) || maxBreakpointWrites < 0)) {
57
+ return fail(`maxBreakpointWrites (${maxBreakpointWrites}) must be a non-negative integer`);
58
+ }
59
+ const offsets = cache.systemBreakpoints;
60
+ if (offsets === undefined || offsets.length === 0) {
61
+ return succeed(cache);
62
+ }
63
+ if (maxBreakpointWrites !== undefined && offsets.length > maxBreakpointWrites) {
64
+ return fail(`cache.systemBreakpoints has ${offsets.length} entries, exceeding the cap of ` +
65
+ `${maxBreakpointWrites} for this request`);
66
+ }
67
+ let previous = 0;
68
+ for (let i = 0; i < offsets.length; i++) {
69
+ const offset = offsets[i];
70
+ if (!Number.isInteger(offset) || offset <= 0 || offset >= systemLength) {
71
+ return fail(`cache.systemBreakpoints[${i}] (${offset}) must be an integer strictly between 0 and ` +
72
+ `system.length (${systemLength})`);
73
+ }
74
+ if (offset <= previous) {
75
+ return fail(`cache.systemBreakpoints must be strictly ascending; [${i}] (${offset}) does not exceed ` +
76
+ `the preceding offset (${previous})`);
77
+ }
78
+ previous = offset;
79
+ }
80
+ return succeed(cache);
81
+ }
82
+ /**
83
+ * Validates an {@link IAiCacheRequest}'s `systemBreakpoints` against the `system` string they
84
+ * index into. Thin wrapper over {@link validateCacheBreakpoints} for a caller that holds the
85
+ * actual string (every `ts-extras` adapter) rather than just its length.
86
+ * @public
87
+ */
88
+ export function validateAiCacheRequest(system, cache, maxBreakpointWrites) {
89
+ return validateCacheBreakpoints(system.length, cache, maxBreakpointWrites);
90
+ }
91
+ /**
92
+ * Splits `system` into cache-annotated segments per `cache.systemBreakpoints`, validating first.
93
+ *
94
+ * @remarks
95
+ * When `cache` is `undefined` or declares no breakpoints, returns the whole string as a single
96
+ * non-breakpointed segment — an un-annotated caller's request body is byte-identical to before
97
+ * this feature existed (design.md §11). Shared by the Anthropic and OpenAI adapters so the
98
+ * splitting logic (and its validation) is written once.
99
+ * @internal
100
+ */
101
+ export function splitSystemForCache(system, cache, maxBreakpointWrites) {
102
+ if ((cache === null || cache === void 0 ? void 0 : cache.systemBreakpoints) === undefined || cache.systemBreakpoints.length === 0) {
103
+ return succeed([{ text: system, cacheBreakpoint: false }]);
104
+ }
105
+ return validateAiCacheRequest(system, cache, maxBreakpointWrites).onSuccess((validated) => {
106
+ const segments = [];
107
+ let start = 0;
108
+ // `validateAiCacheRequest` already guarantees `systemBreakpoints` is defined, non-empty,
109
+ // strictly ascending, and every offset is `> 0` and `< system.length`.
110
+ for (const offset of validated.systemBreakpoints) {
111
+ segments.push({ text: system.slice(start, offset), cacheBreakpoint: true });
112
+ start = offset;
113
+ }
114
+ segments.push({ text: system.slice(start), cacheBreakpoint: false });
115
+ return succeed(segments);
116
+ });
117
+ }
118
+ //# sourceMappingURL=cacheRequest.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cacheRequest.js","sourceRoot":"","sources":["../../../src/packlets/ai-assist/cacheRequest.ts"],"names":[],"mappings":"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","sourcesContent":["// Copyright (c) 2026 Erik Fortune\n//\n// Permission is hereby granted, free of charge, to any person obtaining a copy\n// of this software and associated documentation files (the \"Software\"), to deal\n// in the Software without restriction, including without limitation the rights\n// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n// copies of the Software, and to permit persons to whom the Software is\n// furnished to do so, subject to the following conditions:\n//\n// The above copyright notice and this permission notice shall be included in all\n// copies or substantial portions of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n// SOFTWARE.\n\n/**\n * Request-level prompt-caching plan (design.md §6.1) and the shared validation + splitting logic\n * every adapter that emits cache breakpoints (Anthropic, OpenAI) uses, so the ascending/range/cap\n * rules live in exactly one place rather than being re-derived per adapter.\n *\n * @packageDocumentation\n */\n\nimport { fail, Result, succeed } from '@fgv/ts-utils';\n\n/**\n * A request-level prompt-caching plan.\n *\n * @remarks\n * Offsets rather than content blocks — see design.md §6.1 for why: `AiPrompt`/`IChatRequest`\n * carry `system` as a plain string, offsets are usable without `@fgv/ts-prompt-assist` (a\n * caller can say \"the first 4,000 characters of my system prompt are stable\" directly), and\n * they are already the currency `IPromptSection.start`/`.chars` report.\n * @public\n */\nexport interface IAiCacheRequest {\n /**\n * Strictly ascending UTF-16 offsets into `AiPrompt.system` (equivalently\n * `IProviderCompletionParams.system`), each marking the end of a cacheable prefix. Each must\n * be an integer `> 0` and `< system.length`. Validated where the offsets are actually used\n * (against the request's own `system.length`, and optionally against a cap) — never clamped;\n * an invalid plan fails the request rather than silently caching less than declared. Sent where\n * the provider has a breakpoint mechanism (Anthropic `cache_control`, OpenAI\n * `prompt_cache_breakpoint`). Ignored by adapters that have no such concept (Gemini).\n */\n readonly systemBreakpoints?: ReadonlyArray<number>;\n /**\n * Opaque cache-routing key, sent where the provider has one and by that provider's transport:\n * `prompt_cache_key` as a body field on OpenAI (both routes) and on xAI Responses, and an\n * `x-grok-conv-id` header on xAI Chat Completions. See `supportsPromptCacheRouting`.\n *\n * @remarks\n * On xAI this is not a nicety. Its prompt cache is per-server and evictable, so two requests\n * sharing a prefix can still miss if they are routed to different servers; the key pins them\n * together. Providers with no routing concept ignore it and receive a request byte-identical\n * to one built without a `cache` at all.\n */\n readonly cacheKey?: string;\n}\n\n/**\n * Validates an {@link IAiCacheRequest}'s `systemBreakpoints` against the length of the `system`\n * string they index into, and optionally against a per-model write cap.\n *\n * @remarks\n * **Fails loudly, never clamps** (design.md §6.1): a non-ascending, out-of-range, or over-cap\n * offset returns `fail()`. A silently-clamped breakpoint is precisely the silent, non-caching\n * failure this stream exists to remove. `systemLength` and `maxBreakpointWrites` are themselves\n * validated as non-negative integers — checked before `systemBreakpoints` even, so a malformed\n * value (`NaN`, a negative number, a fraction) fails loudly rather than silently passing every\n * offset check it should have rejected (`offset >= NaN` is always `false`) or, for the cap,\n * passing unnoticed on an empty offset list. `maxBreakpointWrites` is left to the caller to supply —\n * there is no model-keyed cap table in this package yet (design.md §7's `IAiCacheCapability` is\n * out of this slice's scope), and design.md §5.2 establishes that a caller producing breakpoints\n * from the three-level `PromptCacheStability` vocabulary never emits more than two, so an\n * omitted cap is not a soundness gap for that caller — only a direct caller supplying more than a\n * provider's cap would ever need one enforced here.\n *\n * Takes a length rather than {@link validateAiCacheRequest}'s `system` string because a caller\n * building a plan from an `@fgv/ts-prompt-assist` `IPromptComposition` (`totalChars`) has the\n * length without holding the resolved body text itself.\n * @public\n */\nexport function validateCacheBreakpoints(\n systemLength: number,\n cache: IAiCacheRequest,\n maxBreakpointWrites?: number\n): Result<IAiCacheRequest> {\n if (!Number.isInteger(systemLength) || systemLength < 0) {\n return fail(`systemLength (${systemLength}) must be a non-negative integer`);\n }\n if (\n maxBreakpointWrites !== undefined &&\n (!Number.isInteger(maxBreakpointWrites) || maxBreakpointWrites < 0)\n ) {\n return fail(`maxBreakpointWrites (${maxBreakpointWrites}) must be a non-negative integer`);\n }\n const offsets = cache.systemBreakpoints;\n if (offsets === undefined || offsets.length === 0) {\n return succeed(cache);\n }\n if (maxBreakpointWrites !== undefined && offsets.length > maxBreakpointWrites) {\n return fail(\n `cache.systemBreakpoints has ${offsets.length} entries, exceeding the cap of ` +\n `${maxBreakpointWrites} for this request`\n );\n }\n let previous = 0;\n for (let i = 0; i < offsets.length; i++) {\n const offset = offsets[i];\n if (!Number.isInteger(offset) || offset <= 0 || offset >= systemLength) {\n return fail(\n `cache.systemBreakpoints[${i}] (${offset}) must be an integer strictly between 0 and ` +\n `system.length (${systemLength})`\n );\n }\n if (offset <= previous) {\n return fail(\n `cache.systemBreakpoints must be strictly ascending; [${i}] (${offset}) does not exceed ` +\n `the preceding offset (${previous})`\n );\n }\n previous = offset;\n }\n return succeed(cache);\n}\n\n/**\n * Validates an {@link IAiCacheRequest}'s `systemBreakpoints` against the `system` string they\n * index into. Thin wrapper over {@link validateCacheBreakpoints} for a caller that holds the\n * actual string (every `ts-extras` adapter) rather than just its length.\n * @public\n */\nexport function validateAiCacheRequest(\n system: string,\n cache: IAiCacheRequest,\n maxBreakpointWrites?: number\n): Result<IAiCacheRequest> {\n return validateCacheBreakpoints(system.length, cache, maxBreakpointWrites);\n}\n\n/**\n * One piece of `system` after splitting at its cache breakpoints. Concatenating every segment's\n * `text`, in order, reproduces `system` exactly.\n * @public\n */\nexport interface ICacheSystemSegment {\n /** This segment's text, a contiguous slice of the original `system` string. */\n readonly text: string;\n /**\n * True for the segment that ends a cacheable prefix — the one a provider's adapter should mark\n * with its native cache directive (Anthropic `cache_control`, OpenAI `prompt_cache_breakpoint`).\n * False for every other segment, including the final (volatile) tail after the last breakpoint.\n */\n readonly cacheBreakpoint: boolean;\n}\n\n/**\n * Splits `system` into cache-annotated segments per `cache.systemBreakpoints`, validating first.\n *\n * @remarks\n * When `cache` is `undefined` or declares no breakpoints, returns the whole string as a single\n * non-breakpointed segment — an un-annotated caller's request body is byte-identical to before\n * this feature existed (design.md §11). Shared by the Anthropic and OpenAI adapters so the\n * splitting logic (and its validation) is written once.\n * @internal\n */\nexport function splitSystemForCache(\n system: string,\n cache: IAiCacheRequest | undefined,\n maxBreakpointWrites?: number\n): Result<ReadonlyArray<ICacheSystemSegment>> {\n if (cache?.systemBreakpoints === undefined || cache.systemBreakpoints.length === 0) {\n return succeed([{ text: system, cacheBreakpoint: false }]);\n }\n return validateAiCacheRequest(system, cache, maxBreakpointWrites).onSuccess((validated) => {\n const segments: ICacheSystemSegment[] = [];\n let start = 0;\n // `validateAiCacheRequest` already guarantees `systemBreakpoints` is defined, non-empty,\n // strictly ascending, and every offset is `> 0` and `< system.length`.\n for (const offset of validated.systemBreakpoints as ReadonlyArray<number>) {\n segments.push({ text: system.slice(start, offset), cacheBreakpoint: true });\n start = offset;\n }\n segments.push({ text: system.slice(start), cacheBreakpoint: false });\n return succeed(segments);\n });\n}\n"]}
@@ -19,13 +19,14 @@
19
19
  // SOFTWARE.
20
20
  /**
21
21
  * Per-format chat request shape builders. Shared between the synchronous
22
- * (`apiClient.ts`) and streaming (`streamingClient.ts`) paths so the wire
23
- * shapes stay consistent.
22
+ * (`completionClient.ts`) and streaming (`streamingClient.ts`) paths so the
23
+ * wire shapes stay consistent.
24
24
  *
25
25
  * @packageDocumentation
26
26
  */
27
27
  import { isJsonObject } from '@fgv/ts-json-base';
28
28
  import { Converters, fail, succeed } from '@fgv/ts-utils';
29
+ import { splitSystemForCache } from './cacheRequest';
29
30
  import { AiPrompt, toDataUrl } from './model';
30
31
  /**
31
32
  * Splits a unified {@link AiAssist.IChatRequest} into `{ prompt, head }` for the
@@ -116,9 +117,11 @@ const geminiRawTailMessageConverter = Converters.object({
116
117
  }, { strict: false });
117
118
  /**
118
119
  * Builds the messages array from prompt + optional history (`head`) and raw
119
- * continuation (`rawTail`) messages. The caller supplies the user content
120
- * (string for text-only, parts array for vision prompts) since the parts shape
121
- * differs by format.
120
+ * continuation (`rawTail`) messages. The caller supplies the system content
121
+ * (string when no cache breakpoints are requested, parts array when they are
122
+ * — see {@link buildOpenAiChatSystemContent} / {@link buildOpenAiResponsesSystemContent})
123
+ * and the user content (string for text-only, parts array for vision prompts),
124
+ * since both shapes differ by format.
122
125
  *
123
126
  * `rawTail` items (OpenAI / xAI Responses `function_call` /
124
127
  * `function_call_output` continuation items) are appended verbatim after the
@@ -129,8 +132,8 @@ const geminiRawTailMessageConverter = Converters.object({
129
132
  *
130
133
  * @internal
131
134
  */
132
- export function buildMessages(systemPrompt, userContent, options) {
133
- const messages = [{ role: 'system', content: systemPrompt }];
135
+ export function buildMessages(systemContent, userContent, options) {
136
+ const messages = [{ role: 'system', content: systemContent }];
134
137
  if (options === null || options === void 0 ? void 0 : options.head) {
135
138
  for (const msg of options.head) {
136
139
  messages.push({ role: msg.role, content: msg.content });
@@ -150,6 +153,68 @@ export function buildMessages(systemPrompt, userContent, options) {
150
153
  }
151
154
  return messages;
152
155
  }
156
+ /**
157
+ * Splits `segments` (from {@link splitSystemForCache}) into a single string when nothing carries
158
+ * a breakpoint, or a content-part array in `partType`'s shape (with `prompt_cache_breakpoint` on
159
+ * the parts that end a cacheable prefix) otherwise.
160
+ *
161
+ * @remarks
162
+ * A single non-breakpointed segment (the common case: `cache` omitted or empty) returns the
163
+ * plain string, byte-identical to a request built before cache support existed. Shared by
164
+ * {@link buildOpenAiChatSystemContent} and {@link buildOpenAiResponsesSystemContent}, whose only
165
+ * difference is the content-part `type` OpenAI's two APIs use (`'text'` vs `'input_text'`,
166
+ * research.md §1.2).
167
+ * @internal
168
+ */
169
+ function toOpenAiSystemContent(segments, partType) {
170
+ if (segments.length === 1 && !segments[0].cacheBreakpoint) {
171
+ return segments[0].text;
172
+ }
173
+ return segments.map((seg) => (Object.assign({ type: partType, text: seg.text }, (seg.cacheBreakpoint
174
+ ? {
175
+ // eslint-disable-next-line @typescript-eslint/naming-convention -- wire field name
176
+ prompt_cache_breakpoint: { mode: 'explicit' }
177
+ }
178
+ : {}))));
179
+ }
180
+ /**
181
+ * Builds the system content for OpenAI Chat Completions, splitting `system` at `cache`'s
182
+ * breakpoints (design.md §6.3) into content parts each carrying
183
+ * `prompt_cache_breakpoint: { mode: 'explicit' }` where a cacheable prefix ends.
184
+ * `prompt_cache_options` is never touched — OpenAI keeps its own implicit breakpoint and writes
185
+ * up to the latest three of ours on top of it. Returns the plain `system` string when `cache` is
186
+ * `undefined` or declares no breakpoints, byte-identical to a request built before cache support
187
+ * existed.
188
+ * @internal
189
+ */
190
+ export function buildOpenAiChatSystemContent(system, cache) {
191
+ return splitSystemForCache(system, cache).onSuccess((segments) => succeed(toOpenAiSystemContent(segments, 'text')));
192
+ }
193
+ /**
194
+ * Builds the system content for the OpenAI / xAI Responses API. Identical to
195
+ * {@link buildOpenAiChatSystemContent} except for the content-part type (`'input_text'` instead
196
+ * of `'text'`) — see research.md §1.2, "Where the breakpoint may be attached."
197
+ * @internal
198
+ */
199
+ export function buildOpenAiResponsesSystemContent(system, cache) {
200
+ return splitSystemForCache(system, cache).onSuccess((segments) => succeed(toOpenAiSystemContent(segments, 'input_text')));
201
+ }
202
+ /**
203
+ * Builds the Anthropic `system` field, splitting `system` at `cache`'s breakpoints into content
204
+ * blocks carrying `cache_control: { type: 'ephemeral' }` on the block that ends each cacheable
205
+ * prefix (design.md §6.1). Returns the plain `system` string when `cache` is `undefined` or
206
+ * declares no breakpoints — byte-identical to `system: prompt.system` from a request built
207
+ * before cache support existed.
208
+ * @internal
209
+ */
210
+ export function buildAnthropicSystem(system, cache) {
211
+ return splitSystemForCache(system, cache).onSuccess((segments) => {
212
+ if (segments.length === 1 && !segments[0].cacheBreakpoint) {
213
+ return succeed(segments[0].text);
214
+ }
215
+ return succeed(segments.map((seg) => (Object.assign({ type: 'text', text: seg.text }, (seg.cacheBreakpoint ? { cache_control: { type: 'ephemeral' } } : {})))));
216
+ });
217
+ }
153
218
  /**
154
219
  * Builds the user content for OpenAI Chat Completions when attachments are
155
220
  * present. Returns a string when there are no attachments.
@@ -1 +1 @@
1
- {"version":3,"file":"chatRequestBuilders.js","sourceRoot":"","sources":["../../../src/packlets/ai-assist/chatRequestBuilders.ts"],"names":[],"mappings":"AAAA,kCAAkC;AAClC,EAAE;AACF,+EAA+E;AAC/E,gFAAgF;AAChF,+EAA+E;AAC/E,4EAA4E;AAC5E,wEAAwE;AACxE,2DAA2D;AAC3D,EAAE;AACF,iFAAiF;AACjF,kDAAkD;AAClD,EAAE;AACF,6EAA6E;AAC7E,2EAA2E;AAC3E,8EAA8E;AAC9E,yEAAyE;AACzE,gFAAgF;AAChF,gFAAgF;AAChF,YAAY;AAEZ;;;;;;GAMG;AAEH,OAAO,EAAE,YAAY,EAAmB,MAAM,mBAAmB,CAAC;AAClE,OAAO,EAAkB,UAAU,EAAE,IAAI,EAAU,OAAO,EAAE,MAAM,eAAe,CAAC;AAElF,OAAO,EAAE,QAAQ,EAA8C,SAAS,EAAE,MAAM,SAAS,CAAC;AAe1F;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,gBAAgB,CAC9B,MAA0B,EAC1B,QAAqC;IAErC,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC1B,OAAO,IAAI,CAAC,kEAAkE,CAAC,CAAC;IAClF,CAAC;IACD,MAAM,OAAO,GAAG,QAAQ,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IAC9C,IAAI,OAAO,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;QAC5B,OAAO,IAAI,CAAC,sEAAsE,OAAO,CAAC,IAAI,GAAG,CAAC,CAAC;IACrG,CAAC;IACD,MAAM,IAAI,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IACpD,MAAM,MAAM,GAAG,IAAI,QAAQ,CAAC,OAAO,CAAC,OAAO,EAAE,MAAM,aAAN,MAAM,cAAN,MAAM,GAAI,EAAE,EAAE,OAAO,CAAC,WAAW,CAAC,CAAC;IAChF,OAAO,OAAO,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC;AACnC,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,yBAAyB,CAAC,KAAwB;IAChE,OAAO;QACL,GAAG,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC;QAChE,GAAG,KAAK,CAAC,MAAM,CAAC,SAAS,EAAE,CAAC,QAAQ;KACrC,CAAC;AACJ,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,uBAAuB,GAC3B,UAAU,CAAC,MAAM,CACf;IACE,IAAI,EAAE,UAAU,CAAC,eAAe,CAAuB,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IAC7E,OAAO,EAAE,UAAU,CAAC,KAAK,CAAqB;QAC5C,UAAU,CAAC,MAAM;QACjB,UAAU,CAAC,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC,EAAkB,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;KACjE,CAAC;CACH,EACD,EAAE,MAAM,EAAE,KAAK,EAAE,CAClB,CAAC;AAEJ;;;;;;;;;;;;;;GAcG;AACH,MAAM,0BAA0B,GAA0B,UAAU,CAAC,GAAG,CACtE,YAAY,EACZ,CAAC,CAAC,EAAmB,EAAE,CAAC,YAAY,CAAC,CAAC,CAAC,CACxC,CAAC;AAEF;;;;;;;GAOG;AACH,MAAM,6BAA6B,GAG9B,UAAU,CAAC,MAAM,CACpB;IACE,IAAI,EAAE,UAAU,CAAC,eAAe,CAAmB,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrE,6EAA6E;IAC7E,0EAA0E;IAC1E,qFAAqF;IACrF,KAAK,EAAE,UAAU,CAAC,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC,EAAkB,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;CACxE,EACD,EAAE,MAAM,EAAE,KAAK,EAAE,CAClB,CAAC;AAoCF;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,aAAa,CAC3B,YAAoB,EACpB,WAA+B,EAC/B,OAA+B;IAE/B,MAAM,QAAQ,GAAmC,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,YAAY,EAAE,CAAC,CAAC;IAC7F,IAAI,OAAO,aAAP,OAAO,uBAAP,OAAO,CAAE,IAAI,EAAE,CAAC;QAClB,KAAK,MAAM,GAAG,IAAI,OAAO,CAAC,IAAI,EAAE,CAAC;YAC/B,QAAQ,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,GAAG,CAAC,IAAI,EAAE,OAAO,EAAE,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC;QAC1D,CAAC;IACH,CAAC;IACD,QAAQ,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,EAAE,CAAC,CAAC;IACtD,6DAA6D;IAC7D,4EAA4E;IAC5E,qEAAqE;IACrE,IAAI,OAAO,aAAP,OAAO,uBAAP,OAAO,CAAE,OAAO,EAAE,CAAC;QACrB,KAAK,MAAM,IAAI,IAAI,OAAO,CAAC,OAAO,EAAE,CAAC;YACnC,MAAM,SAAS,GAAG,0BAA0B,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;YAC3D,IAAI,SAAS,CAAC,SAAS,EAAE,EAAE,CAAC;gBAC1B,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC;YACjC,CAAC;QACH,CAAC;IACH,CAAC;IACD,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,0BAA0B,CAAC,MAAgB;IACzD,IAAI,MAAM,CAAC,WAAW,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACpC,OAAO,MAAM,CAAC,IAAI,CAAC;IACrB,CAAC;IACD,OAAO;QACL,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE;QACnC,GAAG,MAAM,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,GAAuB,EAAE,EAAE,CAAC,CAAC;YACtD,IAAI,EAAE,WAAW;YACjB,SAAS,kBACP,GAAG,EAAE,SAAS,CAAC,GAAG,CAAC,IAChB,CAAC,GAAG,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAC5D;SACF,CAAC,CAAC;KACJ,CAAC;AACJ,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,+BAA+B,CAAC,MAAgB;IAC9D,IAAI,MAAM,CAAC,WAAW,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACpC,OAAO,MAAM,CAAC,IAAI,CAAC;IACrB,CAAC;IACD,OAAO;QACL,EAAE,IAAI,EAAE,YAAY,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE;QACzC,GAAG,MAAM,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,GAAuB,EAAE,EAAE,CAAC,iBACrD,IAAI,EAAE,aAAa,EACnB,SAAS,EAAE,SAAS,CAAC,GAAG,CAAC,IACtB,CAAC,GAAG,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAC3D,CAAC;KACJ,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,yBAAyB,CAAC,MAAgB;IACxD,IAAI,MAAM,CAAC,WAAW,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACpC,OAAO,MAAM,CAAC,IAAI,CAAC;IACrB,CAAC;IACD,OAAO;QACL,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE;QACnC,GAAG,MAAM,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,GAAuB,EAAE,EAAE,CAAC,CAAC;YACtD,IAAI,EAAE,OAAO;YACb,MAAM,EAAE;gBACN,IAAI,EAAE,QAAQ;gBACd,UAAU,EAAE,GAAG,CAAC,QAAQ;gBACxB,IAAI,EAAE,GAAG,CAAC,MAAM;aACjB;SACF,CAAC,CAAC;KACJ,CAAC;AACJ,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,oBAAoB,CAAC,MAAgB;IACnD,MAAM,KAAK,GAAmC,CAAC,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;IACtE,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,WAAW,EAAE,CAAC;QACrC,KAAK,CAAC,IAAI,CAAC,EAAE,UAAU,EAAE,EAAE,QAAQ,EAAE,GAAG,CAAC,QAAQ,EAAE,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IAC3E,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,sBAAsB,CACpC,MAAgB,EAChB,OAA+B;IAE/B,MAAM,QAAQ,GAAyD,EAAE,CAAC;IAC1E,IAAI,OAAO,aAAP,OAAO,uBAAP,OAAO,CAAE,IAAI,EAAE,CAAC;QAClB,KAAK,MAAM,GAAG,IAAI,OAAO,CAAC,IAAI,EAAE,CAAC;YAC/B,IAAI,GAAG,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;gBAC1B,QAAQ,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,GAAG,CAAC,IAAI,EAAE,OAAO,EAAE,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC;YAC1D,CAAC;QACH,CAAC;IACH,CAAC;IACD,QAAQ,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,yBAAyB,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;IAC5E,IAAI,OAAO,aAAP,OAAO,uBAAP,OAAO,CAAE,OAAO,EAAE,CAAC;QACrB,KAAK,MAAM,GAAG,IAAI,OAAO,CAAC,OAAO,EAAE,CAAC;YAClC,MAAM,SAAS,GAAG,uBAAuB,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;YACvD,IAAI,SAAS,CAAC,SAAS,EAAE,EAAE,CAAC;gBAC1B,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC;YACjC,CAAC;QACH,CAAC;IACH,CAAC;IACD,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,mBAAmB,CACjC,MAAgB,EAChB,OAA+B;IAE/B,MAAM,QAAQ,GAA8C,EAAE,CAAC;IAC/D,IAAI,OAAO,aAAP,OAAO,uBAAP,OAAO,CAAE,IAAI,EAAE,CAAC;QAClB,KAAK,MAAM,GAAG,IAAI,OAAO,CAAC,IAAI,EAAE,CAAC;YAC/B,IAAI,GAAG,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;gBAC1B,QAAQ,CAAC,IAAI,CAAC;oBACZ,IAAI,EAAE,GAAG,CAAC,IAAI,KAAK,WAAW,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI;oBACnD,KAAK,EAAE,CAAC,EAAE,IAAI,EAAE,GAAG,CAAC,OAAO,EAAE,CAAC;iBAC/B,CAAC,CAAC;YACL,CAAC;QACH,CAAC;IACH,CAAC;IACD,QAAQ,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,oBAAoB,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;IACrE,+DAA+D;IAC/D,gEAAgE;IAChE,IAAI,OAAO,aAAP,OAAO,uBAAP,OAAO,CAAE,OAAO,EAAE,CAAC;QACrB,KAAK,MAAM,IAAI,IAAI,OAAO,CAAC,OAAO,EAAE,CAAC;YACnC,MAAM,SAAS,GAAG,6BAA6B,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;YAC9D,IAAI,SAAS,CAAC,SAAS,EAAE,EAAE,CAAC;gBAC1B,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC;YACjC,CAAC;QACH,CAAC;IACH,CAAC;IACD,OAAO,QAAQ,CAAC;AAClB,CAAC","sourcesContent":["// Copyright (c) 2026 Erik Fortune\n//\n// Permission is hereby granted, free of charge, to any person obtaining a copy\n// of this software and associated documentation files (the \"Software\"), to deal\n// in the Software without restriction, including without limitation the rights\n// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n// copies of the Software, and to permit persons to whom the Software is\n// furnished to do so, subject to the following conditions:\n//\n// The above copyright notice and this permission notice shall be included in all\n// copies or substantial portions of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n// SOFTWARE.\n\n/**\n * Per-format chat request shape builders. Shared between the synchronous\n * (`apiClient.ts`) and streaming (`streamingClient.ts`) paths so the wire\n * shapes stay consistent.\n *\n * @packageDocumentation\n */\n\nimport { isJsonObject, type JsonObject } from '@fgv/ts-json-base';\nimport { type Converter, Converters, fail, Result, succeed } from '@fgv/ts-utils';\n\nimport { AiPrompt, type IAiImageAttachment, type IChatMessage, toDataUrl } from './model';\n\n/**\n * The result of splitting an {@link AiAssist.IChatRequest} into the per-builder\n * inputs: the current turn (as an {@link AiPrompt}, carrying system + the final\n * user message + any attachments) and the preceding conversation history.\n * @internal\n */\nexport interface ISplitChatRequest {\n /** The current turn lowered to an {@link AiPrompt} (system + user + attachments). */\n readonly prompt: AiPrompt;\n /** Prior conversation history — every message before the current turn. */\n readonly head: ReadonlyArray<IChatMessage>;\n}\n\n/**\n * Splits a unified {@link AiAssist.IChatRequest} into `{ prompt, head }` for the\n * per-provider builders. The **last** message is the current `user` turn; the\n * preceding messages are history (`head`). This is the single linearization\n * shared by every turn entry point, so the completion path and the client-tool\n * turn path place history at the identical position relative to the current turn.\n *\n * Fails when `messages` is empty (no current turn) or when the last message is\n * not a `user` turn — relabelling a trailing assistant message as the user turn\n * would be a silent footgun, so it is rejected loudly instead.\n *\n * @internal\n */\nexport function splitChatRequest(\n system: string | undefined,\n messages: ReadonlyArray<IChatMessage>\n): Result<ISplitChatRequest> {\n if (messages.length === 0) {\n return fail('messages must contain at least one entry (the current user turn)');\n }\n const current = messages[messages.length - 1];\n if (current.role !== 'user') {\n return fail(`the last message must be the current user turn (role 'user'); got '${current.role}'`);\n }\n const head = messages.slice(0, messages.length - 1);\n const prompt = new AiPrompt(current.content, system ?? '', current.attachments);\n return succeed({ prompt, head });\n}\n\n/**\n * Rebuilds the ordered messages for a proxy wire body so that only the current\n * turn can carry attachments — history (non-current) messages are reduced to\n * `{ role, content }`. The direct per-provider builders already drop attachments\n * on history turns (only the current user turn's attachments are honored), so\n * normalizing here keeps the proxy wire shape consistent with the direct paths\n * and avoids transmitting attachment payloads the upstream provider would ignore.\n *\n * @internal\n */\nexport function normalizeOutboundMessages(split: ISplitChatRequest): IChatMessage[] {\n return [\n ...split.head.map((m) => ({ role: m.role, content: m.content })),\n ...split.prompt.toRequest().messages\n ];\n}\n\n/**\n * Converter for a rawTail message entry. Narrows a `JsonObject` to\n * `{ role: string; content: string | unknown[] }` at runtime using the\n * Converter pattern. Entries that fail validation are silently skipped — the\n * surrounding function is infallible, and a malformed continuation message is\n * better omitted than transmitted verbatim.\n * @internal\n */\nconst rawTailMessageConverter: Converter<{ role: 'user' | 'assistant'; content: string | unknown[] }> =\n Converters.object<{ role: 'user' | 'assistant'; content: string | unknown[] }>(\n {\n role: Converters.enumeratedValue<'user' | 'assistant'>(['user', 'assistant']),\n content: Converters.oneOf<string | unknown[]>([\n Converters.string,\n Converters.isA('array', (v): v is unknown[] => Array.isArray(v))\n ])\n },\n { strict: false }\n );\n\n/**\n * Converter for an OpenAI / xAI Responses API `rawTail` item. These are\n * provider-native input items (`function_call`, `function_call_output`) whose\n * fields differ per item type, so — unlike the Anthropic `{ role, content }`\n * projection — the whole object is preserved verbatim.\n *\n * The static input is already typed `JsonObject`, so the `isJsonObject` guard\n * is a runtime backstop, not a compile-time narrowing: continuation messages\n * originate from a prior turn's `IAiClientToolContinuation.messages` and a\n * consumer may persist and reload them through untyped JSON before passing them\n * back. The guard preserves the same \"a malformed continuation message is\n * better omitted than transmitted verbatim\" posture as the Anthropic path —\n * non-object entries fail conversion and are skipped by the caller.\n * @internal\n */\nconst openAiRawTailItemConverter: Converter<JsonObject> = Converters.isA<JsonObject>(\n 'JsonObject',\n (v): v is JsonObject => isJsonObject(v)\n);\n\n/**\n * Converter for a Gemini `rawTail` item. Gemini continuation messages are\n * `{ role, parts }` turns (a model turn with `functionCall` parts followed by a\n * user turn with `functionResponse` parts). Narrows a `JsonObject` to\n * `{ role: 'user' | 'model'; parts: Array<Record<string, unknown>> }`; entries\n * that fail validation are skipped by the caller.\n * @internal\n */\nconst geminiRawTailMessageConverter: Converter<{\n role: 'user' | 'model';\n parts: unknown[];\n}> = Converters.object<{ role: 'user' | 'model'; parts: unknown[] }>(\n {\n role: Converters.enumeratedValue<'user' | 'model'>(['user', 'model']),\n // `parts` is preserved verbatim and serialized into the request body, so the\n // element shape is not narrowed here — `Array.isArray` soundly guarantees\n // `unknown[]` (narrowing to `Record<string, unknown>[]` would be an unchecked cast).\n parts: Converters.isA('array', (v): v is unknown[] => Array.isArray(v))\n },\n { strict: false }\n);\n\n/**\n * Optional history (`head`) and raw continuation (`rawTail`) messages to weave\n * around the prompt's current user message.\n *\n * @internal\n */\nexport interface IBuildMessagesOptions {\n /**\n * Prior conversation history inserted between the system prompt and the\n * prompt's current user message (multi-turn chat / correction retries). The\n * single ordered linearization is `[system, ...head, user, ...rawTail]`.\n */\n readonly head?: ReadonlyArray<IChatMessage>;\n /**\n * Raw JSON objects appended after the prompt's user message. Used to\n * inject provider-specific continuation messages (e.g. Anthropic assistant\n * turns with thinking blocks, OpenAI Responses `function_call` /\n * `function_call_output` items, Gemini `functionCall` / `functionResponse`\n * turns) that cannot be expressed as plain {@link IChatMessage} objects.\n *\n * Each builder applies its own provider-specific shape guard:\n * - {@link buildAnthropicMessages} projects each entry to `{ role, content }`.\n * - {@link buildMessages} (OpenAI / xAI Responses) preserves each item\n * verbatim (item fields differ per `type`), guarding only that it is a\n * JSON object.\n * - {@link buildGeminiContents} projects each entry to `{ role, parts }`.\n *\n * Entries that fail their builder's shape check are silently skipped (the\n * caller is responsible for supplying well-formed continuation messages).\n * Appended after the current user message.\n */\n readonly rawTail?: ReadonlyArray<JsonObject>;\n}\n\n/**\n * Builds the messages array from prompt + optional history (`head`) and raw\n * continuation (`rawTail`) messages. The caller supplies the user content\n * (string for text-only, parts array for vision prompts) since the parts shape\n * differs by format.\n *\n * `rawTail` items (OpenAI / xAI Responses `function_call` /\n * `function_call_output` continuation items) are appended verbatim after the\n * user message — their fields differ per item `type`, so they are preserved\n * rather than projected. The return type is `Array<Record<string, unknown>>`\n * to accommodate both `{ role, content }` messages and these heterogeneous\n * input items.\n *\n * @internal\n */\nexport function buildMessages(\n systemPrompt: string,\n userContent: string | unknown[],\n options?: IBuildMessagesOptions\n): Array<Record<string, unknown>> {\n const messages: Array<Record<string, unknown>> = [{ role: 'system', content: systemPrompt }];\n if (options?.head) {\n for (const msg of options.head) {\n messages.push({ role: msg.role, content: msg.content });\n }\n }\n messages.push({ role: 'user', content: userContent });\n // OpenAI / xAI Responses continuation items (function_call /\n // function_call_output) are appended verbatim — their field set differs per\n // item type, so the whole object is preserved rather than projected.\n if (options?.rawTail) {\n for (const item of options.rawTail) {\n const converted = openAiRawTailItemConverter.convert(item);\n if (converted.isSuccess()) {\n messages.push(converted.value);\n }\n }\n }\n return messages;\n}\n\n/**\n * Builds the user content for OpenAI Chat Completions when attachments are\n * present. Returns a string when there are no attachments.\n *\n * @internal\n */\nexport function buildOpenAiChatUserContent(prompt: AiPrompt): string | unknown[] {\n if (prompt.attachments.length === 0) {\n return prompt.user;\n }\n return [\n { type: 'text', text: prompt.user },\n ...prompt.attachments.map((att: IAiImageAttachment) => ({\n type: 'image_url',\n image_url: {\n url: toDataUrl(att),\n ...(att.detail !== undefined ? { detail: att.detail } : {})\n }\n }))\n ];\n}\n\n/**\n * Builds the user content for OpenAI / xAI Responses API when attachments\n * are present. Responses API uses `input_text` / `input_image` part types,\n * distinct from Chat Completions' `text` / `image_url`.\n *\n * @internal\n */\nexport function buildOpenAiResponsesUserContent(prompt: AiPrompt): string | unknown[] {\n if (prompt.attachments.length === 0) {\n return prompt.user;\n }\n return [\n { type: 'input_text', text: prompt.user },\n ...prompt.attachments.map((att: IAiImageAttachment) => ({\n type: 'input_image',\n image_url: toDataUrl(att),\n ...(att.detail !== undefined ? { detail: att.detail } : {})\n }))\n ];\n}\n\n/**\n * Builds the user-message content for Anthropic when attachments are present.\n *\n * @internal\n */\nexport function buildAnthropicUserContent(prompt: AiPrompt): string | unknown[] {\n if (prompt.attachments.length === 0) {\n return prompt.user;\n }\n return [\n { type: 'text', text: prompt.user },\n ...prompt.attachments.map((att: IAiImageAttachment) => ({\n type: 'image',\n source: {\n type: 'base64',\n media_type: att.mimeType,\n data: att.base64\n }\n }))\n ];\n}\n\n/**\n * Builds the Gemini `parts` array for the user turn, including any image\n * attachments as `inlineData` parts.\n *\n * @internal\n */\nexport function buildGeminiUserParts(prompt: AiPrompt): Array<Record<string, unknown>> {\n const parts: Array<Record<string, unknown>> = [{ text: prompt.user }];\n for (const att of prompt.attachments) {\n parts.push({ inlineData: { mimeType: att.mimeType, data: att.base64 } });\n }\n return parts;\n}\n\n/**\n * Builds the Anthropic messages array, weaving any `head` history messages\n * between implicit system + the prompt's user message and appending `rawTail`\n * continuation messages after. System messages are filtered out (Anthropic uses\n * a top-level system field).\n *\n * @internal\n */\nexport function buildAnthropicMessages(\n prompt: AiPrompt,\n options?: IBuildMessagesOptions\n): Array<{ role: string; content: string | unknown[] }> {\n const messages: Array<{ role: string; content: string | unknown[] }> = [];\n if (options?.head) {\n for (const msg of options.head) {\n if (msg.role !== 'system') {\n messages.push({ role: msg.role, content: msg.content });\n }\n }\n }\n messages.push({ role: 'user', content: buildAnthropicUserContent(prompt) });\n if (options?.rawTail) {\n for (const msg of options.rawTail) {\n const converted = rawTailMessageConverter.convert(msg);\n if (converted.isSuccess()) {\n messages.push(converted.value);\n }\n }\n }\n return messages;\n}\n\n/**\n * Builds the Gemini `contents` array, weaving any `head` history messages before\n * the prompt's user parts and appending `rawTail` continuation messages after.\n * System messages are filtered out (Gemini uses a top-level systemInstruction\n * field) and assistant roles are mapped to Gemini's `model` role.\n *\n * @internal\n */\nexport function buildGeminiContents(\n prompt: AiPrompt,\n options?: IBuildMessagesOptions\n): Array<{ role: string; parts: unknown[] }> {\n const contents: Array<{ role: string; parts: unknown[] }> = [];\n if (options?.head) {\n for (const msg of options.head) {\n if (msg.role !== 'system') {\n contents.push({\n role: msg.role === 'assistant' ? 'model' : msg.role,\n parts: [{ text: msg.content }]\n });\n }\n }\n }\n contents.push({ role: 'user', parts: buildGeminiUserParts(prompt) });\n // Gemini continuation turns (model `functionCall` parts + user\n // `functionResponse` parts) are projected to `{ role, parts }`.\n if (options?.rawTail) {\n for (const item of options.rawTail) {\n const converted = geminiRawTailMessageConverter.convert(item);\n if (converted.isSuccess()) {\n contents.push(converted.value);\n }\n }\n }\n return contents;\n}\n"]}
1
+ {"version":3,"file":"chatRequestBuilders.js","sourceRoot":"","sources":["../../../src/packlets/ai-assist/chatRequestBuilders.ts"],"names":[],"mappings":"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","sourcesContent":["// Copyright (c) 2026 Erik Fortune\n//\n// Permission is hereby granted, free of charge, to any person obtaining a copy\n// of this software and associated documentation files (the \"Software\"), to deal\n// in the Software without restriction, including without limitation the rights\n// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n// copies of the Software, and to permit persons to whom the Software is\n// furnished to do so, subject to the following conditions:\n//\n// The above copyright notice and this permission notice shall be included in all\n// copies or substantial portions of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n// SOFTWARE.\n\n/**\n * Per-format chat request shape builders. Shared between the synchronous\n * (`completionClient.ts`) and streaming (`streamingClient.ts`) paths so the\n * wire shapes stay consistent.\n *\n * @packageDocumentation\n */\n\nimport { isJsonObject, type JsonObject } from '@fgv/ts-json-base';\nimport { type Converter, Converters, fail, Result, succeed } from '@fgv/ts-utils';\n\nimport { type IAiCacheRequest, splitSystemForCache } from './cacheRequest';\nimport { AiPrompt, type IAiImageAttachment, type IChatMessage, toDataUrl } from './model';\n\n/**\n * The result of splitting an {@link AiAssist.IChatRequest} into the per-builder\n * inputs: the current turn (as an {@link AiPrompt}, carrying system + the final\n * user message + any attachments) and the preceding conversation history.\n * @internal\n */\nexport interface ISplitChatRequest {\n /** The current turn lowered to an {@link AiPrompt} (system + user + attachments). */\n readonly prompt: AiPrompt;\n /** Prior conversation history — every message before the current turn. */\n readonly head: ReadonlyArray<IChatMessage>;\n}\n\n/**\n * Splits a unified {@link AiAssist.IChatRequest} into `{ prompt, head }` for the\n * per-provider builders. The **last** message is the current `user` turn; the\n * preceding messages are history (`head`). This is the single linearization\n * shared by every turn entry point, so the completion path and the client-tool\n * turn path place history at the identical position relative to the current turn.\n *\n * Fails when `messages` is empty (no current turn) or when the last message is\n * not a `user` turn — relabelling a trailing assistant message as the user turn\n * would be a silent footgun, so it is rejected loudly instead.\n *\n * @internal\n */\nexport function splitChatRequest(\n system: string | undefined,\n messages: ReadonlyArray<IChatMessage>\n): Result<ISplitChatRequest> {\n if (messages.length === 0) {\n return fail('messages must contain at least one entry (the current user turn)');\n }\n const current = messages[messages.length - 1];\n if (current.role !== 'user') {\n return fail(`the last message must be the current user turn (role 'user'); got '${current.role}'`);\n }\n const head = messages.slice(0, messages.length - 1);\n const prompt = new AiPrompt(current.content, system ?? '', current.attachments);\n return succeed({ prompt, head });\n}\n\n/**\n * Rebuilds the ordered messages for a proxy wire body so that only the current\n * turn can carry attachments — history (non-current) messages are reduced to\n * `{ role, content }`. The direct per-provider builders already drop attachments\n * on history turns (only the current user turn's attachments are honored), so\n * normalizing here keeps the proxy wire shape consistent with the direct paths\n * and avoids transmitting attachment payloads the upstream provider would ignore.\n *\n * @internal\n */\nexport function normalizeOutboundMessages(split: ISplitChatRequest): IChatMessage[] {\n return [\n ...split.head.map((m) => ({ role: m.role, content: m.content })),\n ...split.prompt.toRequest().messages\n ];\n}\n\n/**\n * Converter for a rawTail message entry. Narrows a `JsonObject` to\n * `{ role: string; content: string | unknown[] }` at runtime using the\n * Converter pattern. Entries that fail validation are silently skipped — the\n * surrounding function is infallible, and a malformed continuation message is\n * better omitted than transmitted verbatim.\n * @internal\n */\nconst rawTailMessageConverter: Converter<{ role: 'user' | 'assistant'; content: string | unknown[] }> =\n Converters.object<{ role: 'user' | 'assistant'; content: string | unknown[] }>(\n {\n role: Converters.enumeratedValue<'user' | 'assistant'>(['user', 'assistant']),\n content: Converters.oneOf<string | unknown[]>([\n Converters.string,\n Converters.isA('array', (v): v is unknown[] => Array.isArray(v))\n ])\n },\n { strict: false }\n );\n\n/**\n * Converter for an OpenAI / xAI Responses API `rawTail` item. These are\n * provider-native input items (`function_call`, `function_call_output`) whose\n * fields differ per item type, so — unlike the Anthropic `{ role, content }`\n * projection — the whole object is preserved verbatim.\n *\n * The static input is already typed `JsonObject`, so the `isJsonObject` guard\n * is a runtime backstop, not a compile-time narrowing: continuation messages\n * originate from a prior turn's `IAiClientToolContinuation.messages` and a\n * consumer may persist and reload them through untyped JSON before passing them\n * back. The guard preserves the same \"a malformed continuation message is\n * better omitted than transmitted verbatim\" posture as the Anthropic path —\n * non-object entries fail conversion and are skipped by the caller.\n * @internal\n */\nconst openAiRawTailItemConverter: Converter<JsonObject> = Converters.isA<JsonObject>(\n 'JsonObject',\n (v): v is JsonObject => isJsonObject(v)\n);\n\n/**\n * Converter for a Gemini `rawTail` item. Gemini continuation messages are\n * `{ role, parts }` turns (a model turn with `functionCall` parts followed by a\n * user turn with `functionResponse` parts). Narrows a `JsonObject` to\n * `{ role: 'user' | 'model'; parts: Array<Record<string, unknown>> }`; entries\n * that fail validation are skipped by the caller.\n * @internal\n */\nconst geminiRawTailMessageConverter: Converter<{\n role: 'user' | 'model';\n parts: unknown[];\n}> = Converters.object<{ role: 'user' | 'model'; parts: unknown[] }>(\n {\n role: Converters.enumeratedValue<'user' | 'model'>(['user', 'model']),\n // `parts` is preserved verbatim and serialized into the request body, so the\n // element shape is not narrowed here — `Array.isArray` soundly guarantees\n // `unknown[]` (narrowing to `Record<string, unknown>[]` would be an unchecked cast).\n parts: Converters.isA('array', (v): v is unknown[] => Array.isArray(v))\n },\n { strict: false }\n);\n\n/**\n * Optional history (`head`) and raw continuation (`rawTail`) messages to weave\n * around the prompt's current user message.\n *\n * @internal\n */\nexport interface IBuildMessagesOptions {\n /**\n * Prior conversation history inserted between the system prompt and the\n * prompt's current user message (multi-turn chat / correction retries). The\n * single ordered linearization is `[system, ...head, user, ...rawTail]`.\n */\n readonly head?: ReadonlyArray<IChatMessage>;\n /**\n * Raw JSON objects appended after the prompt's user message. Used to\n * inject provider-specific continuation messages (e.g. Anthropic assistant\n * turns with thinking blocks, OpenAI Responses `function_call` /\n * `function_call_output` items, Gemini `functionCall` / `functionResponse`\n * turns) that cannot be expressed as plain {@link IChatMessage} objects.\n *\n * Each builder applies its own provider-specific shape guard:\n * - {@link buildAnthropicMessages} projects each entry to `{ role, content }`.\n * - {@link buildMessages} (OpenAI / xAI Responses) preserves each item\n * verbatim (item fields differ per `type`), guarding only that it is a\n * JSON object.\n * - {@link buildGeminiContents} projects each entry to `{ role, parts }`.\n *\n * Entries that fail their builder's shape check are silently skipped (the\n * caller is responsible for supplying well-formed continuation messages).\n * Appended after the current user message.\n */\n readonly rawTail?: ReadonlyArray<JsonObject>;\n}\n\n/**\n * Builds the messages array from prompt + optional history (`head`) and raw\n * continuation (`rawTail`) messages. The caller supplies the system content\n * (string when no cache breakpoints are requested, parts array when they are\n * — see {@link buildOpenAiChatSystemContent} / {@link buildOpenAiResponsesSystemContent})\n * and the user content (string for text-only, parts array for vision prompts),\n * since both shapes differ by format.\n *\n * `rawTail` items (OpenAI / xAI Responses `function_call` /\n * `function_call_output` continuation items) are appended verbatim after the\n * user message — their fields differ per item `type`, so they are preserved\n * rather than projected. The return type is `Array<Record<string, unknown>>`\n * to accommodate both `{ role, content }` messages and these heterogeneous\n * input items.\n *\n * @internal\n */\nexport function buildMessages(\n systemContent: string | unknown[],\n userContent: string | unknown[],\n options?: IBuildMessagesOptions\n): Array<Record<string, unknown>> {\n const messages: Array<Record<string, unknown>> = [{ role: 'system', content: systemContent }];\n if (options?.head) {\n for (const msg of options.head) {\n messages.push({ role: msg.role, content: msg.content });\n }\n }\n messages.push({ role: 'user', content: userContent });\n // OpenAI / xAI Responses continuation items (function_call /\n // function_call_output) are appended verbatim — their field set differs per\n // item type, so the whole object is preserved rather than projected.\n if (options?.rawTail) {\n for (const item of options.rawTail) {\n const converted = openAiRawTailItemConverter.convert(item);\n if (converted.isSuccess()) {\n messages.push(converted.value);\n }\n }\n }\n return messages;\n}\n\n/**\n * Splits `segments` (from {@link splitSystemForCache}) into a single string when nothing carries\n * a breakpoint, or a content-part array in `partType`'s shape (with `prompt_cache_breakpoint` on\n * the parts that end a cacheable prefix) otherwise.\n *\n * @remarks\n * A single non-breakpointed segment (the common case: `cache` omitted or empty) returns the\n * plain string, byte-identical to a request built before cache support existed. Shared by\n * {@link buildOpenAiChatSystemContent} and {@link buildOpenAiResponsesSystemContent}, whose only\n * difference is the content-part `type` OpenAI's two APIs use (`'text'` vs `'input_text'`,\n * research.md §1.2).\n * @internal\n */\nfunction toOpenAiSystemContent(\n segments: ReadonlyArray<{ readonly text: string; readonly cacheBreakpoint: boolean }>,\n partType: 'text' | 'input_text'\n): string | unknown[] {\n if (segments.length === 1 && !segments[0].cacheBreakpoint) {\n return segments[0].text;\n }\n return segments.map((seg) => ({\n type: partType,\n text: seg.text,\n ...(seg.cacheBreakpoint\n ? {\n // eslint-disable-next-line @typescript-eslint/naming-convention -- wire field name\n prompt_cache_breakpoint: { mode: 'explicit' }\n }\n : {})\n }));\n}\n\n/**\n * Builds the system content for OpenAI Chat Completions, splitting `system` at `cache`'s\n * breakpoints (design.md §6.3) into content parts each carrying\n * `prompt_cache_breakpoint: { mode: 'explicit' }` where a cacheable prefix ends.\n * `prompt_cache_options` is never touched — OpenAI keeps its own implicit breakpoint and writes\n * up to the latest three of ours on top of it. Returns the plain `system` string when `cache` is\n * `undefined` or declares no breakpoints, byte-identical to a request built before cache support\n * existed.\n * @internal\n */\nexport function buildOpenAiChatSystemContent(\n system: string,\n cache: IAiCacheRequest | undefined\n): Result<string | unknown[]> {\n return splitSystemForCache(system, cache).onSuccess((segments) =>\n succeed(toOpenAiSystemContent(segments, 'text'))\n );\n}\n\n/**\n * Builds the system content for the OpenAI / xAI Responses API. Identical to\n * {@link buildOpenAiChatSystemContent} except for the content-part type (`'input_text'` instead\n * of `'text'`) — see research.md §1.2, \"Where the breakpoint may be attached.\"\n * @internal\n */\nexport function buildOpenAiResponsesSystemContent(\n system: string,\n cache: IAiCacheRequest | undefined\n): Result<string | unknown[]> {\n return splitSystemForCache(system, cache).onSuccess((segments) =>\n succeed(toOpenAiSystemContent(segments, 'input_text'))\n );\n}\n\n/**\n * Builds the Anthropic `system` field, splitting `system` at `cache`'s breakpoints into content\n * blocks carrying `cache_control: { type: 'ephemeral' }` on the block that ends each cacheable\n * prefix (design.md §6.1). Returns the plain `system` string when `cache` is `undefined` or\n * declares no breakpoints — byte-identical to `system: prompt.system` from a request built\n * before cache support existed.\n * @internal\n */\nexport function buildAnthropicSystem(\n system: string,\n cache: IAiCacheRequest | undefined\n): Result<string | unknown[]> {\n return splitSystemForCache(system, cache).onSuccess((segments) => {\n if (segments.length === 1 && !segments[0].cacheBreakpoint) {\n return succeed<string | unknown[]>(segments[0].text);\n }\n return succeed<string | unknown[]>(\n segments.map((seg) => ({\n type: 'text',\n text: seg.text,\n ...(seg.cacheBreakpoint ? { cache_control: { type: 'ephemeral' } } : {})\n }))\n );\n });\n}\n\n/**\n * Builds the user content for OpenAI Chat Completions when attachments are\n * present. Returns a string when there are no attachments.\n *\n * @internal\n */\nexport function buildOpenAiChatUserContent(prompt: AiPrompt): string | unknown[] {\n if (prompt.attachments.length === 0) {\n return prompt.user;\n }\n return [\n { type: 'text', text: prompt.user },\n ...prompt.attachments.map((att: IAiImageAttachment) => ({\n type: 'image_url',\n image_url: {\n url: toDataUrl(att),\n ...(att.detail !== undefined ? { detail: att.detail } : {})\n }\n }))\n ];\n}\n\n/**\n * Builds the user content for OpenAI / xAI Responses API when attachments\n * are present. Responses API uses `input_text` / `input_image` part types,\n * distinct from Chat Completions' `text` / `image_url`.\n *\n * @internal\n */\nexport function buildOpenAiResponsesUserContent(prompt: AiPrompt): string | unknown[] {\n if (prompt.attachments.length === 0) {\n return prompt.user;\n }\n return [\n { type: 'input_text', text: prompt.user },\n ...prompt.attachments.map((att: IAiImageAttachment) => ({\n type: 'input_image',\n image_url: toDataUrl(att),\n ...(att.detail !== undefined ? { detail: att.detail } : {})\n }))\n ];\n}\n\n/**\n * Builds the user-message content for Anthropic when attachments are present.\n *\n * @internal\n */\nexport function buildAnthropicUserContent(prompt: AiPrompt): string | unknown[] {\n if (prompt.attachments.length === 0) {\n return prompt.user;\n }\n return [\n { type: 'text', text: prompt.user },\n ...prompt.attachments.map((att: IAiImageAttachment) => ({\n type: 'image',\n source: {\n type: 'base64',\n media_type: att.mimeType,\n data: att.base64\n }\n }))\n ];\n}\n\n/**\n * Builds the Gemini `parts` array for the user turn, including any image\n * attachments as `inlineData` parts.\n *\n * @internal\n */\nexport function buildGeminiUserParts(prompt: AiPrompt): Array<Record<string, unknown>> {\n const parts: Array<Record<string, unknown>> = [{ text: prompt.user }];\n for (const att of prompt.attachments) {\n parts.push({ inlineData: { mimeType: att.mimeType, data: att.base64 } });\n }\n return parts;\n}\n\n/**\n * Builds the Anthropic messages array, weaving any `head` history messages\n * between implicit system + the prompt's user message and appending `rawTail`\n * continuation messages after. System messages are filtered out (Anthropic uses\n * a top-level system field).\n *\n * @internal\n */\nexport function buildAnthropicMessages(\n prompt: AiPrompt,\n options?: IBuildMessagesOptions\n): Array<{ role: string; content: string | unknown[] }> {\n const messages: Array<{ role: string; content: string | unknown[] }> = [];\n if (options?.head) {\n for (const msg of options.head) {\n if (msg.role !== 'system') {\n messages.push({ role: msg.role, content: msg.content });\n }\n }\n }\n messages.push({ role: 'user', content: buildAnthropicUserContent(prompt) });\n if (options?.rawTail) {\n for (const msg of options.rawTail) {\n const converted = rawTailMessageConverter.convert(msg);\n if (converted.isSuccess()) {\n messages.push(converted.value);\n }\n }\n }\n return messages;\n}\n\n/**\n * Builds the Gemini `contents` array, weaving any `head` history messages before\n * the prompt's user parts and appending `rawTail` continuation messages after.\n * System messages are filtered out (Gemini uses a top-level systemInstruction\n * field) and assistant roles are mapped to Gemini's `model` role.\n *\n * @internal\n */\nexport function buildGeminiContents(\n prompt: AiPrompt,\n options?: IBuildMessagesOptions\n): Array<{ role: string; parts: unknown[] }> {\n const contents: Array<{ role: string; parts: unknown[] }> = [];\n if (options?.head) {\n for (const msg of options.head) {\n if (msg.role !== 'system') {\n contents.push({\n role: msg.role === 'assistant' ? 'model' : msg.role,\n parts: [{ text: msg.content }]\n });\n }\n }\n }\n contents.push({ role: 'user', parts: buildGeminiUserParts(prompt) });\n // Gemini continuation turns (model `functionCall` parts + user\n // `functionResponse` parts) are projected to `{ role, parts }`.\n if (options?.rawTail) {\n for (const item of options.rawTail) {\n const converted = geminiRawTailMessageConverter.convert(item);\n if (converted.isSuccess()) {\n contents.push(converted.value);\n }\n }\n }\n return contents;\n}\n"]}