summaryrefslogtreecommitdiffhomepage
path: root/lib/dispatch/adapter/claude/request_builder.rb
blob: 716394694d7caadeff98aaa7da695d3c0bebc4c1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
# 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<Dispatch::Adapter::Message>]
        # @param system   [String, Array<Hash>, nil]
        # @param tools    [Array<ToolDefinition, Hash>, 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