# frozen_string_literal: true module Dispatch module Adapter class Claude < Base # Parses the `anthropic-ratelimit-unified-*` HTTP response headers that # Anthropic sends back on every API call. # # These headers are the most up-to-date quota signal available — they are # attached to every streaming and non-streaming response without any # additional requests. The per-window utilization values are floats in # the range 0.0–1.0 (e.g. 0.073 = 7.3% used). # # Example headers: # # anthropic-ratelimit-unified-status: allowed # anthropic-ratelimit-unified-representative-claim: five_hour # anthropic-ratelimit-unified-fallback: available # anthropic-ratelimit-unified-fallback-percentage: 0.5 # anthropic-ratelimit-unified-overage-status: rejected # anthropic-ratelimit-unified-5h-status: allowed # anthropic-ratelimit-unified-5h-reset: 1774933200 # anthropic-ratelimit-unified-5h-utilization: 0.07 # anthropic-ratelimit-unified-5h-surpassed-threshold: false # anthropic-ratelimit-unified-7d-status: allowed # anthropic-ratelimit-unified-7d-utilization: 0.53 # anthropic-ratelimit-unified-7d-reset: 1774933200 # anthropic-ratelimit-unified-7d_sonnet-status: allowed # anthropic-ratelimit-unified-7d_sonnet-utilization: 0.12 # anthropic-ratelimit-unified-7d_opus-utilization: 0.34 # # Reference: reverse-engineered by the community via proxy captures and # subsequently confirmed via Anthropic's leaked source map (2026-03-31). module RateLimitHeaders # Per-window information. WindowInfo = Struct.new( :window_id, # String — "5h", "7d", "7d_sonnet", "7d_opus" :utilization, # Float 0.0–1.0 — exact fraction of quota consumed :status, # String — "allowed" | "exceeded" | "rate_limited" :reset_at, # Time | nil — when this window resets :surpassed_threshold, # Boolean | nil — true once the quota limit is crossed keyword_init: true ) # Top-level summary of all rate-limit windows on a single response. Info = Struct.new( :status, # String — overall: "allowed" | "exceeded" | "rate_limited" :representative_claim, # String | nil — which window is the binding constraint :fallback, # String | nil — "available" | "unavailable" :fallback_percentage, # Float | nil — e.g. 0.5 :overage_status, # String | nil — "approved" | "rejected" :windows, # Hash{String => WindowInfo} — keyed by window_id :captured_at, # Time — when these headers were read :raw_headers, # Hash{String => String} — all unified headers, for debugging keyword_init: true ) do # Convenience: utilization for the representative (binding) window. def binding_utilization return nil unless representative_claim win = windows[representative_claim] || windows[representative_claim.tr("-", "_")] win&.utilization end # True iff any window reports exceeded or rate_limited status. def limited? status == "exceeded" || status == "rate_limited" || windows.any? { |_, w| w.status != "allowed" } end # Human-readable one-liner for debugging. def summary parts = windows.map do |wid, w| pct = w.utilization ? format("%.1f%%", w.utilization * 100) : "?" reset_str = w.reset_at ? " (resets #{w.reset_at.strftime("%H:%M")})" : "" "#{wid}: #{pct}#{reset_str} [#{w.status}]" end parts.unshift("representative=#{representative_claim}") if representative_claim parts.unshift("status=#{status}") if status parts.join(", ") end # Serializable hash suitable for JSON logging. def to_log_hash { captured_at: captured_at&.iso8601(3), status: status, representative_claim: representative_claim, fallback: fallback, fallback_percentage: fallback_percentage, overage_status: overage_status, windows: windows.transform_values do |w| { utilization: w.utilization, status: w.status, reset_at: w.reset_at&.iso8601, surpassed_threshold: w.surpassed_threshold } end, raw_headers: raw_headers } end end # Header prefix for all unified rate-limit headers. HEADER_PREFIX = "anthropic-ratelimit-unified-" # Known window IDs in the headers. WINDOW_IDS = %w[5h 7d 7d_sonnet 7d_opus].freeze module_function # Parse unified rate-limit headers from a Net::HTTP response. # # @param response [Net::HTTPResponse] (or any object that responds to #[]) # @return [Info, nil] nil if no unified headers are present def parse(response) raw = extract_raw_headers(response) return nil if raw.empty? windows = {} WINDOW_IDS.each do |wid| win = parse_window(raw, wid) windows[wid] = win if win end Info.new( status: raw["status"], representative_claim: raw["representative-claim"], fallback: raw["fallback"], fallback_percentage: raw["fallback-percentage"]&.then(&:to_f), overage_status: raw["overage-status"], windows: windows, captured_at: Time.now, raw_headers: raw ) end # ── Private helpers ───────────────────────────────────────────────── # Extract all headers whose name starts with the unified prefix. # Returns a Hash with the prefix stripped from the key. def extract_raw_headers(response) result = {} # Net::HTTPResponse supports each_header (yields lowercase keys) if response.respond_to?(:each_header) response.each_header do |name, value| next unless name.start_with?(HEADER_PREFIX) short_key = name[(HEADER_PREFIX.length)..] result[short_key] = value end elsif response.respond_to?(:to_hash) # Fallback for stub/mock objects response.to_hash.each do |name, values| downcased = name.downcase next unless downcased.start_with?(HEADER_PREFIX) short_key = downcased[(HEADER_PREFIX.length)..] result[short_key] = Array(values).first.to_s end end result end # Parse per-window fields from the raw header map. # Window keys use hyphens in headers but we normalise to match WINDOW_IDS # which use underscores for compound names (7d_sonnet, 7d_opus). def parse_window(raw, wid) # Header keys use hyphens; WINDOW_IDS use underscores for compound names. hyphen_id = wid.tr("_", "-") utilization = raw["#{hyphen_id}-utilization"]&.then(&:to_f) status = raw["#{hyphen_id}-status"] reset_epoch = raw["#{hyphen_id}-reset"] surpassed = raw["#{hyphen_id}-surpassed-threshold"] # Also check underscore form (some headers use them for 7d_sonnet etc.) if utilization.nil? && hyphen_id != wid utilization ||= raw["#{wid}-utilization"]&.then(&:to_f) status ||= raw["#{wid}-status"] reset_epoch ||= raw["#{wid}-reset"] surpassed ||= raw["#{wid}-surpassed-threshold"] end # Skip the window entirely if there are no keys for it. return nil if utilization.nil? && status.nil? reset_at = reset_epoch ? Time.at(reset_epoch.to_i) : nil surpassed_bool = surpassed.nil? ? nil : (surpassed.downcase == "true") WindowInfo.new( window_id: wid, utilization: utilization, status: status || "unknown", reset_at: reset_at, surpassed_threshold: surpassed_bool ) end end end end end