# frozen_string_literal: true require_relative "request_builder/messages" require_relative "request_builder/tools" require_relative "request_builder/cache_control" require_relative "request_builder/thinking" module Dispatch module Adapter class Claude < Base # Orchestrates all request-builder sub-modules into a single # `MessageCreateParamsStreaming`-shaped Hash ready for HTTP serialization. # # Calling order mirrors oh-my-pi `buildParams`: # 1. Messages.build — convert interface messages to wire format # 2. Tools.build — convert tool definitions to wire format # 3. Base body assembly — model, messages, max_tokens, stream # 4. Sampling params — drop top_p/top_k on Opus 4.7+ # 5. Tool list added — body[:tools] # 6. tool_choice — wire-format, apply proxy_ prefix for OAuth # 7. Thinking.apply — sets thinking / output_config; also runs # disableThinkingIfToolChoiceForced and # ensureMaxTokensForThinking # 8. Metadata user_id — via Cloaking.resolve_user_id # 9. System blocks — via Cloaking.build_system_blocks (billing # payload = snapshot of body before system) # 10. CacheControl.apply — auto-place markers, enforce 4-breakpoint # cap, normalize TTL ordering module RequestBuilder module_function # Default max_tokens when none is provided: 1/3 of model max, or # this value if the pricing table has no entry for the model. FALLBACK_MAX_TOKENS = 8192 DEFAULT_MAX_TOKENS_DIVISOR = 3 # ── Public entry point ──────────────────────────────────────────────── # Build the complete Anthropic `MessageCreateParamsStreaming` hash. # # @param model_id [String] # @param messages [Array] # @param system [String, Array, nil] # @param tools [Array, nil] # @param is_oauth [Boolean] # @param base_url [String] # @param stream [Boolean] # @param max_tokens [Integer, nil] # @param thinking [String, Hash, nil] # @param tool_choice [Symbol, Hash, nil] # @param cache_retention [Symbol, nil] :short | :long | :none | nil # @param metadata [Hash, nil] # @param disable_strict_tools [Boolean] # @return [Hash] def build( model_id:, messages:, system:, tools:, is_oauth:, base_url:, stream: true, max_tokens: nil, thinking: nil, tool_choice: nil, cache_retention: nil, metadata: nil, disable_strict_tools: false ) # ── 1. Model info (vision support) ───────────────────────────────── model_info = ModelCatalog.build(model_id) # ── 2. Messages ──────────────────────────────────────────────────── model_messages = Messages.build( Array(messages), model_info: model_info, is_oauth: is_oauth ) # ── 3. Tools ─────────────────────────────────────────────────────── tools_wire = Tools.build( Array(tools), is_oauth: is_oauth, disable_strict: disable_strict_tools ) # ── 4. Base body ─────────────────────────────────────────────────── body = { model: model_id, messages: model_messages, max_tokens: resolve_max_tokens(max_tokens, model_id), stream: stream } # ── 5. Sampling params — drop top_p / top_k on Opus 4.7+ ────────── drop_sampling_if_restricted!(body, model_id) # ── 6. Add tools ─────────────────────────────────────────────────── body[:tools] = tools_wire unless tools_wire.empty? # ── 7. tool_choice ───────────────────────────────────────────────── wire_tc = build_tool_choice(tool_choice, is_oauth: is_oauth) body[:tool_choice] = wire_tc if wire_tc # ── 8. Thinking / output_config ──────────────────────────────────── # Thinking.apply also runs disableThinkingIfToolChoiceForced and # ensureMaxTokensForThinking internally. Thinking.apply( body, model_id: model_id, thinking: thinking, tool_choice: tool_choice, max_output_tokens: PricingTable.max_output_tokens(model_id) ) # ── 9. metadata.user_id ──────────────────────────────────────────── provided_uid = (metadata[:user_id] || metadata["user_id"] if metadata.is_a?(Hash)) resolved_uid = Cloaking.resolve_user_id(provided_uid, is_oauth) body[:metadata] = { user_id: resolved_uid } if resolved_uid # ── 10. System blocks ────────────────────────────────────────────── # Billing payload = snapshot of body as it stands now (before :system # is added), which is what oh-my-pi passes as billingPayload. billing_payload = is_oauth ? body.dup : nil system_blocks = Cloaking.build_system_blocks( system, is_oauth: is_oauth, model_id: model_id, billing_payload: billing_payload ) body[:system] = system_blocks if system_blocks # ── 11. Cache-control (also enforces 4-breakpoint cap + TTL order) ─ CacheControl.apply(body, cache_retention: cache_retention, base_url: base_url) body end # Resolve max_tokens: use caller's value if positive, otherwise derive # a sensible default from the pricing table (1/3 of model max). def resolve_max_tokens(max_tokens, model_id) return max_tokens.to_i if max_tokens.is_a?(Integer) && max_tokens.positive? return max_tokens.to_i if max_tokens.is_a?(Numeric) && max_tokens.positive? model_max = PricingTable.max_output_tokens(model_id) || FALLBACK_MAX_TOKENS divisor = DEFAULT_MAX_TOKENS_DIVISOR derived = (model_max / divisor).to_i derived.positive? ? derived : FALLBACK_MAX_TOKENS end # Remove top_p / top_k for Opus 4.7+ which rejects non-default # sampling parameters with a 400 error. def drop_sampling_if_restricted!(body, model_id) return unless opus_47_plus?(model_id) body.delete(:top_p) body.delete(:top_k) body.delete("top_p") body.delete("top_k") end # Returns true for claude-opus-4.7+ (major.minor ≥ 4.7). # Handles path-prefixed IDs like "anthropic.claude-opus-4-7". def opus_47_plus?(model_id) id = model_id.to_s id = id[(id.rindex("/") + 1)..] if id.include?("/") m = /claude-opus-(\d+)[.-](\d+)/.match(id) return false unless m major = m[1].to_i minor = m[2].to_i major > 4 || (major == 4 && minor >= 7) end # Convert the interface's `tool_choice` kwarg to the Anthropic wire format. # # Interface values: # :auto | :any | :none → { type: "auto" } etc. # { type: :tool, name: "bash" } → { type: "tool", name: "bash" } # (name is proxy_-prefixed when OAuth) # # Returns nil when tool_choice is nil or unrecognised. def build_tool_choice(tool_choice, is_oauth:) case tool_choice when Symbol { type: tool_choice.to_s } when Hash h = tool_choice.transform_keys(&:to_sym) type = h[:type].to_s name = h[:name] wire = { type: type } if name wire_name = name.to_s wire_name = Cloaking.apply_prefix(wire_name) if is_oauth wire[:name] = wire_name end wire end end end end end end