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

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 (156) hide show
  1. package/CAPABILITIES.md +95 -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 +70 -5
  5. package/dist/packlets/ai-assist/chatRequestBuilders.js.map +1 -1
  6. package/dist/packlets/ai-assist/completionClient.js +122 -45
  7. package/dist/packlets/ai-assist/completionClient.js.map +1 -1
  8. package/dist/packlets/ai-assist/imageGenerationClient.js +1 -1
  9. package/dist/packlets/ai-assist/imageGenerationClient.js.map +1 -1
  10. package/dist/packlets/ai-assist/index.js +3 -1
  11. package/dist/packlets/ai-assist/index.js.map +1 -1
  12. package/dist/packlets/ai-assist/model.js.map +1 -1
  13. package/dist/packlets/ai-assist/promptCacheAnthropicBreakpointHarness.js +200 -0
  14. package/dist/packlets/ai-assist/promptCacheAnthropicBreakpointHarness.js.map +1 -0
  15. package/dist/packlets/ai-assist/promptCacheObservabilityHarness.js +137 -0
  16. package/dist/packlets/ai-assist/promptCacheObservabilityHarness.js.map +1 -0
  17. package/dist/packlets/ai-assist/promptCacheRoutingAbHarness.js +156 -0
  18. package/dist/packlets/ai-assist/promptCacheRoutingAbHarness.js.map +1 -0
  19. package/dist/packlets/ai-assist/promptCacheUsageMath.js +124 -0
  20. package/dist/packlets/ai-assist/promptCacheUsageMath.js.map +1 -0
  21. package/dist/packlets/ai-assist/registry.js +9 -11
  22. package/dist/packlets/ai-assist/registry.js.map +1 -1
  23. package/dist/packlets/ai-assist/streamUsageCapability.js +124 -0
  24. package/dist/packlets/ai-assist/streamUsageCapability.js.map +1 -0
  25. package/dist/packlets/ai-assist/streamingAdapters/anthropic.js +24 -5
  26. package/dist/packlets/ai-assist/streamingAdapters/anthropic.js.map +1 -1
  27. package/dist/packlets/ai-assist/streamingAdapters/clientToolContinuationBuilder.js +64 -33
  28. package/dist/packlets/ai-assist/streamingAdapters/clientToolContinuationBuilder.js.map +1 -1
  29. package/dist/packlets/ai-assist/streamingAdapters/common.js +25 -1
  30. package/dist/packlets/ai-assist/streamingAdapters/common.js.map +1 -1
  31. package/dist/packlets/ai-assist/streamingAdapters/gemini.js +12 -5
  32. package/dist/packlets/ai-assist/streamingAdapters/gemini.js.map +1 -1
  33. package/dist/packlets/ai-assist/streamingAdapters/openaiChat.js +34 -8
  34. package/dist/packlets/ai-assist/streamingAdapters/openaiChat.js.map +1 -1
  35. package/dist/packlets/ai-assist/streamingAdapters/openaiResponses.js +22 -14
  36. package/dist/packlets/ai-assist/streamingAdapters/openaiResponses.js.map +1 -1
  37. package/dist/packlets/ai-assist/streamingClient.js +3 -2
  38. package/dist/packlets/ai-assist/streamingClient.js.map +1 -1
  39. package/dist/packlets/ai-assist/thinkingOptionsResolver.js +18 -3
  40. package/dist/packlets/ai-assist/thinkingOptionsResolver.js.map +1 -1
  41. package/dist/packlets/ai-assist/toolFormats.js +80 -0
  42. package/dist/packlets/ai-assist/toolFormats.js.map +1 -1
  43. package/dist/packlets/ai-assist/toolTypes.js +21 -0
  44. package/dist/packlets/ai-assist/toolTypes.js.map +1 -0
  45. package/dist/packlets/ai-assist/usageNormalization.js +178 -0
  46. package/dist/packlets/ai-assist/usageNormalization.js.map +1 -0
  47. package/dist/packlets/ai-assist/usageTypes.js +21 -0
  48. package/dist/packlets/ai-assist/usageTypes.js.map +1 -0
  49. package/dist/packlets/mustache/index.js.map +1 -1
  50. package/dist/packlets/mustache/interfaces.js.map +1 -1
  51. package/dist/packlets/mustache/mustacheTemplate.js +91 -0
  52. package/dist/packlets/mustache/mustacheTemplate.js.map +1 -1
  53. package/dist/ts-extras.d.ts +547 -30
  54. package/lib/packlets/ai-assist/cacheRequest.d.ts +100 -0
  55. package/lib/packlets/ai-assist/cacheRequest.d.ts.map +1 -0
  56. package/lib/packlets/ai-assist/cacheRequest.js +123 -0
  57. package/lib/packlets/ai-assist/cacheRequest.js.map +1 -0
  58. package/lib/packlets/ai-assist/chatRequestBuilders.d.ts +34 -4
  59. package/lib/packlets/ai-assist/chatRequestBuilders.d.ts.map +1 -1
  60. package/lib/packlets/ai-assist/chatRequestBuilders.js +73 -5
  61. package/lib/packlets/ai-assist/chatRequestBuilders.js.map +1 -1
  62. package/lib/packlets/ai-assist/completionClient.d.ts +6 -0
  63. package/lib/packlets/ai-assist/completionClient.d.ts.map +1 -1
  64. package/lib/packlets/ai-assist/completionClient.js +121 -44
  65. package/lib/packlets/ai-assist/completionClient.js.map +1 -1
  66. package/lib/packlets/ai-assist/imageGenerationClient.d.ts.map +1 -1
  67. package/lib/packlets/ai-assist/imageGenerationClient.js +1 -1
  68. package/lib/packlets/ai-assist/imageGenerationClient.js.map +1 -1
  69. package/lib/packlets/ai-assist/index.d.ts +7 -2
  70. package/lib/packlets/ai-assist/index.d.ts.map +1 -1
  71. package/lib/packlets/ai-assist/index.js +11 -1
  72. package/lib/packlets/ai-assist/index.js.map +1 -1
  73. package/lib/packlets/ai-assist/model.d.ts +59 -288
  74. package/lib/packlets/ai-assist/model.d.ts.map +1 -1
  75. package/lib/packlets/ai-assist/model.js.map +1 -1
  76. package/lib/packlets/ai-assist/promptCacheAnthropicBreakpointHarness.d.ts +124 -0
  77. package/lib/packlets/ai-assist/promptCacheAnthropicBreakpointHarness.d.ts.map +1 -0
  78. package/lib/packlets/ai-assist/promptCacheAnthropicBreakpointHarness.js +208 -0
  79. package/lib/packlets/ai-assist/promptCacheAnthropicBreakpointHarness.js.map +1 -0
  80. package/lib/packlets/ai-assist/promptCacheObservabilityHarness.d.ts +77 -0
  81. package/lib/packlets/ai-assist/promptCacheObservabilityHarness.d.ts.map +1 -0
  82. package/lib/packlets/ai-assist/promptCacheObservabilityHarness.js +143 -0
  83. package/lib/packlets/ai-assist/promptCacheObservabilityHarness.js.map +1 -0
  84. package/lib/packlets/ai-assist/promptCacheRoutingAbHarness.d.ts +84 -0
  85. package/lib/packlets/ai-assist/promptCacheRoutingAbHarness.d.ts.map +1 -0
  86. package/lib/packlets/ai-assist/promptCacheRoutingAbHarness.js +163 -0
  87. package/lib/packlets/ai-assist/promptCacheRoutingAbHarness.js.map +1 -0
  88. package/lib/packlets/ai-assist/promptCacheUsageMath.d.ts +105 -0
  89. package/lib/packlets/ai-assist/promptCacheUsageMath.d.ts.map +1 -0
  90. package/lib/packlets/ai-assist/promptCacheUsageMath.js +130 -0
  91. package/lib/packlets/ai-assist/promptCacheUsageMath.js.map +1 -0
  92. package/lib/packlets/ai-assist/registry.d.ts.map +1 -1
  93. package/lib/packlets/ai-assist/registry.js +9 -11
  94. package/lib/packlets/ai-assist/registry.js.map +1 -1
  95. package/lib/packlets/ai-assist/streamUsageCapability.d.ts +119 -0
  96. package/lib/packlets/ai-assist/streamUsageCapability.d.ts.map +1 -0
  97. package/lib/packlets/ai-assist/streamUsageCapability.js +130 -0
  98. package/lib/packlets/ai-assist/streamUsageCapability.js.map +1 -0
  99. package/lib/packlets/ai-assist/streamingAdapters/anthropic.d.ts.map +1 -1
  100. package/lib/packlets/ai-assist/streamingAdapters/anthropic.js +23 -4
  101. package/lib/packlets/ai-assist/streamingAdapters/anthropic.js.map +1 -1
  102. package/lib/packlets/ai-assist/streamingAdapters/clientToolContinuationBuilder.d.ts +21 -12
  103. package/lib/packlets/ai-assist/streamingAdapters/clientToolContinuationBuilder.d.ts.map +1 -1
  104. package/lib/packlets/ai-assist/streamingAdapters/clientToolContinuationBuilder.js +64 -34
  105. package/lib/packlets/ai-assist/streamingAdapters/clientToolContinuationBuilder.js.map +1 -1
  106. package/lib/packlets/ai-assist/streamingAdapters/common.d.ts +21 -0
  107. package/lib/packlets/ai-assist/streamingAdapters/common.d.ts.map +1 -1
  108. package/lib/packlets/ai-assist/streamingAdapters/common.js +25 -1
  109. package/lib/packlets/ai-assist/streamingAdapters/common.js.map +1 -1
  110. package/lib/packlets/ai-assist/streamingAdapters/gemini.d.ts.map +1 -1
  111. package/lib/packlets/ai-assist/streamingAdapters/gemini.js +12 -5
  112. package/lib/packlets/ai-assist/streamingAdapters/gemini.js.map +1 -1
  113. package/lib/packlets/ai-assist/streamingAdapters/openaiChat.d.ts +1 -1
  114. package/lib/packlets/ai-assist/streamingAdapters/openaiChat.d.ts.map +1 -1
  115. package/lib/packlets/ai-assist/streamingAdapters/openaiChat.js +33 -7
  116. package/lib/packlets/ai-assist/streamingAdapters/openaiChat.js.map +1 -1
  117. package/lib/packlets/ai-assist/streamingAdapters/openaiResponses.d.ts +1 -1
  118. package/lib/packlets/ai-assist/streamingAdapters/openaiResponses.d.ts.map +1 -1
  119. package/lib/packlets/ai-assist/streamingAdapters/openaiResponses.js +21 -13
  120. package/lib/packlets/ai-assist/streamingAdapters/openaiResponses.js.map +1 -1
  121. package/lib/packlets/ai-assist/streamingClient.d.ts.map +1 -1
  122. package/lib/packlets/ai-assist/streamingClient.js +3 -2
  123. package/lib/packlets/ai-assist/streamingClient.js.map +1 -1
  124. package/lib/packlets/ai-assist/thinkingOptionsResolver.d.ts.map +1 -1
  125. package/lib/packlets/ai-assist/thinkingOptionsResolver.js +18 -3
  126. package/lib/packlets/ai-assist/thinkingOptionsResolver.js.map +1 -1
  127. package/lib/packlets/ai-assist/toolFormats.d.ts +55 -1
  128. package/lib/packlets/ai-assist/toolFormats.d.ts.map +1 -1
  129. package/lib/packlets/ai-assist/toolFormats.js +82 -0
  130. package/lib/packlets/ai-assist/toolFormats.js.map +1 -1
  131. package/lib/packlets/ai-assist/toolTypes.d.ts +328 -0
  132. package/lib/packlets/ai-assist/toolTypes.d.ts.map +1 -0
  133. package/lib/packlets/ai-assist/toolTypes.js +22 -0
  134. package/lib/packlets/ai-assist/toolTypes.js.map +1 -0
  135. package/lib/packlets/ai-assist/usageNormalization.d.ts +58 -0
  136. package/lib/packlets/ai-assist/usageNormalization.d.ts.map +1 -0
  137. package/lib/packlets/ai-assist/usageNormalization.js +184 -0
  138. package/lib/packlets/ai-assist/usageNormalization.js.map +1 -0
  139. package/lib/packlets/ai-assist/usageTypes.d.ts +84 -0
  140. package/lib/packlets/ai-assist/usageTypes.d.ts.map +1 -0
  141. package/lib/packlets/ai-assist/usageTypes.js +22 -0
  142. package/lib/packlets/ai-assist/usageTypes.js.map +1 -0
  143. package/lib/packlets/mustache/index.d.ts +1 -1
  144. package/lib/packlets/mustache/index.d.ts.map +1 -1
  145. package/lib/packlets/mustache/index.js.map +1 -1
  146. package/lib/packlets/mustache/interfaces.d.ts +30 -0
  147. package/lib/packlets/mustache/interfaces.d.ts.map +1 -1
  148. package/lib/packlets/mustache/interfaces.js.map +1 -1
  149. package/lib/packlets/mustache/mustacheTemplate.d.ts +56 -1
  150. package/lib/packlets/mustache/mustacheTemplate.d.ts.map +1 -1
  151. package/lib/packlets/mustache/mustacheTemplate.js +91 -0
  152. package/lib/packlets/mustache/mustacheTemplate.js.map +1 -1
  153. package/package.json +16 -16
  154. package/perf/promptCacheAnthropicBreakpoint.js +135 -0
  155. package/perf/promptCacheObservability.js +107 -0
  156. package/perf/promptCacheRoutingAb.js +105 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"promptCacheAnthropicBreakpointHarness.js","sourceRoot":"","sources":["../../../src/packlets/ai-assist/promptCacheAnthropicBreakpointHarness.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 * Pure logic and injectable transport for `perf/promptCacheAnthropicBreakpoint.js` — the first\n * measurement of whether C3's Anthropic `cache_control` emission (`IAiCacheRequest.systemBreakpoints`)\n * actually produces a cache hit against a live provider.\n *\n * @remarks\n * Every prompt-cache measurement shipped by this stream so far (`promptCacheObservability.js`,\n * `promptCacheRoutingAb.js`) ran against xAI, whose caching is automatic — no request-side\n * directive is sent, so neither harness exercises C3's emit path at all. Anthropic's cache is the\n * sharp case: it is opt-in **per content block** via `cache_control`, with no automatic path, so a\n * breakpoint is the only thing that can produce a hit. This harness is an A/B rather than a single\n * standing assertion (unlike `promptCacheObservabilityHarness.ts`) precisely because Anthropic has\n * no automatic caching to fall back on: without a negative control that reliably shows no hit, a\n * hit in the positive arm could not be attributed to `systemBreakpoints` at all.\n *\n * Reuses {@link compareCacheHitRatios} / {@link computeCacheUsageRatio} /\n * {@link formatCacheRatioPercent} from `promptCacheUsageMath.ts` rather than re-deriving the\n * ratio/verdict arithmetic — see {@link withAnthropicZeroDefaults} for the one Anthropic-specific\n * wrinkle those shared functions need help with. Also reuses\n * {@link describeObservabilityUsage} from `promptCacheObservabilityHarness.ts` to print reads and\n * writes separately, rather than writing a third formatter for the same shape.\n * @packageDocumentation\n */\n\nimport { fail, succeed, type Result } from '@fgv/ts-utils';\n\nimport { type IAiCacheRequest } from './cacheRequest';\nimport { type IProviderCompletionParams } from './completionClient';\nimport { type IAiCompletionResponse, type IAiProviderDescriptor } from './model';\nimport { describeObservabilityUsage } from './promptCacheObservabilityHarness';\nimport {\n type CacheHitComparisonVerdict,\n compareCacheHitRatios,\n formatCacheRatioPercent\n} from './promptCacheUsageMath';\nimport { type IAiCompletionUsage } from './usageTypes';\n\n/** Injected transport — only the network call needs a live key. @internal */\nexport interface IPromptCacheAnthropicBreakpointDeps {\n readonly callCompletion: (params: IProviderCompletionParams) => Promise<Result<IAiCompletionResponse>>;\n}\n\n/**\n * Builds one arm's stable system prefix. Same shape as `buildObservabilityStablePrefix` /\n * `buildRoutingAbPrefix` (~8,700 tokens measured for this exact 400-line generator against a\n * comparable provider in this stream's earlier runs) — comfortably above every\n * `minCacheablePrefixTokens` entry design.md §7 lists for Anthropic, including Haiku-class models'\n * 4,096-token floor, not just the 1,024-token floor this harness's target model (`claude-sonnet-5`,\n * the `'base'` tier this stream's other harnesses also default to) actually needs. `salt` and `arm`\n * both feed the text so the two arms — and two separate runs — never warm each other's cache.\n * @internal\n */\nexport function buildAnthropicBreakpointPrefix(salt: string, arm: string): string {\n const lines: string[] = [];\n for (let i = 0; i < 400; i++) {\n lines.push(\n `Directive ${salt}-${arm}-${i}: when asked about topic ${i}, respond precisely and cite source ${i}-${\n i * 7\n }.`\n );\n }\n return `You are a careful assistant operating under the following fixed policy document.\\n\\n${lines.join(\n '\\n'\n )}`;\n}\n\n/**\n * Fills Anthropic's cache-usage fields with their design-mandated zero default when the wire\n * response omits them, before feeding a usage block to {@link compareCacheHitRatios}.\n *\n * @remarks\n * `normalizeAnthropicUsage` always reports `reports: 'reads-and-writes'` for this provider, and\n * design.md §8 states the semantics that follow from it explicitly: *\"Under `'reads-and-writes'`,\n * an absent `cacheWriteTokens` genuinely means zero were written.\"* The same reasoning covers\n * `cachedInputTokens` and `uncachedInputTokens`. So for an Anthropic usage block specifically, an\n * absent field is a **known** zero, not an unknown value — applying the default here does not\n * violate design.md's R-c (\"unknown is reported as unknown, never defaulted to a number\"); it is\n * what R-c's own `reports` discriminator exists to allow. This is deliberately narrower than\n * {@link computeCacheUsageRatio}, which stays provider-agnostic (a `'reads'`-only provider's\n * absent field genuinely is ambiguous, per design.md §8's own OQ-1 discussion) — the default is\n * applied here, once, rather than widening the shared function's semantics for every caller.\n *\n * Display (`describeObservabilityUsage`) is untouched by this and keeps printing the raw wire\n * value, so a reader can still see whether a field was actually reported or defaulted.\n * @internal\n */\nexport function withAnthropicZeroDefaults(\n usage: IAiCompletionUsage | undefined\n): IAiCompletionUsage | undefined {\n if (usage === undefined || usage.reports !== 'reads-and-writes') {\n return usage;\n }\n return {\n ...usage,\n cachedInputTokens: usage.cachedInputTokens ?? 0,\n uncachedInputTokens: usage.uncachedInputTokens ?? 0,\n cacheWriteTokens: usage.cacheWriteTokens ?? 0\n };\n}\n\n/** One arm's cold-then-warm outcome. @internal */\nexport interface IPromptCacheAnthropicBreakpointArmResult {\n readonly label: string;\n readonly coldUsage: IAiCompletionUsage | undefined;\n readonly warmUsage: IAiCompletionUsage | undefined;\n}\n\n/**\n * Runs one arm: a cold call, then a warm call sharing the same\n * {@link buildAnthropicBreakpointPrefix} system prompt and differing only in the final user turn —\n * optionally supplying `cache.systemBreakpoints`. A single breakpoint is placed one character short\n * of the end of `system`, marking essentially the whole fixed prefix as cacheable (design.md §6.1:\n * offsets must be strictly `< system.length`).\n * @internal\n */\nexport async function runPromptCacheAnthropicBreakpointArm(\n deps: IPromptCacheAnthropicBreakpointDeps,\n params: {\n readonly descriptor: IAiProviderDescriptor;\n readonly apiKey: string;\n readonly label: string;\n readonly salt: string;\n readonly arm: string;\n readonly withBreakpoint: boolean;\n }\n): Promise<Result<IPromptCacheAnthropicBreakpointArmResult>> {\n const { descriptor, apiKey, label, salt, arm, withBreakpoint } = params;\n const system = buildAnthropicBreakpointPrefix(salt, arm);\n const cache: IAiCacheRequest | undefined = withBreakpoint\n ? { systemBreakpoints: [system.length - 1] }\n : undefined;\n const call = (content: string): Promise<Result<IAiCompletionResponse>> =>\n deps.callCompletion({\n descriptor,\n apiKey,\n system,\n messages: [{ role: 'user', content }],\n cache\n });\n\n const cold = await call('In one sentence, what is directive 12 about?');\n if (cold.isFailure()) {\n return fail(`${label}: cold call failed: ${cold.message}`);\n }\n\n // Only the tail differs from the cold call — the system prefix is byte-identical.\n const warm = await call('In one sentence, what is directive 99 about?');\n if (warm.isFailure()) {\n return fail(`${label}: warm call failed: ${warm.message}`);\n }\n\n return succeed({ label, coldUsage: cold.value.usage, warmUsage: warm.value.usage });\n}\n\n/** The full A/B: both arms' results and the breakpoint verdict. @internal */\nexport interface IPromptCacheAnthropicBreakpointResult {\n readonly withoutBreakpoint: IPromptCacheAnthropicBreakpointArmResult;\n readonly withBreakpoint: IPromptCacheAnthropicBreakpointArmResult;\n /** Baseline = `withoutBreakpoint`'s warm call; candidate = `withBreakpoint`'s warm call. */\n readonly verdict: CacheHitComparisonVerdict;\n readonly withoutBreakpointWarmRatio: number | undefined;\n readonly withBreakpointWarmRatio: number | undefined;\n}\n\n/**\n * Runs both arms of the Anthropic breakpoint A/B and classifies the result.\n *\n * @remarks\n * Both warm usages are passed through {@link withAnthropicZeroDefaults} before comparison, so the\n * negative control (`withoutBreakpoint`) is correctly read as \"no cache activity\" even if\n * Anthropic's wire response omits the cache fields entirely on a request that sends no\n * `cache_control` — the expected shape for that arm — rather than being scored `'inconclusive'`\n * by {@link compareCacheHitRatios}'s provider-agnostic \"absent field = unknown\" default.\n * @internal\n */\nexport async function runPromptCacheAnthropicBreakpointHarness(\n deps: IPromptCacheAnthropicBreakpointDeps,\n params: {\n readonly descriptor: IAiProviderDescriptor;\n readonly apiKey: string;\n readonly salt: string;\n }\n): Promise<Result<IPromptCacheAnthropicBreakpointResult>> {\n const { descriptor, apiKey, salt } = params;\n\n const withoutBreakpointResult = await runPromptCacheAnthropicBreakpointArm(deps, {\n descriptor,\n apiKey,\n label: 'arm 1: WITHOUT systemBreakpoints (negative control)',\n salt,\n arm: 'a',\n withBreakpoint: false\n });\n if (withoutBreakpointResult.isFailure()) {\n return fail(withoutBreakpointResult.message);\n }\n\n const withBreakpointResult = await runPromptCacheAnthropicBreakpointArm(deps, {\n descriptor,\n apiKey,\n label: 'arm 2: WITH systemBreakpoints (C3 emission under test)',\n salt,\n arm: 'b',\n withBreakpoint: true\n });\n if (withBreakpointResult.isFailure()) {\n return fail(withBreakpointResult.message);\n }\n\n const comparison = compareCacheHitRatios(\n withAnthropicZeroDefaults(withoutBreakpointResult.value.warmUsage),\n withAnthropicZeroDefaults(withBreakpointResult.value.warmUsage)\n );\n return succeed({\n withoutBreakpoint: withoutBreakpointResult.value,\n withBreakpoint: withBreakpointResult.value,\n verdict: comparison.verdict,\n withoutBreakpointWarmRatio: comparison.baselineRatio,\n withBreakpointWarmRatio: comparison.candidateRatio\n });\n}\n\n/**\n * One line per call, reusing `describeObservabilityUsage` so reads and writes are printed\n * separately (`uncached=… cached=… written=…`) rather than collapsed into a single ratio, per\n * this measurement's requirement to report Anthropic's `reads-and-writes` fields individually.\n * @internal\n */\nexport function describeAnthropicBreakpointUsage(\n label: string,\n usage: IAiCompletionUsage | undefined\n): string {\n return describeObservabilityUsage(label, usage);\n}\n\n/**\n * Formats the A/B's closing verdict line(s).\n * @internal\n */\nexport function formatAnthropicBreakpointVerdict(result: IPromptCacheAnthropicBreakpointResult): string {\n const a = formatCacheRatioPercent(result.withoutBreakpointWarmRatio);\n const b = formatCacheRatioPercent(result.withBreakpointWarmRatio);\n switch (result.verdict) {\n case 'confirms':\n return (\n `arm 1 (no breakpoints) warm ${a} -> arm 2 (systemBreakpoints) warm ${b}\\n` +\n 'CONFIRMS the prediction: Anthropic cache_control breakpoints produce a cache hit that an ' +\n 'otherwise-identical request with no cache directive does not.'\n );\n case 'misses':\n return (\n `arm 1 (no breakpoints) warm ${a} -> arm 2 (systemBreakpoints) warm ${b}\\n` +\n \"MISSES the prediction: arm 2 did not clear the floor. Either C3's Anthropic cache_control \" +\n \"emission is wrong, or this harness's model/threshold assumptions are — revise the design \" +\n 'or the code, do not re-run for a better number. (Re-run once with a fresh SALT first, to ' +\n 'rule out a TTL-tainted reading.)'\n );\n case 'ambiguous':\n return (\n `arm 1 (no breakpoints) warm ${a} -> arm 2 (systemBreakpoints) warm ${b}\\n` +\n 'AMBIGUOUS: neither arm clearly separates. Re-run with a fresh SALT.'\n );\n case 'inconclusive':\n return 'inconclusive: cache-hit ratio not computable for at least one arm — a call may have failed.';\n }\n}\n"]}
