@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
@@ -17,15 +17,27 @@
17
17
  // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
18
  // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
19
  // SOFTWARE.
20
+ /**
21
+ * Chat completion clients for AI assist. One provider dispatcher over
22
+ * `IAiProviderDescriptor.apiFormat`, the four adapters it routes to (OpenAI Chat
23
+ * Completions, OpenAI/xAI Responses, Anthropic, Google Gemini), the response
24
+ * validators those adapters use, and the proxied variant of the same modality.
25
+ *
26
+ * @packageDocumentation
27
+ */
28
+ import { isJsonObject } from '@fgv/ts-json-base';
20
29
  import { captureResult, fail, succeed, Validators } from '@fgv/ts-utils';
21
30
  import { DEFAULT_ANTHROPIC_MAX_TOKENS, isAdaptiveThinkingModel, isResponsesOnlyModel, resolveProviderModel, usesMaxCompletionTokensField } from './model';
31
+ import { aiCompletionUsage } from './converters';
22
32
  import { anthropicEffortToBudgetTokens, checkTemperatureConflict, mergeThinkingConfig, providerDiscriminatorForId } from './thinkingOptionsResolver';
23
- import { buildAnthropicMessages, buildGeminiContents, buildMessages, buildOpenAiChatUserContent, buildOpenAiResponsesUserContent, normalizeOutboundMessages, splitChatRequest } from './chatRequestBuilders';
33
+ import { buildAnthropicMessages, buildAnthropicSystem, buildGeminiContents, buildMessages, buildOpenAiChatSystemContent, buildOpenAiChatUserContent, buildOpenAiResponsesSystemContent, buildOpenAiResponsesUserContent, normalizeOutboundMessages, splitChatRequest } from './chatRequestBuilders';
24
34
  import { anthropicAuthHeaders, bearerAuthHeader, geminiAuthHeader, resolveEffectiveBaseUrl } from './endpoint';
25
35
  import { fetchJson } from './http';
26
36
  import { toAnthropicTools, toGeminiTools, toResponsesApiTools } from './toolFormats';
27
37
  import { ANTHROPIC_STRUCTURED_OUTPUT_TOOL_NAME, NO_STRUCTURED_OUTPUT, isStructuredOutputEnforcement, resolveStructuredOutput } from './structuredOutput';
28
38
  import { resolveStructuredOutputCapability } from './registry';
39
+ import { supportsCacheUsageReporting, supportsPromptCacheBreakpoints, supportsPromptCacheRouting } from './streamUsageCapability';
40
+ import { normalizeAnthropicUsage, normalizeGeminiUsage, normalizeOpenAiChatUsage, normalizeOpenAiResponsesUsage } from './usageNormalization';
29
41
  const openAiMessage = Validators.object({
30
42
  content: Validators.string
31
43
  });
@@ -51,15 +63,22 @@ const responsesApiResponse = Validators.object({
51
63
  status: Validators.string
52
64
  });
53
65
  const geminiPart = Validators.object({
54
- text: Validators.string
66
+ text: Validators.string.optional()
55
67
  });
56
68
  const geminiContent = Validators.object({
57
- parts: Validators.arrayOf(geminiPart).withConstraint((arr) => arr.length > 0)
69
+ // No non-empty constraint: `parts: []` is a shape Gemini actually returns, and rejecting it
70
+ // here reports a schema violation for what is really "the model produced no output".
71
+ parts: Validators.arrayOf(geminiPart).optional()
58
72
  });
59
73
  const geminiCandidate = Validators.object({
60
- content: geminiContent,
61
- finishReason: Validators.string
74
+ content: geminiContent.optional(),
75
+ finishReason: Validators.string.optional(),
76
+ finishMessage: Validators.string.optional()
62
77
  });
78
+ // Terminal reasons that mean "the model finished normally", as distinct from a refusal. Kept in
79
+ // step with the identical set in `imageGenerationClient.ts`; both exist so an empty response can
80
+ // be reported as the decline it is rather than as an empty string or a parse error.
81
+ const benignGeminiFinishReasons = new Set(['STOP', 'MAX_TOKENS']);
63
82
  const geminiResponse = Validators.object({
64
83
  candidates: Validators.arrayOf(geminiCandidate).withConstraint((arr) => arr.length > 0)
65
84
  });
