summaryrefslogtreecommitdiffhomepage
path: root/spec/dispatch/adapter/claude/request_builder/thinking_spec.rb
blob: df8bba27e0a4dc913bbeff1912ba6d0b574a5d14 (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
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
# frozen_string_literal: true

RSpec.describe Dispatch::Adapter::Claude::RequestBuilder::Thinking do
  # Shorthand: build params and apply thinking config.
  def apply(model_id:, thinking:, tool_choice: nil,
            max_tokens: nil, max_output_tokens: nil)
    params = {}
    params[:max_tokens] = max_tokens if max_tokens
    described_class.apply(
      params,
      model_id:,
      thinking:,
      tool_choice:,
      max_output_tokens:
    )
    params
  end

  # ── nil / false → no-op ──────────────────────────────────────────────────

  describe "when thinking is nil or false" do
    it "does not add thinking or output_config keys for nil" do
      params = apply(model_id: "claude-opus-4-7", thinking: nil)
      expect(params).not_to have_key(:thinking)
      expect(params).not_to have_key(:output_config)
    end

    it "does not add thinking or output_config keys for false" do
      params = apply(model_id: "claude-opus-4-7", thinking: false)
      expect(params).not_to have_key(:thinking)
      expect(params).not_to have_key(:output_config)
    end
  end

  # ── Opus 4.7 adaptive with display ───────────────────────────────────────

  describe "Opus 4.7 (adaptive + display: summarized)" do
    let(:model_id) { "claude-opus-4-7" }

    it 'sets thinking: {type: "adaptive", display: "summarized"} for effort string "high"' do
      params = apply(model_id:, thinking: "high")
      expect(params[:thinking]).to eq({ type: "adaptive", display: "summarized" })
    end

    it "sets output_config.effort to the provided effort level" do
      params = apply(model_id:, thinking: "high")
      expect(params[:output_config]).to eq({ effort: "high" })
    end

    it 'sets effort "low" when thinking: "low"' do
      params = apply(model_id:, thinking: "low")
      expect(params[:output_config]).to eq({ effort: "low" })
    end

    it 'sets effort "medium" when thinking: "medium"' do
      params = apply(model_id:, thinking: "medium")
      expect(params[:output_config]).to eq({ effort: "medium" })
    end

    it 'sets effort "max" when thinking: "max"' do
      params = apply(model_id:, thinking: "max")
      expect(params[:output_config]).to eq({ effort: "max" })
    end

    it 'sets effort "xhigh" when thinking: "xhigh"' do
      params = apply(model_id:, thinking: "xhigh")
      expect(params[:output_config]).to eq({ effort: "xhigh" })
    end

    it "also works with the dated model ID claude-opus-4-7-20251018" do
      params = apply(model_id: "claude-opus-4-7-20251018", thinking: "high")
      expect(params[:thinking]).to eq({ type: "adaptive", display: "summarized" })
      expect(params[:output_config]).to eq({ effort: "high" })
    end

    it "does not set output_config when thinking kwarg is unrecognised junk" do
      params = apply(model_id:, thinking: "turbo")
      expect(params[:thinking]).to eq({ type: "adaptive", display: "summarized" })
      expect(params).not_to have_key(:output_config)
    end
  end

  # ── Opus 4.6 adaptive without display ────────────────────────────────────

  describe "Opus 4.6 (adaptive, no display)" do
    let(:model_id) { "claude-opus-4-6" }

    it 'sets thinking: {type: "adaptive"} without a display key' do
      params = apply(model_id:, thinking: "high")
      expect(params[:thinking]).to eq({ type: "adaptive" })
      expect(params[:thinking]).not_to have_key(:display)
    end

    it "sets output_config.effort = 'high'" do
      params = apply(model_id:, thinking: "high")
      expect(params[:output_config]).to eq({ effort: "high" })
    end
  end

  # ── Sonnet 4.6 adaptive without display ──────────────────────────────────

  describe "Sonnet 4.6 (adaptive, no display)" do
    let(:model_id) { "claude-sonnet-4-6" }

    it 'sets thinking: {type: "adaptive"} without a display key' do
      params = apply(model_id:, thinking: "medium")
      expect(params[:thinking]).to eq({ type: "adaptive" })
      expect(params[:thinking]).not_to have_key(:display)
    end

    it "sets output_config.effort = 'medium'" do
      params = apply(model_id:, thinking: "medium")
      expect(params[:output_config]).to eq({ effort: "medium" })
    end
  end

  # ── Sonnet 4.5 and older → enabled mode ──────────────────────────────────

  describe "Sonnet 4.5 (enabled mode)" do
    let(:model_id) { "claude-sonnet-4-5" }

    context "with a Hash {type: :enabled, budget_tokens: 8000}" do
      let(:thinking) { { type: :enabled, budget_tokens: 8000 } }

      it 'sets thinking: {type: "enabled", budget_tokens: 8000}' do
        params = apply(model_id:, thinking:)
        expect(params[:thinking]).to eq({ type: "enabled", budget_tokens: 8000 })
      end

      it "does not set output_config" do
        params = apply(model_id:, thinking:)
        expect(params).not_to have_key(:output_config)
      end

      it "raises max_tokens to at least budget_tokens + OUTPUT_FALLBACK_BUFFER" do
        params = apply(model_id:, thinking:, max_tokens: 100)
        expected = 8000 + described_class::OUTPUT_FALLBACK_BUFFER
        expect(params[:max_tokens]).to eq(expected)
      end

      it "does not lower an already-sufficient max_tokens" do
        big = 8000 + described_class::OUTPUT_FALLBACK_BUFFER + 1000
        params = apply(model_id:, thinking:, max_tokens: big)
        expect(params[:max_tokens]).to eq(big)
      end

      it "clamps raised max_tokens to max_output_tokens when provided" do
        # max_output_tokens = 9000 < budget + buffer = 12096
        params = apply(model_id:, thinking: { type: :enabled, budget_tokens: 8000 },
                       max_tokens: 100, max_output_tokens: 9000)
        expect(params[:max_tokens]).to eq(9000)
      end
    end

    context "with a string effort level (unusual for older models)" do
      it 'maps effort string to a proper budget: "high" => 10_000 tokens' do
        params = apply(model_id:, thinking: "high")
        expect(params[:thinking]).to eq({ type: "enabled", budget_tokens: 10_000 })
      end

      it 'maps "low" => 1_024 tokens' do
        params = apply(model_id:, thinking: "low")
        expect(params[:thinking]).to eq({ type: "enabled", budget_tokens: 1_024 })
      end

      it 'maps "medium" => 4_000 tokens' do
        params = apply(model_id:, thinking: "medium")
        expect(params[:thinking]).to eq({ type: "enabled", budget_tokens: 4_000 })
      end

      it 'maps unknown string to the "high" fallback budget (10_000)' do
        params = apply(model_id:, thinking: "superduper")
        expect(params[:thinking]).to eq({ type: "enabled", budget_tokens: 10_000 })
      end
    end
  end

  # ── Opus 4.5 (enabled mode, another older model) ──────────────────────────

  describe "Opus 4.5 (enabled mode)" do
    it "uses enabled mode for claude-opus-4-5" do
      params = apply(model_id: "claude-opus-4-5", thinking: { type: :enabled, budget_tokens: 2000 })
      expect(params[:thinking][:type]).to eq("enabled")
      expect(params[:thinking][:budget_tokens]).to eq(2000)
    end
  end

  # ── max_tokens guard: zero budget_tokens ──────────────────────────────────

  describe "max_tokens guard when budget_tokens is 0" do
    it "does NOT modify max_tokens when budget_tokens is 0" do
      params = apply(
        model_id: "claude-sonnet-4-5",
        thinking: { type: :enabled, budget_tokens: 0 },
        max_tokens: 100
      )
      expect(params[:max_tokens]).to eq(100)
    end
  end

  # ── tool_choice: :any strips thinking ────────────────────────────────────

  describe "tool_choice: :any strips thinking and output_config" do
    it "removes thinking and output_config for tool_choice: :any" do
      params = apply(model_id: "claude-opus-4-7", thinking: "high", tool_choice: :any)
      expect(params).not_to have_key(:thinking)
      expect(params).not_to have_key(:output_config)
    end

    it 'removes thinking and output_config for tool_choice: {type: :tool, name: "edit"}' do
      params = apply(model_id: "claude-opus-4-7", thinking: "high",
                     tool_choice: { type: :tool, name: "edit" })
      expect(params).not_to have_key(:thinking)
      expect(params).not_to have_key(:output_config)
    end

    it 'removes thinking for tool_choice: {type: "any"}' do
      params = apply(model_id: "claude-opus-4-7", thinking: "high",
                     tool_choice: { type: "any" })
      expect(params).not_to have_key(:thinking)
    end

    it "does NOT remove thinking for tool_choice: :auto" do
      params = apply(model_id: "claude-opus-4-7", thinking: "high", tool_choice: :auto)
      expect(params).to have_key(:thinking)
    end

    it "does NOT remove thinking for tool_choice: :none" do
      params = apply(model_id: "claude-opus-4-7", thinking: "high", tool_choice: :none)
      expect(params).to have_key(:thinking)
    end
  end

  # ── Hash thinking kwarg with effort key ───────────────────────────────────

  describe "Hash thinking kwarg with effort: key (adaptive model)" do
    it "extracts effort from the hash and sets output_config" do
      params = apply(model_id: "claude-opus-4-7",
                     thinking: { effort: "medium" })
      expect(params[:output_config]).to eq({ effort: "medium" })
    end

    it "ignores effort from hash when value is unrecognised" do
      params = apply(model_id: "claude-opus-4-7",
                     thinking: { effort: "extreme" })
      expect(params).not_to have_key(:output_config)
    end
  end

  # ── OUTPUT_FALLBACK_BUFFER constant ──────────────────────────────────────

  describe "OUTPUT_FALLBACK_BUFFER" do
    it "equals 4096" do
      expect(described_class::OUTPUT_FALLBACK_BUFFER).to eq(4096)
    end
  end
end