@@ -0,0 +1,137 @@
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
+ * Pure logic and injectable transport for `perf/promptCacheObservability.js` — the C1 standing
22
+ * assertion for design.md §8.
23
+ *
24
+ * @remarks
25
+ * Extracted so the ratio arithmetic and the cold/warm verdict can be unit-tested against fixture
26
+ * usage blocks without live traffic or an API key. Mirrors `samples/testbed`'s `xaiCacheProbe`
27
+ * scenario, whose `IXaiCacheProbeDeps` seam this design.md §8's addendum names as the shape to
28
+ * copy: only the network call is injected, everything else is a plain function.
29
+ * @packageDocumentation
30
+ */
31
+ import { fail, succeed } from '@fgv/ts-utils';
32
+ import { compareCacheHitRatios, computeCacheUsageRatio, formatCacheRatioPercent } from './promptCacheUsageMath';
33
+ /**
34
+ * Builds the stable system prefix both calls share. Ported verbatim from the harness's
35
+ * `stablePrefix()` — repetition, not padding, so the text does not compress into something a
36
+ * provider's tokenizer collapses, and large enough that a genuine cache hit is unmistakable
37
+ * against design.md's recorded 128-token cold floor.
38
+ * @internal
39
+ */
40
+ export function buildObservabilityStablePrefix() {
41
+ const lines = [];
42
+ for (let i = 0; i < 400; i++) {
43
+ lines.push(`Directive ${i}: when asked about topic ${i}, respond precisely and cite source ${i}-${i * 7}.`);
44
+ }
45
+ return `You are a careful assistant operating under the following fixed policy document.\n\n${lines.join('\n')}`;
46
+ }
47
+ /**
48
+ * One line summarizing a call's usage block, e.g.
49
+ * `cold call : reports=reads uncached=8684 cached=192 (2.2% of input) written=undefined output=20`.
50
+ * Ported from `summarizeUsage`, now returning the line instead of printing it, and computing the
51
+ * ratio via {@link computeCacheUsageRatio} instead of an unguarded division (see that function's
52
+ * remarks for the `NaN%` this replaces).
53
+ * @internal
54
+ */
55
+ export function describeObservabilityUsage(label, usage) {
56
+ if (usage === undefined) {
57
+ return `${label}: usage absent from response`;
58
+ }
59
+ const { cachedRatio } = computeCacheUsageRatio(usage);
60
+ return (`${label}: reports=${usage.reports} uncached=${usage.uncachedInputTokens} cached=${usage.cachedInputTokens} ` +
61
+ `(${formatCacheRatioPercent(cachedRatio)} of input) written=${usage.cacheWriteTokens} output=${usage.outputTokens}`);
62
+ }
63
+ /**
64
+ * Evaluates the standing assertion's written-down prediction (design.md §8) against a cold/warm
65
+ * pair of usage blocks.
66
+ *
67
+ * @remarks
68
+ * This is new: the original harness printed both summaries and a static reminder for a human to
69
+ * judge by eye ("prediction check: reports should read 'reads' on both calls…"). Formalizing that
70
+ * judgement as a pure function is what makes it testable — and per `TESTING_GUIDELINES.md` §
71
+ * "Measurement Harnesses", an unevaluated prediction is exactly the kind of thing a broken
72
+ * harness never gets caught printing.
73
+ * @internal
74
+ */
75
+ export function evaluateObservabilityPrediction(cold, warm) {
76
+ const comparison = compareCacheHitRatios(cold, warm);
77
+ // `cold`/`warm` undefined is a strict subset of `comparison.verdict === 'inconclusive'` (an
78
+ // undefined usage block always yields an undefined ratio), but checking it explicitly is what
79
+ // narrows both to defined below — `reports` is a required field, so once narrowed there is no
80
+ // "usage present but reports absent" case left to guard against with `?.`/`?? 'absent'`.
81
+ if (cold === undefined || warm === undefined || comparison.verdict === 'inconclusive') {
82
+ return {
83
+ cold,
84
+ warm,
85
+ coldRatio: comparison.baselineRatio,
86
+ warmRatio: comparison.candidateRatio,
87
+ reportsAsPredicted: false,
88
+ verdict: 'inconclusive',
89
+ detail: 'cache-hit ratio not computable for the cold call, the warm call, or both'
90
+ };
91
+ }
92
+ const reportsAsPredicted = cold.reports === 'reads' && warm.reports === 'reads';
93
+ const common = {
94
+ cold,
95
+ warm,
96
+ coldRatio: comparison.baselineRatio,
97
+ warmRatio: comparison.candidateRatio,
98
+ reportsAsPredicted
99
+ };
100
+ if (!reportsAsPredicted) {
101
+ return Object.assign(Object.assign({}, common), { verdict: 'missed', detail: `usage.reports was not 'reads' on both calls (cold=${cold.reports}, warm=${warm.reports})` });
102
+ }
103
+ if (comparison.verdict === 'confirms') {
104
+ return Object.assign(Object.assign({}, common), { verdict: 'confirmed', detail: `warm cache-hit ratio (${formatCacheRatioPercent(comparison.candidateRatio)}) clears the ` +
105
+ `cold floor (${formatCacheRatioPercent(comparison.baselineRatio)})` });
106
+ }
107
+ return Object.assign(Object.assign({}, common), { verdict: 'missed', detail: `warm cache-hit ratio (${formatCacheRatioPercent(comparison.candidateRatio)}) did not clear ` +
108
+ `the cold floor (${formatCacheRatioPercent(comparison.baselineRatio)})` });
109
+ }
110
+ /**
111
+ * Runs the standing assertion: a cold call, then a warm call sharing the same
112
+ * {@link buildObservabilityStablePrefix} system prompt and differing only in the final user turn.
113
+ * @internal
114
+ */
115
+ export async function runPromptCacheObservabilityHarness(deps, descriptor, apiKey) {
116
+ const system = buildObservabilityStablePrefix();
117
+ const cold = await deps.callCompletion({
118
+ descriptor,
119
+ apiKey,
120
+ system,
121
+ messages: [{ role: 'user', content: 'In one sentence, what is directive 12 about?' }]
122
+ });
123
+ if (cold.isFailure()) {
124
+ return fail(`cold call failed: ${cold.message}`);
125
+ }
126
+ const warm = await deps.callCompletion({
127
+ descriptor,
128
+ apiKey,
129
+ system,
130
+ messages: [{ role: 'user', content: 'In one sentence, what is directive 99 about?' }]
131
+ });
132
+ if (warm.isFailure()) {
133
+ return fail(`warm call failed: ${warm.message}`);
134
+ }
135
+ return succeed(evaluateObservabilityPrediction(cold.value.usage, warm.value.usage));
136
+ }
137
+ //# sourceMappingURL=promptCacheObservabilityHarness.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"promptCacheObservabilityHarness.js","sourceRoot":"","sources":["../../../src/packlets/ai-assist/promptCacheObservabilityHarness.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 * Pure logic and injectable transport for `perf/promptCacheObservability.js` — the C1 standing\n * assertion for design.md §8.\n *\n * @remarks\n * Extracted so the ratio arithmetic and the cold/warm verdict can be unit-tested against fixture\n * usage blocks without live traffic or an API key. Mirrors `samples/testbed`'s `xaiCacheProbe`\n * scenario, whose `IXaiCacheProbeDeps` seam this design.md §8's addendum names as the shape to\n * copy: only the network call is injected, everything else is a plain function.\n * @packageDocumentation\n */\n\nimport { fail, succeed, type Result } from '@fgv/ts-utils';\n\nimport { type IProviderCompletionParams } from './completionClient';\nimport { type IAiCompletionResponse, type IAiProviderDescriptor } from './model';\nimport {\n compareCacheHitRatios,\n computeCacheUsageRatio,\n formatCacheRatioPercent\n} from './promptCacheUsageMath';\nimport { type IAiCompletionUsage } from './usageTypes';\n\n/**\n * Injected transport. The harness's value is the arithmetic below; the network call is the only\n * part that needs a live key, so it is the only part injected.\n * @internal\n */\nexport interface IPromptCacheObservabilityDeps {\n readonly callCompletion: (params: IProviderCompletionParams) => Promise<Result<IAiCompletionResponse>>;\n}\n\n/**\n * Builds the stable system prefix both calls share. Ported verbatim from the harness's\n * `stablePrefix()` — repetition, not padding, so the text does not compress into something a\n * provider's tokenizer collapses, and large enough that a genuine cache hit is unmistakable\n * against design.md's recorded 128-token cold floor.\n * @internal\n */\nexport function buildObservabilityStablePrefix(): string {\n const lines: string[] = [];\n for (let i = 0; i < 400; i++) {\n lines.push(\n `Directive ${i}: when asked about topic ${i}, respond precisely and cite source ${i}-${i * 7}.`\n );\n }\n return `You are a careful assistant operating under the following fixed policy document.\\n\\n${lines.join(\n '\\n'\n )}`;\n}\n\n/**\n * One line summarizing a call's usage block, e.g.\n * `cold call : reports=reads uncached=8684 cached=192 (2.2% of input) written=undefined output=20`.\n * Ported from `summarizeUsage`, now returning the line instead of printing it, and computing the\n * ratio via {@link computeCacheUsageRatio} instead of an unguarded division (see that function's\n * remarks for the `NaN%` this replaces).\n * @internal\n */\nexport function describeObservabilityUsage(label: string, usage: IAiCompletionUsage | undefined): string {\n if (usage === undefined) {\n return `${label}: usage absent from response`;\n }\n const { cachedRatio } = computeCacheUsageRatio(usage);\n return (\n `${label}: reports=${usage.reports} uncached=${usage.uncachedInputTokens} cached=${usage.cachedInputTokens} ` +\n `(${formatCacheRatioPercent(cachedRatio)} of input) written=${usage.cacheWriteTokens} output=${\n usage.outputTokens\n }`\n );\n}\n\n/**\n * Whether the standing assertion's prediction held: `usage.reports` should be `'reads'` on both\n * calls, and the warm call's cache-hit ratio should clear the cold call's by a wide margin.\n * @internal\n */\nexport type PromptCacheObservabilityVerdict = 'confirmed' | 'missed' | 'inconclusive';\n\n/** The evaluated outcome of one cold/warm pair, per design.md §8's written-down prediction. @internal */\nexport interface IPromptCacheObservabilityResult {\n readonly cold: IAiCompletionUsage | undefined;\n readonly warm: IAiCompletionUsage | undefined;\n readonly coldRatio: number | undefined;\n readonly warmRatio: number | undefined;\n /** `true` only when both calls reported `usage.reports === 'reads'`, as design.md §8 predicts for xAI. */\n readonly reportsAsPredicted: boolean;\n readonly verdict: PromptCacheObservabilityVerdict;\n readonly detail: string;\n}\n\n/**\n * Evaluates the standing assertion's written-down prediction (design.md §8) against a cold/warm\n * pair of usage blocks.\n *\n * @remarks\n * This is new: the original harness printed both summaries and a static reminder for a human to\n * judge by eye (\"prediction check: reports should read 'reads' on both calls…\"). Formalizing that\n * judgement as a pure function is what makes it testable — and per `TESTING_GUIDELINES.md` §\n * \"Measurement Harnesses\", an unevaluated prediction is exactly the kind of thing a broken\n * harness never gets caught printing.\n * @internal\n */\nexport function evaluateObservabilityPrediction(\n cold: IAiCompletionUsage | undefined,\n warm: IAiCompletionUsage | undefined\n): IPromptCacheObservabilityResult {\n const comparison = compareCacheHitRatios(cold, warm);\n\n // `cold`/`warm` undefined is a strict subset of `comparison.verdict === 'inconclusive'` (an\n // undefined usage block always yields an undefined ratio), but checking it explicitly is what\n // narrows both to defined below — `reports` is a required field, so once narrowed there is no\n // \"usage present but reports absent\" case left to guard against with `?.`/`?? 'absent'`.\n if (cold === undefined || warm === undefined || comparison.verdict === 'inconclusive') {\n return {\n cold,\n warm,\n coldRatio: comparison.baselineRatio,\n warmRatio: comparison.candidateRatio,\n reportsAsPredicted: false,\n verdict: 'inconclusive',\n detail: 'cache-hit ratio not computable for the cold call, the warm call, or both'\n };\n }\n\n const reportsAsPredicted = cold.reports === 'reads' && warm.reports === 'reads';\n const common = {\n cold,\n warm,\n coldRatio: comparison.baselineRatio,\n warmRatio: comparison.candidateRatio,\n reportsAsPredicted\n };\n\n if (!reportsAsPredicted) {\n return {\n ...common,\n verdict: 'missed',\n detail: `usage.reports was not 'reads' on both calls (cold=${cold.reports}, warm=${warm.reports})`\n };\n }\n if (comparison.verdict === 'confirms') {\n return {\n ...common,\n verdict: 'confirmed',\n detail:\n `warm cache-hit ratio (${formatCacheRatioPercent(comparison.candidateRatio)}) clears the ` +\n `cold floor (${formatCacheRatioPercent(comparison.baselineRatio)})`\n };\n }\n return {\n ...common,\n verdict: 'missed',\n detail:\n `warm cache-hit ratio (${formatCacheRatioPercent(comparison.candidateRatio)}) did not clear ` +\n `the cold floor (${formatCacheRatioPercent(comparison.baselineRatio)})`\n };\n}\n\n/**\n * Runs the standing assertion: a cold call, then a warm call sharing the same\n * {@link buildObservabilityStablePrefix} system prompt and differing only in the final user turn.\n * @internal\n */\nexport async function runPromptCacheObservabilityHarness(\n deps: IPromptCacheObservabilityDeps,\n descriptor: IAiProviderDescriptor,\n apiKey: string\n): Promise<Result<IPromptCacheObservabilityResult>> {\n const system = buildObservabilityStablePrefix();\n\n const cold = await deps.callCompletion({\n descriptor,\n apiKey,\n system,\n messages: [{ role: 'user', content: 'In one sentence, what is directive 12 about?' }]\n });\n if (cold.isFailure()) {\n return fail(`cold call failed: ${cold.message}`);\n }\n\n const warm = await deps.callCompletion({\n descriptor,\n apiKey,\n system,\n messages: [{ role: 'user', content: 'In one sentence, what is directive 99 about?' }]\n });\n if (warm.isFailure()) {\n return fail(`warm call failed: ${warm.message}`);\n }\n\n return succeed(evaluateObservabilityPrediction(cold.value.usage, warm.value.usage));\n}\n"]}
@@ -0,0 +1,156 @@
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
+ * Pure logic and injectable transport for `perf/promptCacheRoutingAb.js` — the A/B that measures
22
+ * whether `IAiCacheRequest.cacheKey` actually improves the xAI cache-hit rate (design.md §2's
23
+ * 2026-09-19 measurement note, and result.md's "gating defect in C3").
24
+ *
25
+ * @remarks
26
+ * Extracted for the same reason as `promptCacheObservabilityHarness.ts`: the verdict this harness
27
+ * prints (CONFIRMS / MISSES / AMBIGUOUS) "has already been used to justify a merge," per this
28
+ * stream's follow-up task, and had no test checking it classifies correctly.
29
+ * @packageDocumentation
30
+ */
31
+ import { fail, succeed } from '@fgv/ts-utils';
32
+ import { compareCacheHitRatios, computeCacheUsageRatio, formatCacheRatioPercent } from './promptCacheUsageMath';
33
+ /**
34
+ * Builds one arm's stable system prefix. Ported verbatim from the harness's `stablePrefix(arm)`.
35
+ * `salt` and `arm` both feed the text so the two arms — and two separate runs — never warm each
36
+ * other's cache.
37
+ * @internal
38
+ */
39
+ export function buildRoutingAbPrefix(salt, arm) {
40
+ const lines = [];
41
+ for (let i = 0; i < 400; i++) {
42
+ lines.push(`Directive ${salt}-${arm}-${i}: when asked about topic ${i}, respond precisely and cite source ${i}-${i * 7}.`);
43
+ }
44
+ return `You are a careful assistant operating under the following fixed policy document.\n\n${lines.join('\n')}`;
45
+ }
46
+ /**
47
+ * One line describing a usage block. Ported from `describeUsage`, now computing the ratio via
48
+ * {@link computeCacheUsageRatio} instead of an inline calculation that coalesced a missing
49
+ * `cachedInputTokens` or `uncachedInputTokens` to `0` (see that function's remarks).
50
+ * @internal
51
+ */
52
+ export function describeRoutingAbUsage(usage) {
53
+ if (usage === undefined) {
54
+ return 'usage absent from response';
55
+ }
56
+ const { cachedInputTokens, uncachedInputTokens } = usage;
57
+ const { totalInputTokens, cachedRatio } = computeCacheUsageRatio(usage);
58
+ if (cachedRatio === undefined) {
59
+ return `reports=${usage.reports} cached=${cachedInputTokens} uncached=${uncachedInputTokens} (ratio not computable)`;
60
+ }
61
+ return `reports=${usage.reports} cached=${cachedInputTokens} of ${totalInputTokens} input (${formatCacheRatioPercent(cachedRatio)})`;
62
+ }
63
+ /**
64
+ * Runs one arm: a cold call, then a warm call sharing the same {@link buildRoutingAbPrefix} system
65
+ * prompt and differing only in the final user turn — optionally supplying `cacheKey`. Ported from
66
+ * `runArm`, with the network call injected.
67
+ * @internal
68
+ */
69
+ export async function runPromptCacheRoutingAbArm(deps, params) {
70
+ const { descriptor, apiKey, label, salt, arm, cacheKey } = params;
71
+ const system = buildRoutingAbPrefix(salt, arm);
72
+ const cache = cacheKey !== undefined ? { cacheKey } : undefined;
73
+ const call = (content) => deps.callCompletion({
74
+ descriptor,
75
+ apiKey,
76
+ system,
77
+ messages: [{ role: 'user', content }],
78
+ cache
79
+ });
80
+ const cold = await call('In one sentence, what is directive 12 about?');
81
+ if (cold.isFailure()) {
82
+ return fail(`${label}: cold call failed: ${cold.message}`);
83
+ }
84
+ // Only the tail differs from the cold call — the prefix is byte-identical.
85
+ const warm = await call('In one sentence, what is directive 99 about?');
86
+ if (warm.isFailure()) {
87
+ return fail(`${label}: warm call failed: ${warm.message}`);
88
+ }
89
+ return succeed({ label, coldUsage: cold.value.usage, warmUsage: warm.value.usage });
90
+ }
91
+ /**
92
+ * Runs both arms of the routing-key A/B and classifies the result.
93
+ *
94
+ * @remarks
95
+ * The verdict compares each arm's **warm** call against the other's — not an arm's own cold vs.
96
+ * warm call — because the question is whether the routing key changes the warm hit rate relative
97
+ * to the no-key baseline, exactly as `promptCacheRoutingAb.js`'s original `main()` computed it
98
+ * (`pct(withoutKey)` / `pct(withKey)`, both taken from each arm's warm response).
99
+ * @internal
100
+ */
101
+ export async function runPromptCacheRoutingAbHarness(deps, params) {
102
+ const { descriptor, apiKey, salt } = params;
103
+ const withoutKeyResult = await runPromptCacheRoutingAbArm(deps, {
104
+ descriptor,
105
+ apiKey,
106
+ label: 'arm 1: WITHOUT cacheKey (behaviour before this change)',
107
+ salt,
108
+ arm: 'a'
109
+ });
110
+ if (withoutKeyResult.isFailure()) {
111
+ return fail(withoutKeyResult.message);
112
+ }
113
+ const withKeyResult = await runPromptCacheRoutingAbArm(deps, {
114
+ descriptor,
115
+ apiKey,
116
+ label: 'arm 2: WITH cacheKey (this change)',
117
+ salt,
118
+ arm: 'b',
119
+ cacheKey: `routing-ab-${salt}`
120
+ });
121
+ if (withKeyResult.isFailure()) {
122
+ return fail(withKeyResult.message);
123
+ }
124
+ const comparison = compareCacheHitRatios(withoutKeyResult.value.warmUsage, withKeyResult.value.warmUsage);
125
+ return succeed({
126
+ withoutKey: withoutKeyResult.value,
127
+ withKey: withKeyResult.value,
128
+ verdict: comparison.verdict,
129
+ withoutKeyWarmRatio: comparison.baselineRatio,
130
+ withKeyWarmRatio: comparison.candidateRatio
131
+ });
132
+ }
133
+ /**
134
+ * Formats the A/B's closing verdict line(s), matching the original harness's console output.
135
+ * @internal
136
+ */
137
+ export function formatRoutingAbVerdict(result) {
138
+ const a = formatCacheRatioPercent(result.withoutKeyWarmRatio);
139
+ const b = formatCacheRatioPercent(result.withKeyWarmRatio);
140
+ switch (result.verdict) {
141
+ case 'confirms':
142
+ return (`arm 1 warm ${a} -> arm 2 warm ${b}\n` +
143
+ 'CONFIRMS the prediction: the routing key materially improves the hit rate.');
144
+ case 'misses':
145
+ return (`arm 1 warm ${a} -> arm 2 warm ${b}\n` +
146
+ 'MISSES the prediction: arm 2 did not jump. The routing key is not buying what this ' +
147
+ 'change claims — revise the change or the claim, do not re-run for a better number. ' +
148
+ '(Re-run once with a fresh SALT first, to rule out a TTL-tainted reading.)');
149
+ case 'ambiguous':
150
+ return (`arm 1 warm ${a} -> arm 2 warm ${b}\n` +
151
+ 'AMBIGUOUS: arm 1 may have hit by routing luck. Re-run with a fresh SALT.');
152
+ case 'inconclusive':
153
+ return 'inconclusive: cache-hit ratio not computable for at least one arm.';
154
+ }
155
+ }
156
+ //# sourceMappingURL=promptCacheRoutingAbHarness.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"promptCacheRoutingAbHarness.js","sourceRoot":"","sources":["../../../src/packlets/ai-assist/promptCacheRoutingAbHarness.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 * Pure logic and injectable transport for `perf/promptCacheRoutingAb.js` — the A/B that measures\n * whether `IAiCacheRequest.cacheKey` actually improves the xAI cache-hit rate (design.md §2's\n * 2026-09-19 measurement note, and result.md's \"gating defect in C3\").\n *\n * @remarks\n * Extracted for the same reason as `promptCacheObservabilityHarness.ts`: the verdict this harness\n * prints (CONFIRMS / MISSES / AMBIGUOUS) \"has already been used to justify a merge,\" per this\n * stream's follow-up task, and had no test checking it classifies correctly.\n * @packageDocumentation\n */\n\nimport { fail, succeed, type Result } from '@fgv/ts-utils';\n\nimport { type IAiCacheRequest } from './cacheRequest';\nimport { type IProviderCompletionParams } from './completionClient';\nimport { type IAiCompletionResponse, type IAiProviderDescriptor } from './model';\nimport {\n type CacheHitComparisonVerdict,\n compareCacheHitRatios,\n computeCacheUsageRatio,\n formatCacheRatioPercent\n} from './promptCacheUsageMath';\nimport { type IAiCompletionUsage } from './usageTypes';\n\n/** Injected transport — only the network call needs a live key. @internal */\nexport interface IPromptCacheRoutingAbDeps {\n readonly callCompletion: (params: IProviderCompletionParams) => Promise<Result<IAiCompletionResponse>>;\n}\n\n/**\n * Builds one arm's stable system prefix. Ported verbatim from the harness's `stablePrefix(arm)`.\n * `salt` and `arm` both feed the text so the two arms — and two separate runs — never warm each\n * other's cache.\n * @internal\n */\nexport function buildRoutingAbPrefix(salt: string, arm: string): string {\n const lines: string[] = [];\n for (let i = 0; i < 400; i++) {\n lines.push(\n `Directive ${salt}-${arm}-${i}: when asked about topic ${i}, respond precisely and cite source ${i}-${\n i * 7\n }.`\n );\n }\n return `You are a careful assistant operating under the following fixed policy document.\\n\\n${lines.join(\n '\\n'\n )}`;\n}\n\n/**\n * One line describing a usage block. Ported from `describeUsage`, now computing the ratio via\n * {@link computeCacheUsageRatio} instead of an inline calculation that coalesced a missing\n * `cachedInputTokens` or `uncachedInputTokens` to `0` (see that function's remarks).\n * @internal\n */\nexport function describeRoutingAbUsage(usage: IAiCompletionUsage | undefined): string {\n if (usage === undefined) {\n return 'usage absent from response';\n }\n const { cachedInputTokens, uncachedInputTokens } = usage;\n const { totalInputTokens, cachedRatio } = computeCacheUsageRatio(usage);\n if (cachedRatio === undefined) {\n return `reports=${usage.reports} cached=${cachedInputTokens} uncached=${uncachedInputTokens} (ratio not computable)`;\n }\n return `reports=${\n usage.reports\n } cached=${cachedInputTokens} of ${totalInputTokens} input (${formatCacheRatioPercent(cachedRatio)})`;\n}\n\n/** One arm's cold-then-warm outcome. @internal */\nexport interface IPromptCacheRoutingAbArmResult {\n readonly label: string;\n readonly coldUsage: IAiCompletionUsage | undefined;\n readonly warmUsage: IAiCompletionUsage | undefined;\n}\n\n/**\n * Runs one arm: a cold call, then a warm call sharing the same {@link buildRoutingAbPrefix} system\n * prompt and differing only in the final user turn — optionally supplying `cacheKey`. Ported from\n * `runArm`, with the network call injected.\n * @internal\n */\nexport async function runPromptCacheRoutingAbArm(\n deps: IPromptCacheRoutingAbDeps,\n params: {\n readonly descriptor: IAiProviderDescriptor;\n readonly apiKey: string;\n readonly label: string;\n readonly salt: string;\n readonly arm: string;\n readonly cacheKey?: string;\n }\n): Promise<Result<IPromptCacheRoutingAbArmResult>> {\n const { descriptor, apiKey, label, salt, arm, cacheKey } = params;\n const system = buildRoutingAbPrefix(salt, arm);\n const cache: IAiCacheRequest | undefined = cacheKey !== undefined ? { cacheKey } : undefined;\n const call = (content: string): Promise<Result<IAiCompletionResponse>> =>\n deps.callCompletion({\n descriptor,\n apiKey,\n system,\n messages: [{ role: 'user', content }],\n cache\n });\n\n const cold = await call('In one sentence, what is directive 12 about?');\n if (cold.isFailure()) {\n return fail(`${label}: cold call failed: ${cold.message}`);\n }\n\n // Only the tail differs from the cold call — the prefix is byte-identical.\n const warm = await call('In one sentence, what is directive 99 about?');\n if (warm.isFailure()) {\n return fail(`${label}: warm call failed: ${warm.message}`);\n }\n\n return succeed({ label, coldUsage: cold.value.usage, warmUsage: warm.value.usage });\n}\n\n/** The full A/B: both arms' results and the routing-key verdict. @internal */\nexport interface IPromptCacheRoutingAbResult {\n readonly withoutKey: IPromptCacheRoutingAbArmResult;\n readonly withKey: IPromptCacheRoutingAbArmResult;\n /** Baseline = `withoutKey`'s warm call; candidate = `withKey`'s warm call. */\n readonly verdict: CacheHitComparisonVerdict;\n readonly withoutKeyWarmRatio: number | undefined;\n readonly withKeyWarmRatio: number | undefined;\n}\n\n/**\n * Runs both arms of the routing-key A/B and classifies the result.\n *\n * @remarks\n * The verdict compares each arm's **warm** call against the other's — not an arm's own cold vs.\n * warm call — because the question is whether the routing key changes the warm hit rate relative\n * to the no-key baseline, exactly as `promptCacheRoutingAb.js`'s original `main()` computed it\n * (`pct(withoutKey)` / `pct(withKey)`, both taken from each arm's warm response).\n * @internal\n */\nexport async function runPromptCacheRoutingAbHarness(\n deps: IPromptCacheRoutingAbDeps,\n params: {\n readonly descriptor: IAiProviderDescriptor;\n readonly apiKey: string;\n readonly salt: string;\n }\n): Promise<Result<IPromptCacheRoutingAbResult>> {\n const { descriptor, apiKey, salt } = params;\n\n const withoutKeyResult = await runPromptCacheRoutingAbArm(deps, {\n descriptor,\n apiKey,\n label: 'arm 1: WITHOUT cacheKey (behaviour before this change)',\n salt,\n arm: 'a'\n });\n if (withoutKeyResult.isFailure()) {\n return fail(withoutKeyResult.message);\n }\n\n const withKeyResult = await runPromptCacheRoutingAbArm(deps, {\n descriptor,\n apiKey,\n label: 'arm 2: WITH cacheKey (this change)',\n salt,\n arm: 'b',\n cacheKey: `routing-ab-${salt}`\n });\n if (withKeyResult.isFailure()) {\n return fail(withKeyResult.message);\n }\n\n const comparison = compareCacheHitRatios(withoutKeyResult.value.warmUsage, withKeyResult.value.warmUsage);\n return succeed({\n withoutKey: withoutKeyResult.value,\n withKey: withKeyResult.value,\n verdict: comparison.verdict,\n withoutKeyWarmRatio: comparison.baselineRatio,\n withKeyWarmRatio: comparison.candidateRatio\n });\n}\n\n/**\n * Formats the A/B's closing verdict line(s), matching the original harness's console output.\n * @internal\n */\nexport function formatRoutingAbVerdict(result: IPromptCacheRoutingAbResult): string {\n const a = formatCacheRatioPercent(result.withoutKeyWarmRatio);\n const b = formatCacheRatioPercent(result.withKeyWarmRatio);\n switch (result.verdict) {\n case 'confirms':\n return (\n `arm 1 warm ${a} -> arm 2 warm ${b}\\n` +\n 'CONFIRMS the prediction: the routing key materially improves the hit rate.'\n );\n case 'misses':\n return (\n `arm 1 warm ${a} -> arm 2 warm ${b}\\n` +\n 'MISSES the prediction: arm 2 did not jump. The routing key is not buying what this ' +\n 'change claims — revise the change or the claim, do not re-run for a better number. ' +\n '(Re-run once with a fresh SALT first, to rule out a TTL-tainted reading.)'\n );\n case 'ambiguous':\n return (\n `arm 1 warm ${a} -> arm 2 warm ${b}\\n` +\n 'AMBIGUOUS: arm 1 may have hit by routing luck. Re-run with a fresh SALT.'\n );\n case 'inconclusive':\n return 'inconclusive: cache-hit ratio not computable for at least one arm.';\n }\n}\n"]}
@@ -0,0 +1,124 @@
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
+ * Computes {@link ICacheUsageRatio} from a completion's usage block.
22
+ *
23
+ * @remarks
24
+ * The two harnesses this was extracted from each computed this ratio independently, inline, and
25
+ * disagreed with each other — and with this function — on edge cases that neither had a test for:
26
+ *
27
+ * - `promptCacheObservability.js`'s `summarizeUsage` divided by a `0` total without a guard,
28
+ * printing `NaN%` whenever a response reported `uncachedInputTokens: 0, cachedInputTokens: 0`.
29
+ * - `promptCacheRoutingAb.js`'s inline `pct()` coalesced a missing `cachedInputTokens` **or**
30
+ * `uncachedInputTokens` to `0` (`(u.cachedInputTokens ?? 0) + (u.uncachedInputTokens ?? 0)`,
31
+ * `100 * (u.cachedInputTokens ?? 0) / total`) — and which field was missing decided the
32
+ * direction of the fabrication: a missing `cachedInputTokens` fabricated a false low ratio
33
+ * (feeding a false `MISSES`), while a missing `uncachedInputTokens` fabricated a false **100%**
34
+ * (feeding a false `CONFIRMS`) — this is not a one-directional bug.
35
+ * - `promptCacheRoutingAb.js`'s `describeUsage` also special-cased a *genuinely reported*
36
+ * `cachedInputTokens: 0, uncachedInputTokens: 0` (both fields present) as `0.0%`, and the
37
+ * verdict's `pct()` scored that same input as a hard `0`, feeding `MISSES`. This function scores
38
+ * a real zero-input total as not computable, same as observability's zero-total case above —
39
+ * there were no bytes to have a ratio over, so `0.0%` claims more than the data supports.
40
+ *
41
+ * All three are fixed here rather than reproduced: this function returns `undefined` — never a
42
+ * fabricated number — whenever the ratio cannot honestly be computed, matching the same
43
+ * unknown-is-unknown rule the normalized {@link IAiCompletionUsage} type itself follows.
44
+ * @internal
45
+ */
46
+ export function computeCacheUsageRatio(usage) {
47
+ if (usage === undefined) {
48
+ return { totalInputTokens: undefined, cachedRatio: undefined };
49
+ }
50
+ const { cachedInputTokens, uncachedInputTokens } = usage;
51
+ if (cachedInputTokens === undefined || uncachedInputTokens === undefined) {
52
+ return { totalInputTokens: undefined, cachedRatio: undefined };
53
+ }
54
+ // A *present but out-of-range* component is not the same hazard as an absent one, and the
55
+ // absent case above does not cover it. A token count cannot be negative or non-finite, so one
56
+ // that is means the usage block is malformed — and computing anyway yields a ratio outside
57
+ // [0, 1] that this function's own contract promises cannot happen. That ratio then reaches
58
+ // `compareCacheHitRatios`, where a skewed baseline can fabricate a favourable verdict from an
59
+ // unremarkable candidate. Reachable from real wire data, not just hand-built input:
60
+ // `usageNormalization.ts` derives `uncachedInputTokens` as `promptTokens - cachedInputTokens`
61
+ // on both the Chat Completions and Responses routes with no guard, so any provider reporting
62
+ // `cached_tokens > prompt_tokens` produces exactly this shape.
63
+ if (!Number.isFinite(cachedInputTokens) ||
64
+ !Number.isFinite(uncachedInputTokens) ||
65
+ cachedInputTokens < 0 ||
66
+ uncachedInputTokens < 0) {
67
+ return { totalInputTokens: undefined, cachedRatio: undefined };
68
+ }
69
+ const totalInputTokens = cachedInputTokens + uncachedInputTokens;
70
+ if (totalInputTokens <= 0) {
71
+ return { totalInputTokens, cachedRatio: undefined };
72
+ }
73
+ return { totalInputTokens, cachedRatio: cachedInputTokens / totalInputTokens };
74
+ }
75
+ /**
76
+ * Formats a cache-hit ratio as a percentage string, or `'n/a'` when {@link computeCacheUsageRatio}
77
+ * could not compute one.
78
+ * @internal
79
+ */
80
+ export function formatCacheRatioPercent(ratio) {
81
+ return ratio === undefined ? 'n/a' : `${(ratio * 100).toFixed(1)}%`;
82
+ }
83
+ /** A candidate ratio at or below this percentage cannot count as a cache hit. @internal */
84
+ export const CACHE_HIT_FLOOR_PERCENT = 50;
85
+ /**
86
+ * The candidate ratio must clear the baseline ratio by at least this multiple to be attributed to
87
+ * something other than routing luck.
88
+ * @internal
89
+ */
90
+ export const CACHE_HIT_MIN_IMPROVEMENT_FACTOR = 5;
91
+ /**
92
+ * Classifies a baseline/candidate pair of usage blocks by their cache-hit ratio.
93
+ *
94
+ * @remarks
95
+ * Ported from `promptCacheRoutingAb.js`'s inline verdict —
96
+ * `if (b > 50 && b > a * 5) CONFIRMS; else if (b <= 50) MISSES; else AMBIGUOUS` — now driven off
97
+ * {@link computeCacheUsageRatio} rather than a hand-rolled `pct()` that coalesced missing fields
98
+ * to `0`. `'inconclusive'` is new: it is what an unclassifiable ratio (baseline or candidate
99
+ * `undefined`) reports now, in place of the original's silent `0`, which could otherwise produce
100
+ * a false `MISSES` verdict from data that never said "no cache hit" — it said nothing at all.
101
+ *
102
+ * The same classification also drives `promptCacheObservability.js`'s cold/warm comparison
103
+ * (baseline = cold call, candidate = warm call), which previously had no computed verdict of any
104
+ * kind — only two printed summaries and a static reminder for a human to judge by eye.
105
+ * @internal
106
+ */
107
+ export function compareCacheHitRatios(baseline, candidate) {
108
+ const baselineRatio = computeCacheUsageRatio(baseline).cachedRatio;
109
+ const candidateRatio = computeCacheUsageRatio(candidate).cachedRatio;
110
+ if (baselineRatio === undefined || candidateRatio === undefined) {
111
+ return { baselineRatio, candidateRatio, verdict: 'inconclusive' };
112
+ }
113
+ const baselinePercent = baselineRatio * 100;
114
+ const candidatePercent = candidateRatio * 100;
115
+ if (candidatePercent > CACHE_HIT_FLOOR_PERCENT &&
116
+ candidatePercent > baselinePercent * CACHE_HIT_MIN_IMPROVEMENT_FACTOR) {
117
+ return { baselineRatio, candidateRatio, verdict: 'confirms' };
118
+ }
119
+ if (candidatePercent <= CACHE_HIT_FLOOR_PERCENT) {
120
+ return { baselineRatio, candidateRatio, verdict: 'misses' };
121
+ }
122
+ return { baselineRatio, candidateRatio, verdict: 'ambiguous' };
123
+ }
124
+ //# sourceMappingURL=promptCacheUsageMath.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"promptCacheUsageMath.js","sourceRoot":"","sources":["../../../src/packlets/ai-assist/promptCacheUsageMath.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 * Cache-hit ratio arithmetic and cold/warm verdict classification shared by the two prompt-cache\n * measurement harnesses under `perf/` (`promptCacheObservability.js`,\n * `promptCacheRoutingAb.js`).\n *\n * @remarks\n * Extracted per design.md §8's addendum (\"Preferred remedy: keep `perf/` placement, add the\n * seam\") and `TESTING_GUIDELINES.md` § \"Measurement Harnesses\" — before this module existed,\n * both harnesses computed this math inline, disagreed with each other on how to treat missing or\n * zero-valued fields, and had no test of either version. See {@link computeCacheUsageRatio} and\n * {@link compareCacheHitRatios} for what each inline version did and what changed.\n * @packageDocumentation\n */\n\nimport { type IAiCompletionUsage } from './usageTypes';\n\n/**\n * A cache-hit ratio derived from one completion's usage block.\n * @internal\n */\nexport interface ICacheUsageRatio {\n /** `cachedInputTokens + uncachedInputTokens`, when both are known. `0` is a valid value. */\n readonly totalInputTokens: number | undefined;\n /**\n * `cachedInputTokens / totalInputTokens`, in `[0, 1]`.\n *\n * @remarks\n * `undefined` whenever the ratio is not a meaningful number: the usage block itself is absent,\n * either token count is unreported, or the total is `0` (there is no input to have a ratio\n * over). Per design.md's governing rule R-c, unknown is reported as unknown, never defaulted to\n * a number.\n */\n readonly cachedRatio: number | undefined;\n}\n\n/**\n * Computes {@link ICacheUsageRatio} from a completion's usage block.\n *\n * @remarks\n * The two harnesses this was extracted from each computed this ratio independently, inline, and\n * disagreed with each other — and with this function — on edge cases that neither had a test for:\n *\n * - `promptCacheObservability.js`'s `summarizeUsage` divided by a `0` total without a guard,\n * printing `NaN%` whenever a response reported `uncachedInputTokens: 0, cachedInputTokens: 0`.\n * - `promptCacheRoutingAb.js`'s inline `pct()` coalesced a missing `cachedInputTokens` **or**\n * `uncachedInputTokens` to `0` (`(u.cachedInputTokens ?? 0) + (u.uncachedInputTokens ?? 0)`,\n * `100 * (u.cachedInputTokens ?? 0) / total`) — and which field was missing decided the\n * direction of the fabrication: a missing `cachedInputTokens` fabricated a false low ratio\n * (feeding a false `MISSES`), while a missing `uncachedInputTokens` fabricated a false **100%**\n * (feeding a false `CONFIRMS`) — this is not a one-directional bug.\n * - `promptCacheRoutingAb.js`'s `describeUsage` also special-cased a *genuinely reported*\n * `cachedInputTokens: 0, uncachedInputTokens: 0` (both fields present) as `0.0%`, and the\n * verdict's `pct()` scored that same input as a hard `0`, feeding `MISSES`. This function scores\n * a real zero-input total as not computable, same as observability's zero-total case above —\n * there were no bytes to have a ratio over, so `0.0%` claims more than the data supports.\n *\n * All three are fixed here rather than reproduced: this function returns `undefined` — never a\n * fabricated number — whenever the ratio cannot honestly be computed, matching the same\n * unknown-is-unknown rule the normalized {@link IAiCompletionUsage} type itself follows.\n * @internal\n */\nexport function computeCacheUsageRatio(usage: IAiCompletionUsage | undefined): ICacheUsageRatio {\n if (usage === undefined) {\n return { totalInputTokens: undefined, cachedRatio: undefined };\n }\n const { cachedInputTokens, uncachedInputTokens } = usage;\n if (cachedInputTokens === undefined || uncachedInputTokens === undefined) {\n return { totalInputTokens: undefined, cachedRatio: undefined };\n }\n // A *present but out-of-range* component is not the same hazard as an absent one, and the\n // absent case above does not cover it. A token count cannot be negative or non-finite, so one\n // that is means the usage block is malformed — and computing anyway yields a ratio outside\n // [0, 1] that this function's own contract promises cannot happen. That ratio then reaches\n // `compareCacheHitRatios`, where a skewed baseline can fabricate a favourable verdict from an\n // unremarkable candidate. Reachable from real wire data, not just hand-built input:\n // `usageNormalization.ts` derives `uncachedInputTokens` as `promptTokens - cachedInputTokens`\n // on both the Chat Completions and Responses routes with no guard, so any provider reporting\n // `cached_tokens > prompt_tokens` produces exactly this shape.\n if (\n !Number.isFinite(cachedInputTokens) ||\n !Number.isFinite(uncachedInputTokens) ||\n cachedInputTokens < 0 ||\n uncachedInputTokens < 0\n ) {\n return { totalInputTokens: undefined, cachedRatio: undefined };\n }\n const totalInputTokens = cachedInputTokens + uncachedInputTokens;\n if (totalInputTokens <= 0) {\n return { totalInputTokens, cachedRatio: undefined };\n }\n return { totalInputTokens, cachedRatio: cachedInputTokens / totalInputTokens };\n}\n\n/**\n * Formats a cache-hit ratio as a percentage string, or `'n/a'` when {@link computeCacheUsageRatio}\n * could not compute one.\n * @internal\n */\nexport function formatCacheRatioPercent(ratio: number | undefined): string {\n return ratio === undefined ? 'n/a' : `${(ratio * 100).toFixed(1)}%`;\n}\n\n/**\n * Verdict for a baseline/candidate pair of cache-hit ratios.\n *\n * @remarks\n * `'inconclusive'` has no equivalent in the original inline logic — see {@link compareCacheHitRatios}.\n * @internal\n */\nexport type CacheHitComparisonVerdict = 'confirms' | 'misses' | 'ambiguous' | 'inconclusive';\n\n/** A candidate ratio at or below this percentage cannot count as a cache hit. @internal */\nexport const CACHE_HIT_FLOOR_PERCENT: number = 50;\n\n/**\n * The candidate ratio must clear the baseline ratio by at least this multiple to be attributed to\n * something other than routing luck.\n * @internal\n */\nexport const CACHE_HIT_MIN_IMPROVEMENT_FACTOR: number = 5;\n\n/** The outcome of comparing a baseline usage block against a candidate one. @internal */\nexport interface ICacheHitComparison {\n readonly baselineRatio: number | undefined;\n readonly candidateRatio: number | undefined;\n readonly verdict: CacheHitComparisonVerdict;\n}\n\n/**\n * Classifies a baseline/candidate pair of usage blocks by their cache-hit ratio.\n *\n * @remarks\n * Ported from `promptCacheRoutingAb.js`'s inline verdict —\n * `if (b > 50 && b > a * 5) CONFIRMS; else if (b <= 50) MISSES; else AMBIGUOUS` — now driven off\n * {@link computeCacheUsageRatio} rather than a hand-rolled `pct()` that coalesced missing fields\n * to `0`. `'inconclusive'` is new: it is what an unclassifiable ratio (baseline or candidate\n * `undefined`) reports now, in place of the original's silent `0`, which could otherwise produce\n * a false `MISSES` verdict from data that never said \"no cache hit\" — it said nothing at all.\n *\n * The same classification also drives `promptCacheObservability.js`'s cold/warm comparison\n * (baseline = cold call, candidate = warm call), which previously had no computed verdict of any\n * kind — only two printed summaries and a static reminder for a human to judge by eye.\n * @internal\n */\nexport function compareCacheHitRatios(\n baseline: IAiCompletionUsage | undefined,\n candidate: IAiCompletionUsage | undefined\n): ICacheHitComparison {\n const baselineRatio = computeCacheUsageRatio(baseline).cachedRatio;\n const candidateRatio = computeCacheUsageRatio(candidate).cachedRatio;\n if (baselineRatio === undefined || candidateRatio === undefined) {\n return { baselineRatio, candidateRatio, verdict: 'inconclusive' };\n }\n const baselinePercent = baselineRatio * 100;\n const candidatePercent = candidateRatio * 100;\n if (\n candidatePercent > CACHE_HIT_FLOOR_PERCENT &&\n candidatePercent > baselinePercent * CACHE_HIT_MIN_IMPROVEMENT_FACTOR\n ) {\n return { baselineRatio, candidateRatio, verdict: 'confirms' };\n }\n if (candidatePercent <= CACHE_HIT_FLOOR_PERCENT) {\n return { baselineRatio, candidateRatio, verdict: 'misses' };\n }\n return { baselineRatio, candidateRatio, verdict: 'ambiguous' };\n}\n"]}
@@ -42,8 +42,7 @@ const BUILTIN_PROVIDERS = [
42
42
  supportedTools: [],
43
43
  corsRestricted: false,
44
44
  streamingCorsRestricted: false,
45
- acceptsImageInput: false,
46
- thinkingMode: 'unsupported'
45
+ acceptsImageInput: false
47
46
  },
48
47
  {
49
48
  id: 'anthropic',
@@ -70,7 +69,6 @@ const BUILTIN_PROVIDERS = [
70
69
  corsRestricted: false,
71
70
  streamingCorsRestricted: false,
72
71
  acceptsImageInput: true,
73
- thinkingMode: 'optional',
74
72
  // Claude 5 family requires the adaptive thinking wire shape (thinking.type: 'adaptive' +
75
73
  // output_config.effort) and 400s on the legacy thinking.type: 'enabled' + budget_tokens
76
74
  // shape; see AiAssist.isAdaptiveThinkingModel.
@@ -105,10 +103,16 @@ const BUILTIN_PROVIDERS = [
105
103
  '@google-gemini:embedding': 'gemini-embedding-001' // NOT deprecated — aliased for uniformity only
106
104
  },
107
105
  supportedTools: ['web_search'],
106
+ // `generateContent` HTTP-400s (INVALID_ARGUMENT) when built-in grounding
107
+ // (`web_search` → `google_search`) and `function_declarations` are combined in
108
+ // one request. The only descriptor in the registry that declares an exclusion;
109
+ // keep it that way deliberately — a new entry here changes what a host gets
110
+ // back without the host changing anything, so it wants the same evidence a
111
+ // capability claim wants.
112
+ serverToolsExclusiveWithClientTools: ['web_search'],
108
113
  corsRestricted: false,
109
114
  streamingCorsRestricted: false,
110
115
  acceptsImageInput: true,
111
- thinkingMode: 'optional',
112
116
  // Gemini carries the constraint inside `generationConfig`
113
117
  // (responseMimeType + responseSchema), uniform across the family.
114
118
  structuredOutput: [{ modelPrefix: '', format: 'gemini-response-schema' }],
@@ -145,8 +149,7 @@ const BUILTIN_PROVIDERS = [
145
149
  supportedTools: [],
146
150
  corsRestricted: false,
147
151
  streamingCorsRestricted: false,
148
- acceptsImageInput: false,
149
- thinkingMode: 'unsupported'
152
+ acceptsImageInput: false
150
153
  },
151
154
  {
152
155
  id: 'mistral',
@@ -160,7 +163,6 @@ const BUILTIN_PROVIDERS = [
160
163
  corsRestricted: false,
161
164
  streamingCorsRestricted: false,
162
165
  acceptsImageInput: false,
163
- thinkingMode: 'unsupported',
164
166
  structuredOutput: [{ modelPrefix: '', format: 'openai-json-schema' }],
165
167
  embedding: [{ modelPrefix: '', format: 'openai-embeddings' }]
166
168
  },
@@ -176,7 +178,6 @@ const BUILTIN_PROVIDERS = [
176
178
  corsRestricted: false,
177
179
  streamingCorsRestricted: false,
178
180
  acceptsImageInput: false,
179
- thinkingMode: 'unsupported',
180
181
  embedding: [{ modelPrefix: '', format: 'openai-embeddings' }]
181
182
  },
182
183
  {
@@ -210,7 +211,6 @@ const BUILTIN_PROVIDERS = [
210
211
  corsRestricted: false,
211
212
  streamingCorsRestricted: false,
212
213
  acceptsImageInput: true,
213
- thinkingMode: 'optional',
214
214
  responsesOnlyModelPrefixes: ['gpt-5.5-pro'],
215
215
  // Declared once for the whole line. The Chat-Completions-vs-Responses split is
216
216
  // NOT declared here on purpose: the route depends on whether the call carries
@@ -262,7 +262,6 @@ const BUILTIN_PROVIDERS = [
262
262
  corsRestricted: false,
263
263
  streamingCorsRestricted: false,
264
264
  acceptsImageInput: false,
265
- thinkingMode: 'unsupported',
266
265
  embedding: [{ modelPrefix: '', format: 'openai-embeddings' }]
267
266
  },
268
267
  {
@@ -287,7 +286,6 @@ const BUILTIN_PROVIDERS = [
287
286
  corsRestricted: true,
288
287
  streamingCorsRestricted: true,
289
288
  acceptsImageInput: true,
290
- thinkingMode: 'optional',
291
289
  structuredOutput: [{ modelPrefix: '', format: 'openai-json-schema' }],
292
290
  imageGeneration: [
293
291
  {