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
|