# frozen_string_literal: true module Dispatch module Adapter class Claude < Base module RequestBuilder # Applies Anthropic prompt-caching breakpoints to an already-assembled # request-params hash, then enforces the 4-breakpoint cap and the # cache-control TTL ordering rule. # # Placement order (matches oh-my-pi `applyPromptCaching`): # 1. last tool definition # 2. last system block # 3. penultimate user message — last text block (or last block) # 4. last user message — last text block (or last block) # # After placement: # - `enforceCacheControlLimit`: strip excess markers above 4. # - `normalizeCacheControlTtlOrdering`: once a non-"1h" block is # seen in (tools→system→messages) order, downgrade any subsequent # "1h" block to plain ephemeral. # # Call `CacheControl.apply(params, cache_retention:, base_url:)` after # fully assembling params[:tools], params[:system], params[:messages]. module CacheControl MAX_BREAKPOINTS = 4 module_function # Main entry point. Mutates params in-place. # # @param params [Hash] assembled request params # @param cache_retention [Symbol, nil] :long | :short | :none | nil # @param base_url [String] def apply(params, cache_retention: nil, base_url: "https://api.anthropic.com") cc = resolve_cache_control(cache_retention, base_url) # Auto-place markers only when we have a cache_control descriptor # AND the caller hasn't already placed markers on message blocks. place_markers(params, cc) if cc && !caller_placed_markers?(params[:messages]) # Always enforce the breakpoint cap and TTL ordering rule, even # when auto-placement was skipped — the caller may have pre-set # markers via ToolDefinition#cache_control or TextBlock#cache_control. enforce_limit(params) normalize_ttl_ordering(params) end # ── Cache-control resolution ───────────────────────────────────────── # Translate a cache_retention symbol to an Anthropic cache_control hash. # Returns nil when caching is disabled (:none). def resolve_cache_control(cache_retention, base_url) retention = cache_retention || :short return nil if retention == :none if retention == :long && base_url.to_s.include?("api.anthropic.com") { "type" => "ephemeral", "ttl" => "1h" } else { "type" => "ephemeral", "ttl" => "5m" } end end def place_markers(params, cc) breakpoints = 0 # 1. Last tool tools = params[:tools] if tools.is_a?(Array) && !tools.empty? set_cache_control!(tools.last, cc) breakpoints += 1 end return if breakpoints >= MAX_BREAKPOINTS # 2. Last system block system = params[:system] if system.is_a?(Array) && !system.empty? set_cache_control!(system.last, cc) breakpoints += 1 end return if breakpoints >= MAX_BREAKPOINTS messages = Array(params[:messages]) user_indexes = messages.each_index.select { |i| messages[i][:role] == "user" } # 3. Penultimate user message — last text block if user_indexes.length >= 2 penultimate = messages[user_indexes[-2]] placed = apply_to_last_text_block?(penultimate, cc) breakpoints += 1 if placed end return if breakpoints >= MAX_BREAKPOINTS # 4. Last user message — last text block return unless user_indexes.length >= 1 last_user = messages[user_indexes[-1]] apply_to_last_text_block?(last_user, cc) end # Apply cache_control to the last text block (or last block as fallback) # of a single message. Converts String content to [{type:"text",text:…}]. # Returns true if a marker was placed. def apply_to_last_text_block?(msg, cc) content = msg[:content] if content.is_a?(String) # Convert string content to a single text block array block = { type: "text", text: content } set_cache_control!(block, cc) msg[:content] = [block] return true end if content.is_a?(Array) && !content.empty? # Find last text block; fall back to absolute last block idx = content.rindex { |b| b.is_a?(Hash) && block_type(b) == "text" } target = idx ? content[idx] : content.last set_cache_control!(target, cc) if target.is_a?(Hash) return true end false end # Returns true if any message's content array already has a # cache_control marker (meaning the caller is in charge of caching). def caller_placed_markers?(messages) return false unless messages.is_a?(Array) messages.any? do |msg| next false unless msg[:content].is_a?(Array) msg[:content].any? { |b| b.is_a?(Hash) && cache_control_present?(b) } end end # ── Enforce 4-breakpoint cap ───────────────────────────────────────── def enforce_limit(params) total = count_breakpoints(params) return if total <= MAX_BREAKPOINTS excess = { value: total - MAX_BREAKPOINTS } system_blocks = Array(params[:system]) tool_blocks = Array(params[:tools]) messages = Array(params[:messages]) last_system_idx = last_marked_index(system_blocks) last_tool_idx = last_marked_index(tool_blocks) # 1. Strip system blocks, but preserve the last one strip_except_index(system_blocks, last_system_idx, excess) unless system_blocks.empty? return if excess[:value] <= 0 # 2. Strip tool blocks, but preserve the last one strip_except_index(tool_blocks, last_tool_idx, excess) unless tool_blocks.empty? return if excess[:value] <= 0 # 3. Strip message content blocks (in order) strip_message_markers(messages, excess) return if excess[:value] <= 0 # 4. Strip all remaining system markers strip_all_marked(system_blocks, excess) return if excess[:value] <= 0 # 5. Strip all remaining tool markers strip_all_marked(tool_blocks, excess) end def count_breakpoints(params) total = 0 Array(params[:tools]).each { |b| total += 1 if b.is_a?(Hash) && cache_control_present?(b) } Array(params[:system]).each { |b| total += 1 if b.is_a?(Hash) && cache_control_present?(b) } Array(params[:messages]).each do |msg| next unless msg[:content].is_a?(Array) msg[:content].each { |b| total += 1 if b.is_a?(Hash) && cache_control_present?(b) } end total end def last_marked_index(blocks) blocks.rindex { |b| b.is_a?(Hash) && cache_control_present?(b) } || -1 end def strip_except_index(blocks, preserve_idx, excess) blocks.each_with_index do |b, idx| break if excess[:value] <= 0 next if idx == preserve_idx next unless b.is_a?(Hash) && cache_control_present?(b) delete_cache_control!(b) excess[:value] -= 1 end end def strip_all_marked(blocks, excess) blocks.each do |b| break if excess[:value] <= 0 next unless b.is_a?(Hash) && cache_control_present?(b) delete_cache_control!(b) excess[:value] -= 1 end end def strip_message_markers(messages, excess) messages.each do |msg| break if excess[:value] <= 0 next unless msg[:content].is_a?(Array) msg[:content].each do |b| break if excess[:value] <= 0 next unless b.is_a?(Hash) && cache_control_present?(b) delete_cache_control!(b) excess[:value] -= 1 end end end # ── Normalize TTL ordering ─────────────────────────────────────────── # # Walk tools → system → messages in order. # Once a block with a non-"1h" ttl (including plain ephemeral with no # ttl) is seen, all subsequent "1h" blocks are downgraded by deleting # their ttl field (resulting in plain {type: "ephemeral"}). def normalize_ttl_ordering(params) seen_non_one_hour = { value: false } Array(params[:tools]).each { |b| normalize_block_ttl(b, seen_non_one_hour) } Array(params[:system]).each { |b| normalize_block_ttl(b, seen_non_one_hour) } Array(params[:messages]).each do |msg| next unless msg[:content].is_a?(Array) msg[:content].each { |b| normalize_block_ttl(b, seen_non_one_hour) if b.is_a?(Hash) } end end def normalize_block_ttl(block, seen_non_one_hour) return unless block.is_a?(Hash) cc = get_cache_control(block) return unless cc.is_a?(Hash) ttl = cc[:ttl] || cc["ttl"] if ttl != "1h" seen_non_one_hour[:value] = true return end # This block has ttl: "1h" — downgrade if a non-"1h" was seen before return unless seen_non_one_hour[:value] cc.delete(:ttl) cc.delete("ttl") end # ── Low-level helpers ──────────────────────────────────────────────── # Set cache_control on a block hash, using whichever key type the # hash already uses (symbol or string). def set_cache_control!(hash, cc) if hash.any? { |k, _| k.is_a?(Symbol) } hash[:cache_control] = cc else hash["cache_control"] = cc end end # Delete cache_control from a block hash (both key types). def delete_cache_control!(hash) hash.delete(:cache_control) hash.delete("cache_control") end # True if the block hash has a cache_control entry (either key type). def cache_control_present?(hash) hash.key?(:cache_control) || hash.key?("cache_control") end # Retrieve the cache_control value from a block hash. def get_cache_control(hash) hash[:cache_control] || hash["cache_control"] end # Return the "type" of a block hash regardless of key style. def block_type(hash) (hash[:type] || hash["type"]).to_s end end end end end end