@@ -71,36 +90,52 @@ const geminiResponse = Validators.object({
71
90
  * Works for xAI Grok, OpenAI, Groq, and Mistral.
72
91
  * @internal
73
92
  */
74
- async function callOpenAiCompletion(config, prompt, head, temperature, logger, signal, resolvedThinking, maxTokens, useMaxCompletionTokensField = false, structured = NO_STRUCTURED_OUTPUT) {
93
+ async function callOpenAiCompletion(config, prompt, head, temperature, logger, signal, resolvedThinking, maxTokens, useMaxCompletionTokensField = false, structured = NO_STRUCTURED_OUTPUT, reportsUsage = false, cache, cacheKeyHeader) {
75
94
  var _a;
76
95
  const url = `${config.baseUrl}/chat/completions`;
77
- const messages = buildMessages(prompt.system, buildOpenAiChatUserContent(prompt), {
96
+ const systemContentResult = buildOpenAiChatSystemContent(prompt.system, cache);
97
+ if (systemContentResult.isFailure()) {
98
+ return fail(systemContentResult.message);
99
+ }
100
+ const messages = buildMessages(systemContentResult.value, buildOpenAiChatUserContent(prompt), {
78
101
  head
79
102
  });
80
103
  const effort = (_a = resolvedThinking === null || resolvedThinking === void 0 ? void 0 : resolvedThinking.openAiEffort) !== null && _a !== void 0 ? _a : resolvedThinking === null || resolvedThinking === void 0 ? void 0 : resolvedThinking.xaiEffort;
81
104
  const maxTokensField = useMaxCompletionTokensField ? 'max_completion_tokens' : 'max_tokens';
82
- const body = Object.assign(Object.assign(Object.assign({ model: config.model, messages }, (temperature !== undefined ? { temperature } : {})), (effort !== undefined && config.model !== 'grok-4' ? { reasoning_effort: effort } : {})), (maxTokens !== undefined ? { [maxTokensField]: maxTokens } : {}));
105
+ const body = Object.assign(Object.assign(Object.assign(Object.assign({ model: config.model, messages }, (temperature !== undefined ? { temperature } : {})), (effort !== undefined && config.model !== 'grok-4' ? { reasoning_effort: effort } : {})), (maxTokens !== undefined ? { [maxTokensField]: maxTokens } : {})), ((cache === null || cache === void 0 ? void 0 : cache.cacheKey) !== undefined && cacheKeyHeader === undefined
106
+ ? // eslint-disable-next-line @typescript-eslint/naming-convention -- wire field name
107
+ { prompt_cache_key: cache.cacheKey }
108
+ : {}));
83
109
  if ((resolvedThinking === null || resolvedThinking === void 0 ? void 0 : resolvedThinking.otherParams) !== undefined) {
84
110
  Object.assign(body, resolvedThinking.otherParams);
85
111
  }
86
112
  Object.assign(body, structured.wire);
87
113
  const headers = bearerAuthHeader(config.apiKey);
114
+ // Sticky-routing key, when the provider carries it as a header rather than a body field.
115
+ // xAI's prompt cache is per-server, so an identical prefix can still miss if the request is
116
+ // routed to a different box; `x-grok-conv-id` pins it.
117
+ if ((cache === null || cache === void 0 ? void 0 : cache.cacheKey) !== undefined && cacheKeyHeader !== undefined) {
118
+ headers[cacheKeyHeader] = cache.cacheKey;
119
+ }
88
120
  /* c8 ignore next 1 - optional logger */
89
121
  logger === null || logger === void 0 ? void 0 : logger.info(`OpenAI completion: model=${config.model}`);
90
122
  const jsonResult = await fetchJson(url, headers, body, logger, signal);
91
123
  if (jsonResult.isFailure()) {
92
124
  return fail(jsonResult.message);
93
125
  }
126
+ // Only descriptors with confirmed cache-relevant usage reporting are normalized — the
127
+ // adapter is shared by Groq/Mistral/Ollama/openai-compat too, and an ordinary usage block
128
+ // from one of those carries no cache information. See supportsCacheUsageReporting.
129
+ const rawUsage = jsonResult.value.usage;
130
+ const usage = reportsUsage
131
+ ? normalizeOpenAiChatUsage(isJsonObject(rawUsage) ? rawUsage : undefined)
132
+ : undefined;
94
133
  return openAiResponse
95
134
  .validate(jsonResult.value)
96
135
  .withErrorFormat((msg) => `OpenAI API response: ${msg}`)
97
136
  .onSuccess((response) => {
98
137
  const choice = response.choices[0];
99
- return succeed({
100
- content: choice.message.content,
101
- truncated: choice.finish_reason === 'length',
102
- structuredOutput: structured.enforcement
103
- });
138
+ return succeed(Object.assign({ content: choice.message.content, truncated: choice.finish_reason === 'length', structuredOutput: structured.enforcement }, (usage !== undefined ? { usage } : {})));
104
139
  });
105
140
  }
106
141
  // ============================================================================
@@ -127,14 +162,21 @@ function extractResponsesApiText(output) {
127
162
  * Used when tools are configured for an openai-format provider.
128
163
  * @internal
129
164
  */
130
- async function callOpenAiResponsesCompletion(config, prompt, tools = [], head, temperature, logger, signal, resolvedThinking, maxTokens, structured = NO_STRUCTURED_OUTPUT) {
165
+ async function callOpenAiResponsesCompletion(config, prompt, tools = [], head, temperature, logger, signal, resolvedThinking, maxTokens, structured = NO_STRUCTURED_OUTPUT, reportsUsage = false, cache) {
131
166
  var _a;
132
167
  const url = `${config.baseUrl}/responses`;
133
- const input = buildMessages(prompt.system, buildOpenAiResponsesUserContent(prompt), {
168
+ const systemContentResult = buildOpenAiResponsesSystemContent(prompt.system, cache);
169
+ if (systemContentResult.isFailure()) {
170
+ return fail(systemContentResult.message);
171
+ }
172
+ const input = buildMessages(systemContentResult.value, buildOpenAiResponsesUserContent(prompt), {
134
173
  head
135
174
  });
136
175
  const effort = (_a = resolvedThinking === null || resolvedThinking === void 0 ? void 0 : resolvedThinking.openAiEffort) !== null && _a !== void 0 ? _a : resolvedThinking === null || resolvedThinking === void 0 ? void 0 : resolvedThinking.xaiEffort;
137
- const body = Object.assign(Object.assign(Object.assign({ model: config.model, input }, (tools.length > 0 ? { tools: toResponsesApiTools(tools) } : {})), (temperature !== undefined ? { temperature } : {})), (effort !== undefined && config.model !== 'grok-4' ? { reasoning: { effort } } : {}));
176
+ const body = Object.assign(Object.assign(Object.assign(Object.assign({ model: config.model, input }, (tools.length > 0 ? { tools: toResponsesApiTools(tools) } : {})), (temperature !== undefined ? { temperature } : {})), (effort !== undefined && config.model !== 'grok-4' ? { reasoning: { effort } } : {})), ((cache === null || cache === void 0 ? void 0 : cache.cacheKey) !== undefined
177
+ ? // eslint-disable-next-line @typescript-eslint/naming-convention -- wire field name
178
+ { prompt_cache_key: cache.cacheKey }
179
+ : {}));
138
180
  // Shared by OpenAI and xAI — both route through the Responses API with the same field name.
139
181
  if (maxTokens !== undefined) {
140
182
  body.max_output_tokens = maxTokens;
@@ -150,15 +192,16 @@ async function callOpenAiResponsesCompletion(config, prompt, tools = [], head, t
150
192
  if (jsonResult.isFailure()) {
151
193
  return fail(jsonResult.message);
152
194
  }
195
+ // See the identical gate in callOpenAiCompletion above — this route is shared the same way.
196
+ const rawResponsesUsage = jsonResult.value.usage;
197
+ const responsesUsage = reportsUsage
198
+ ? normalizeOpenAiResponsesUsage(isJsonObject(rawResponsesUsage) ? rawResponsesUsage : undefined)
199
+ : undefined;
153
200
  return responsesApiResponse
154
201
  .validate(jsonResult.value)
155
202
  .withErrorFormat((msg) => `Responses API response: ${msg}`)
156
203
  .onSuccess((response) => {
157
- return extractResponsesApiText(response.output).onSuccess((text) => succeed({
158
- content: text,
159
- truncated: response.status === 'incomplete',
160
- structuredOutput: structured.enforcement
161
- }));
204
+ return extractResponsesApiText(response.output).onSuccess((text) => succeed(Object.assign({ content: text, truncated: response.status === 'incomplete', structuredOutput: structured.enforcement }, (responsesUsage !== undefined ? { usage: responsesUsage } : {}))));
162
205
  });
163
206
  }
164
207
  // ============================================================================
@@ -224,10 +267,14 @@ function extractAnthropicStructuredOutput(content) {
224
267
  return fail(`Anthropic API response: structured output was forced but no '${ANTHROPIC_STRUCTURED_OUTPUT_TOOL_NAME}' tool_use block was returned`);
225
268
  }
226
269
  /** Calls the Anthropic Messages API with optional tool support. @internal */
227
- async function callAnthropicCompletion(config, prompt, head, temperature, logger, tools, signal, resolvedThinking, useAdaptiveThinking = false, maxTokens, structured = NO_STRUCTURED_OUTPUT) {
270
+ async function callAnthropicCompletion(config, prompt, head, temperature, logger, tools, signal, resolvedThinking, useAdaptiveThinking = false, maxTokens, structured = NO_STRUCTURED_OUTPUT, cache) {
228
271
  const url = `${config.baseUrl}/messages`;
229
272
  const messages = buildAnthropicMessages(prompt, { head });
230
- const body = Object.assign({ model: config.model, system: prompt.system, messages,
273
+ const systemResult = buildAnthropicSystem(prompt.system, cache);
274
+ if (systemResult.isFailure()) {
275
+ return fail(systemResult.message);
276
+ }
277
+ const body = Object.assign({ model: config.model, system: systemResult.value, messages,
231
278
  // Anthropic's Messages API requires max_tokens on every request — see
232
279
  // AiAssist.DEFAULT_ANTHROPIC_MAX_TOKENS for why only this provider defaults it.
233
280
  max_tokens: maxTokens !== null && maxTokens !== void 0 ? maxTokens : DEFAULT_ANTHROPIC_MAX_TOKENS }, (temperature !== undefined ? { temperature } : {}));
@@ -278,6 +325,8 @@ async function callAnthropicCompletion(config, prompt, head, temperature, logger
278
325
  }
279
326
  const rawContent = jsonResult.value.content;
280
327
  const stopReason = jsonResult.value.stop_reason;
328
+ const rawAnthropicUsage = jsonResult.value.usage;
329
+ const anthropicUsage = normalizeAnthropicUsage(isJsonObject(rawAnthropicUsage) ? rawAnthropicUsage : undefined);
281
330
  if (!Array.isArray(rawContent)) {
282
331
  return fail('Anthropic API response: content is not an array');
283
332
  }
@@ -287,11 +336,7 @@ async function callAnthropicCompletion(config, prompt, head, temperature, logger
287
336
  const extracted = structured.enforcement === 'tool-forced'
288
337
  ? extractAnthropicStructuredOutput(rawContent)
289
338
  : extractAnthropicText(rawContent);
290
- return extracted.onSuccess((text) => succeed({
291
- content: text,
292
- truncated: stopReason === 'max_tokens',
293
- structuredOutput: structured.enforcement
294
- }));
339
+ return extracted.onSuccess((text) => succeed(Object.assign({ content: text, truncated: stopReason === 'max_tokens', structuredOutput: structured.enforcement }, (anthropicUsage !== undefined ? { usage: anthropicUsage } : {}))));
295
340
  }
296
341
  // ============================================================================
297
342
  // Google Gemini adapter
@@ -339,22 +384,35 @@ async function callGeminiCompletion(config, prompt, head, temperature, logger, t
339
384
  if (jsonResult.isFailure()) {
340
385
  return fail(jsonResult.message);
341
386
  }
387
+ const rawGeminiUsage = jsonResult.value.usageMetadata;
388
+ const geminiUsage = normalizeGeminiUsage(isJsonObject(rawGeminiUsage) ? rawGeminiUsage : undefined);
342
389
  return geminiResponse
343
390
  .validate(jsonResult.value)
344
391
  .withErrorFormat((msg) => `Gemini API response: ${msg}`)
345
392
  .onSuccess((response) => {
393
+ var _a, _b, _c;
346
394
  const candidate = response.candidates[0];
347
- return succeed({
348
- // ALL parts, not `parts[0]`. Gemini may split one reply across several
349
- // text parts, and reading only the first silently discards the rest —
350
- // yielding a truncated document that often still parses, which is the
351
- // worst way to be wrong. The streaming adapter has always concatenated
352
- // (`fullText += part.text`); this path did not, so the same response gave
353
- // different text depending on which one you called.
354
- content: candidate.content.parts.map((part) => part.text).join(''),
355
- truncated: candidate.finishReason === 'MAX_TOKENS',
356
- structuredOutput: structured.enforcement
357
- });
395
+ // ALL parts, not `parts[0]`. Gemini may split one reply across several
396
+ // text parts, and reading only the first silently discards the rest —
397
+ // yielding a truncated document that often still parses, which is the
398
+ // worst way to be wrong. The streaming adapter has always concatenated
399
+ // (`fullText += part.text`); this path did not, so the same response gave
400
+ // different text depending on which one you called.
401
+ //
402
+ // `part.text ?? ''` rather than `part.text`: a reasoning or tool part contributes no text,
403
+ // and joining `undefined` would put the literal string "undefined" into the reply.
404
+ const content = (_c = (_b = (_a = candidate.content) === null || _a === void 0 ? void 0 : _a.parts) === null || _b === void 0 ? void 0 : _b.map((part) => { var _a; return (_a = part.text) !== null && _a !== void 0 ? _a : ''; }).join('')) !== null && _c !== void 0 ? _c : '';
405
+ if (content.length === 0) {
406
+ // Nothing came back. If the candidate says why, say so — an empty string handed to a
407
+ // caller is indistinguishable from a model that legitimately replied with nothing, and
408
+ // sends them looking in the wrong place.
409
+ const reason = candidate.finishReason;
410
+ if (reason !== undefined && !benignGeminiFinishReasons.has(reason)) {
411
+ const suffix = candidate.finishMessage ? ` — ${candidate.finishMessage}` : '';
412
+ return fail(`Gemini completion declined: ${reason}${suffix}`);
413
+ }
414
+ }
415
+ return succeed(Object.assign({ content, truncated: candidate.finishReason === 'MAX_TOKENS', structuredOutput: structured.enforcement }, (geminiUsage !== undefined ? { usage: geminiUsage } : {})));
358
416
  });
359
417
  }
360
418
  // ============================================================================
@@ -367,7 +425,7 @@ async function callGeminiCompletion(config, prompt, head, temperature, logger, t
367
425
  * @public
368
426
  */
369
427
  export async function callProviderCompletion(params) {
370
- const { descriptor, apiKey, system, messages, temperature, modelOverride, tier, logger, tools, signal, endpoint, thinking, maxTokens, structuredOutput } = params;
428
+ const { descriptor, apiKey, system, messages, temperature, modelOverride, tier, logger, tools, signal, endpoint, thinking, maxTokens, structuredOutput, cache } = params;
371
429
  const splitResult = splitChatRequest(system, messages);
372
430
  if (splitResult.isFailure()) {
373
431
  return fail(splitResult.message);
@@ -444,15 +502,30 @@ export async function callProviderCompletion(params) {
444
502
  `tools=${toolTypes}, supported=${supported}`);
445
503
  }
446
504
  switch (descriptor.apiFormat) {
447
- case 'openai':
505
+ case 'openai': {
448
506
  // Responses-API-only models (e.g. gpt-5.5-pro) 400 on /chat/completions, so they route
449
507
  // to the Responses path even with no tools requested — same path the tools case uses.
508
+ // `cache` carries two independently-supported things, and they are gated separately.
509
+ // `systemBreakpoints` restructures `content` and needs the server to tolerate an
510
+ // unrecognized field inside it (OpenAI only). `cacheKey` is an opaque routing string that
511
+ // makes repeated requests with a shared prefix land on the same cache-holding server —
512
+ // which xAI needs precisely because it does per-server byte-for-byte prefix matching.
513
+ // Gating them together withheld the routing key from the provider that depends on it most.
514
+ // Every descriptor supporting neither (Groq, Mistral, Ollama, openai-compat) still gets the
515
+ // exact request body it would have gotten had the caller passed no `cache` at all.
516
+ const routing = supportsPromptCacheRouting(descriptor);
517
+ const breakpoints = supportsPromptCacheBreakpoints(descriptor) ? cache === null || cache === void 0 ? void 0 : cache.systemBreakpoints : undefined;
518
+ const routedKey = routing !== undefined ? cache === null || cache === void 0 ? void 0 : cache.cacheKey : undefined;
519
+ const gatedCache = breakpoints !== undefined || routedKey !== undefined
520
+ ? { systemBreakpoints: breakpoints, cacheKey: routedKey }
521
+ : undefined;
450
522
  if (usesResponsesApi) {
451
- return callOpenAiResponsesCompletion(config, prompt, tools, head, temperature, logger, signal, resolvedThinking, maxTokens, resolvedStructured);
523
+ return callOpenAiResponsesCompletion(config, prompt, tools, head, temperature, logger, signal, resolvedThinking, maxTokens, resolvedStructured, supportsCacheUsageReporting(descriptor), gatedCache);
452
524
  }
453
- return callOpenAiCompletion(config, prompt, head, temperature, logger, signal, resolvedThinking, maxTokens, usesMaxCompletionTokensField(descriptor), resolvedStructured);
525
+ return callOpenAiCompletion(config, prompt, head, temperature, logger, signal, resolvedThinking, maxTokens, usesMaxCompletionTokensField(descriptor), resolvedStructured, supportsCacheUsageReporting(descriptor), gatedCache, routing === null || routing === void 0 ? void 0 : routing.chatCompletionsHeader);
526
+ }
454
527
  case 'anthropic':
455
- return callAnthropicCompletion(config, prompt, head, temperature, logger, tools, signal, resolvedThinking, isAdaptiveThinkingModel(descriptor, config.model), maxTokens, resolvedStructured);
528
+ return callAnthropicCompletion(config, prompt, head, temperature, logger, tools, signal, resolvedThinking, isAdaptiveThinkingModel(descriptor, config.model), maxTokens, resolvedStructured, cache);
456
529
  case 'gemini':
457
530
  return callGeminiCompletion(config, prompt, head, temperature, logger, tools, signal, resolvedThinking, maxTokens, resolvedStructured);
458
531
  /* c8 ignore next 4 - defensive coding: exhaustive switch guaranteed by TypeScript */
@@ -471,12 +544,25 @@ export async function callProviderCompletion(params) {
471
544
  * CORS, and API key forwarding. The request body serializes the unified
472
545
  * {@link AiAssist.IChatRequest} shape (`system?` + `messages`). Enforces the same
473
546
  * non-empty / trailing-user-turn and image-input invariants as the direct path.
547
+ *
548
+ * @remarks
549
+ * Two parameters are handled before the body is composed, rather than forwarded.
550
+ * `tier` is resolved here and sent as a concrete `modelOverride`, because both
551
+ * halves of the resolution live on the descriptor the caller already holds — so a
552
+ * proxy needs no `tier` vocabulary, where a `tier` body field it did not understand
553
+ * would be ignored and silently serve a frontier request from the base model.
554
+ * `endpoint` is **refused**: it names where the prompt must go, the proxy is what
555
+ * makes that call, and the field has never been sent to one — so honoring it is
556
+ * unverifiable and ignoring it would reach the provider's default upstream instead
557
+ * of the host the caller pinned. Use `callProviderCompletion`, or point the
558
+ * proxy itself at the intended upstream.
559
+ *
474
560
  * @param proxyUrl - Base URL of the proxy server
475
561
  * @param params - Same parameters as {@link callProviderCompletion}
476
562
  * @public
477
563
  */
478
564
  export async function callProxiedCompletion(proxyUrl, params) {
479
- const { descriptor, apiKey, system, messages, temperature, modelOverride, logger, tools, signal, thinking, maxTokens, structuredOutput } = params;
565
+ const { descriptor, apiKey, system, messages, temperature, modelOverride, logger, tools, signal, thinking, maxTokens, structuredOutput, cache, tier, endpoint } = params;
480
566
  const splitResult = splitChatRequest(system, messages);
481
567
  if (splitResult.isFailure()) {
482
568
  return fail(splitResult.message);
@@ -484,6 +570,48 @@ export async function callProxiedCompletion(proxyUrl, params) {
484
570
  if (splitResult.value.prompt.attachments.length > 0 && !descriptor.acceptsImageInput) {
485
571
  return fail(`provider "${descriptor.id}" does not accept image input`);
486
572
  }
573
+ // `endpoint` names the upstream the request must reach. Unlike `tier` it cannot be
574
+ // resolved on this side, because the proxy is the one making that call — it would
575
+ // have to be forwarded and honored. No proxy honors it: the field has never been
576
+ // sent, so every deployed proxy would ignore it and fall back to the provider's
577
+ // public API.
578
+ //
579
+ // Silently is the problem. A caller names an endpoint to pin *where the prompt
580
+ // goes* — a self-hosted model, a LAN deployment, a residency boundary — so the
581
+ // failure is not a degraded answer, it is the content going somewhere they
582
+ // explicitly said it must not, discoverable only by watching the proxy's traffic.
583
+ // That is the same shape as the structured-output silent-drop this function
584
+ // already refuses to allow, with a worse consequence.
585
+ //
586
+ // So refuse rather than pretend. Honoring `endpoint` over a proxy needs a wire
587
+ // field AND a report-back the caller can verify, the way `structuredOutput` is
588
+ // acknowledged below; until that protocol exists, the direct path is where this
589
+ // parameter works.
590
+ if (endpoint !== undefined) {
591
+ return fail(`callProxiedCompletion: endpoint is not supported on the proxied path — a proxy ` +
592
+ `cannot confirm it honored it, and silently reaching the provider's default ` +
593
+ `upstream would send the request somewhere the caller excluded. Use ` +
594
+ `callProviderCompletion, or route the proxy itself at the intended upstream.`);
595
+ }
596
+ // The quality tier is resolved HERE rather than forwarded, because it can be:
597
+ // the tier walk and the alias map both live on the descriptor, which is
598
+ // client-side, so this side already holds everything the resolution needs.
599
+ // Sending the concrete model through the existing `modelOverride` field means a
600
+ // proxy needs no new vocabulary and an already-deployed one honors the tier
601
+ // without being updated — where a `tier` body field it did not understand would
602
+ // be ignored, silently serving a frontier request from the base model.
603
+ //
604
+ // Only when a tier was actually asked for. With no tier, `modelOverride` passes
605
+ // through exactly as before, including absent — which is what lets a proxy apply
606
+ // its own default.
607
+ let effectiveModelOverride = modelOverride;
608
+ if (tier !== undefined) {
609
+ const tierResult = resolveProviderModel(descriptor, modelOverride, tier);
610
+ if (tierResult.isFailure()) {
611
+ return fail(tierResult.message);
612
+ }
613
+ effectiveModelOverride = tierResult.value;
614
+ }
487
615
  const body = {
488
616
  providerId: descriptor.id,
489
617
  apiKey,
@@ -497,8 +625,8 @@ export async function callProxiedCompletion(proxyUrl, params) {
497
625
  if (system !== undefined) {
498
626
  body.system = system;
499
627
  }
500
- if (modelOverride !== undefined) {
501
- body.modelOverride = modelOverride;
628
+ if (effectiveModelOverride !== undefined) {
629
+ body.modelOverride = effectiveModelOverride;
502
630
  }
503
631
  if (tools && tools.length > 0) {
504
632
  body.tools = tools;
@@ -517,12 +645,19 @@ export async function callProxiedCompletion(proxyUrl, params) {
517
645
  // `JsonSchema.fromJson(raw)` before calling `callProviderCompletion`.
518
646
  body.structuredOutput =
519
647
  structuredOutput.mode === 'schema'
520
- ? Object.assign({ mode: 'schema', schema: structuredOutput.schema.toJson() }, (structuredOutput.onUnsupported !== undefined
648
+ ? Object.assign(Object.assign({ mode: 'schema', schema: structuredOutput.schema.toJson() }, (structuredOutput.onUnsupported !== undefined
521
649
  ? { onUnsupported: structuredOutput.onUnsupported }
650
+ : {})), (structuredOutput.adaptOptionalToNullable !== undefined
651
+ ? { adaptOptionalToNullable: structuredOutput.adaptOptionalToNullable }
522
652
  : {})) : Object.assign({ mode: 'json-object' }, (structuredOutput.onUnsupported !== undefined
523
653
  ? { onUnsupported: structuredOutput.onUnsupported }
524
654
  : {}));
525
655
  }
656
+ // `IAiCacheRequest` is plain numbers and a string — JSON-serializable as-is, unlike
657
+ // `structuredOutput`'s schema above, so it needs no wire projection before forwarding.
658
+ if (cache !== undefined) {
659
+ body.cache = cache;
660
+ }
526
661
  /* c8 ignore next 1 - optional logger */
527
662
  logger === null || logger === void 0 ? void 0 : logger.info(`AI proxy request: provider=${descriptor.id}, proxy=${proxyUrl}`);
528
663
  const url = `${proxyUrl}/api/ai/completion`;
@@ -542,22 +677,24 @@ export async function callProxiedCompletion(proxyUrl, params) {
542
677
  // rather than a response claiming an enforcement nobody verified — a proxy
543
678
  // predating this feature drops the constraint silently, which is the exact
544
679
  // failure this surface exists to remove.
680
+ // A proxy relaying `callProviderCompletion`'s result carries an already-normalized
681
+ // IAiCompletionUsage, not a provider wire shape — so this validates that shape
682
+ // rather than reaching for the per-provider normalizers. A proxy that reports
683
+ // nothing, or reports something malformed, yields `undefined`: the documented
684
+ // "no normalized usage exposed", never a partially-trusted object and never a
685
+ // fabricated zero.
686
+ const usage = aiCompletionUsage.convert(response.usage).orDefault();
687
+ // Built once: the two returns differ only in the enforcement they report, and
688
+ // duplicating the shape is how a field added to one and not the other drifts.
689
+ const base = Object.assign({ content: response.content, truncated: response.truncated === true }, (usage !== undefined ? { usage } : {}));
545
690
  if (structuredOutput === undefined) {
546
- return succeed({
547
- content: response.content,
548
- truncated: response.truncated === true,
549
- structuredOutput: 'none'
550
- });
691
+ return succeed(Object.assign(Object.assign({}, base), { structuredOutput: 'none' }));
551
692
  }
552
693
  if (!isStructuredOutputEnforcement(response.structuredOutput)) {
553
694
  return fail(`proxy did not report which structured-output constraint it applied ` +
554
695
  `(got ${JSON.stringify(response.structuredOutput)}); it may predate the feature and have ` +
555
696
  `dropped the request silently`);
556
697
  }
557
- return succeed({
558
- content: response.content,
559
- truncated: response.truncated === true,
560
- structuredOutput: response.structuredOutput
561
- });
698
+ return succeed(Object.assign(Object.assign({}, base), { structuredOutput: response.structuredOutput }));
562
699
  }
563
700
  //# sourceMappingURL=completionClient.js.map