samagotchi 0.78.0 → 0.79.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1b19c3433cc4f46956563a8e4e6532d8b60061084b8050b534b9230b3c608c54
4
- data.tar.gz: 97b8e078c43e1c9901139f1f937d16e43b57b6147cd8298fbb9b77abb37c0b96
3
+ metadata.gz: fc74f7069433e0f6be4252973309183e17c3ecc7ad06800684bf9be6fe40015f
4
+ data.tar.gz: 12a8d7e7c8002a8762d7752f6cae9bd20fae6565cb2d81636cc1ca9a6ff2469d
5
5
  SHA512:
6
- metadata.gz: '0022706491e62a2d7f603a5290fd4c5525b17c6712af6d31dc5ce163b36c4e4414074d7b4da609f890ec5e243ea89b33a5ba0621528d8face6c5226108d0a443'
7
- data.tar.gz: da23fef030284a3401d5ec17017ef2a7d1165d30909f4a96e095823315bc38bbbe1eaf1bdfd76eefe604af19cd160c5f68fd19cb3c4ccb478b28bd9ea1de57eb
6
+ metadata.gz: 004ef50c618ba8bb488adf9e5623da491691bf37850946870bb94511451d8e56876288232d0bae0212dcfc7d9a1c79544d4297ef0ab8692f5678ff3617fc144c
7
+ data.tar.gz: cb551afd6bdae21bfd61a4ff64ead0306816b41b88f4b5083fbf5e7d2afbb9ef9b9353d5d5750ccbfd1764fe10db8c60f8eb534a2cd4d728b04e3145f84dad4a
data/CHANGELOG.md CHANGED
@@ -8,6 +8,19 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.79.0] - 2026-10-17
12
+
13
+ ### Fixed
14
+
15
+ - Web: a repeated toast cannot unstick a sticky error and make it time out.
16
+ - Anthropic: `api: anthropic` hosts on the Claude API now read their model list, including 1M-token windows and image
17
+ input, so `chi models` and model validation work.
18
+ - Anthropic: on Claude 5.x models, which turn thinking off by `between_tools` and refuse `disabled`, recaps, side
19
+ answers and `--thinking off` sessions now send `thinking: {type: "between_tools"}` after that 400 (a recap used to
20
+ fall back to adaptive thinking at low effort, a `--thinking off` session to the provider's default thinking). Each
21
+ side-answer client learns it once for itself.
22
+ - A host whose model list fails is asked again at most once a minute (it was asked on every context-window lookup).
23
+
11
24
  ## [0.78.0] - 2026-10-17
12
25
 
13
26
  ### Added
@@ -3269,7 +3282,8 @@ and long-lived sessions.
3269
3282
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
3270
3283
  hotkey panel that send selected text or the clipboard to your sessions.
3271
3284
 
3272
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.78.0...HEAD
3285
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.79.0...HEAD
3286
+ [0.79.0]: https://github.com/dm1try/samagotchi/compare/v0.78.0...v0.79.0
3273
3287
  [0.78.0]: https://github.com/dm1try/samagotchi/compare/v0.77.0...v0.78.0
3274
3288
  [0.77.0]: https://github.com/dm1try/samagotchi/compare/v0.76.0...v0.77.0
3275
3289
  [0.76.0]: https://github.com/dm1try/samagotchi/compare/v0.75.0...v0.76.0
@@ -644,8 +644,10 @@ What each backend gets:
644
644
  | Messages host, `dialect: fireworks` | `thinking: {type: "disabled"}` | `thinking: {type: "adaptive"}` and `output_config: {effort: <level>}` (no `display`) |
645
645
 
646
646
  On a Messages host `default` sends nothing: the provider's own default, which differs by model (some Claude models
647
- think adaptively but show no thinking text, their blocks still going back; others, and Splash, don't think at all). A model that refuses `disabled` (current
648
- Claude models) gets the request again without it, as on any chat host. A model that refuses adaptive thinking
647
+ think adaptively but show no thinking text, their blocks still going back; others, and Splash, don't think at all). A model that refuses `disabled` and names `between_tools` in the 400 (Claude 5.x) gets `thinking: {type:
648
+ "between_tools"}` wherever `off` asked for `disabled`, from then on: the adapter keeps that per model for as long as it
649
+ lives, so a worker's turns keep it, and each side-answer client (recap, `ctx.ask_model`, a broadcast's triage) learns it
650
+ once for itself. One that refuses `disabled` without naming it gets the request again without it, as on any chat host. A model that refuses adaptive thinking
649
651
  (older Claude: Haiku 4.5) is switched, once and for the worker's life, to the budget form: `thinking: {type:
650
652
  "enabled", budget_tokens: B}` with B 2048 / 8192 / 24576 for low / medium / high and no effort; `max_tokens` always
651
653
  stays above the budget (the budget is lowered to leave 1024 for the answer, or `max_tokens` raised to 2048). A
@@ -17,7 +17,7 @@ ChatLoop::Run#generate
17
17
  → POST {base}/v1/messages # LLM::HTTP: retries, cancel, first-token limit
18
18
  → AnthropicAssembly#add per SSE event # → ChatResponse (text, reasoning, tool_calls, usage,
19
19
  # finish_reason, provider_content)
20
- → a signature 400 (Claude 5.x preserved thinking): #binding_recovery, then the same
20
+ → a signature 400 (Claude 5.x preserved thinking): AnthropicBindingRecovery#advance, then the same
21
21
  request with drop_block or the blocks stripped; ChatResponse#thinking_drop names them
22
22
  → a 400 it can fix: #refusal_fix, then the same request again (once per kind)
23
23
  → ChatLoop#settle_dropped_thinking! # the dropped blocks leave the stored model entries
@@ -31,6 +31,7 @@ Paths are under `lib/samagotchi/llm/`.
31
31
  |---|---|
32
32
  | `adapters.rb` | `Adapters::BY_API` (`openai`, `anthropic`), `.for(entry)`, `.build(api:, …)` for side requests (`IdleClient`) |
33
33
  | `anthropic_messages.rb` | `AnthropicMessages`: the request body, `#chat` (streamed or not), the 400 fixes, `#list_models`, `#thinking_fields`, `#replays_content?` |
34
+ | `anthropic_binding_recovery.rb` | `AnthropicBindingRecovery`: the preserved-thinking recovery (`#advance` picks drop_block or strip for a signature 400, `#recovered_body`, `#with_thinking_drop`); one per adapter |
34
35
  | `anthropic_wire.rb` | `AnthropicWire`: ChatLoop's wire messages as Messages' `system` and `messages`, the two breakpoints, tool ids and names |
35
36
  | `anthropic_assembly.rb` | `AnthropicAssembly`: a response from a whole body (`.from_message`) or stream events (`#add`), its usage in the OpenAI shape |
36
37
  | `dialect.rb` | `Dialect` (`STANDARD`, `FIREWORKS`): what a provider's `/v1/messages` does differently |
@@ -54,7 +55,8 @@ with `tool_choice: {type: "auto"}` (when any), then the loop's options. The opti
54
55
  (`default.max_tokens`), else the model list's `max_tokens` capped at `DEFAULT_MAX_TOKENS` (64000), and never over an
55
56
  output cap a 400 named. `#with_budget_room` keeps `max_tokens` above a budget-form `budget_tokens` plus `MIN_BUDGET`.
56
57
 
57
- `#thinking_fields(level)` is the thinking level on this wire: `off` is `thinking: {type: "disabled"}`; `low`, `medium`
58
+ `#thinking_fields(level)` is the thinking level on this wire: `off` is `thinking: {type: "disabled"}` (sent as `{type:
59
+ "between_tools"}` to a model whose 400 for `disabled` names that form, Claude 5.x: see the 400 fallbacks); `low`, `medium`
58
60
  and `high` are `thinking: {type: "adaptive", display: <dialect's>}` with `output_config: {effort: <level>}`; `default`
59
61
  sends nothing. `#thinking_off_refused_fields` (low effort) is what `IdleClient` sends a side ask whose model refused
60
62
  thinking off.
@@ -137,7 +139,7 @@ A model switch never 400s: the API drops blocks another model can't read silentl
137
139
 
138
140
  **Recover on the 400, then settle.** Normal requests carry no beta header and no `block_binding`, so Fireworks,
139
141
  Splash and gateways see the same bytes as before. On a signature 400, before anything streamed,
140
- `AnthropicMessages#binding_recovery` picks the next step, once each per request:
142
+ `AnthropicBindingRecovery#advance` (one per adapter, `anthropic_binding_recovery.rb`) picks the next step, once each per request:
141
143
 
142
144
  - **drop_block**: the same body with `thinking.block_binding: {prefix_mismatch_behavior: "drop_block"}` (the body's
143
145
  adaptive or enabled thinking, `display` as sent; adaptive when the level sends none) and the header
@@ -209,12 +211,15 @@ thinking, nil for none), `cache_breakpoints` and `session_header`.
209
211
 
210
212
  `AnthropicMessages#chat` handles the 400s it can fix itself, once each per request, and remembers the fix for the
211
213
  adapter's life, so ChatLoop's broader paths never see them (`#refusal_fix`, logged under `http`; kept in an
212
- `LLM::RefusalMemo`, locked, since the turn, side-ask and recap threads share the host's adapter):
214
+ `LLM::RefusalMemo`, locked, since the turn thread and the model-list reads share the host's adapter; `IdleClient`,
215
+ whose recap, side answers and triage each build their own adapter (`Adapters.build`), keeps what it learned for that
216
+ client's life only):
213
217
 
214
218
  | 400 about | Fix | Remembered |
215
219
  |---|---|---|
216
220
  | `thinking.display` (`DISPLAY_RE`) | no `display` from now on (`#without_display`), the thinking kept | per host (`thinking_display_refused`) |
217
221
  | adaptive thinking or its effort (`ADAPTIVE_REFUSED_RE`) | the budget form: `thinking: {type: "enabled", budget_tokens: BUDGETS[level]}`, no effort (`#budget_form`) | per model (`thinking_form_switched`) |
222
+ | `thinking: {type: "disabled"}` where the 400 names `between_tools` (`THINKING_OFF_FORM_RE`; Claude 5.x) | `thinking: {type: "between_tools"}` wherever `disabled` was asked (`#thinking_between_tools`), so a recap or side answer (`IdleClient` step 0) and a `--thinking off` session never fall back to adaptive low or to no thinking fields; a model that refuses `between_tools` too raises the 400 as before | per model (`thinking_off_form_switched`) |
218
223
  | `cache_control` | no breakpoints | per host (`cache_control_refused`) |
219
224
  | `max_tokens: N > M` (`OUTPUT_CAP_RE`) | `max_tokens` capped at M | per model (`output_cap`) |
220
225
  | `block_binding` or the binding beta, on a drop_block retry (`thinking_binding_unsupported?`) | the strip instead (Preserved thinking) | per host (`binding_controls_refused`) |
@@ -273,8 +273,8 @@ One iteration with tool calls:
273
273
  → an ask verdict: Gate#settle_ask (asks the user, records waited_ms)
274
274
  → KernelLoop#dispatch_tool_call, or the denial text
275
275
  → :tool_call_completed, then the :after_tool_call hook
276
- → returns {output:, capped_output:, truncated:, activity:, images:, shown_params:,
277
- shown_label:, diff:}
276
+ → returns a ToolRun value object with output, capped_output, truncated, activity, images, shown_params,
277
+ shown_label, refs, diff and output_file; fields a call doesn't produce are nil
278
278
  → :tool_dispatch_completed {iteration, call_count}
279
279
  → native: ToolResponse.joined(runs, ids:), one entry with the capped outputs
280
280
  chat: ToolResponse.single(run, tool_call_id:, ids:) as each call finishes (the block), with the
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.78.0
3
+ version: 0.79.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -46,12 +46,14 @@ module Samagotchi
46
46
  # Every page of the host's model list (MAX_MODEL_PAGES at most): the
47
47
  # block gives a page's URI for the last page's last_id (nil first).
48
48
  # @return [Array<ModelInfo>]
49
- def list_model_pages
49
+ def list_model_pages(headers: {})
50
50
  models = []
51
51
  after = nil
52
52
  MAX_MODEL_PAGES.times do
53
53
  uri = yield(after)
54
- body = parse_json(@http.fetch(uri, Net::HTTP::Get.new(uri), log_fields: { purpose: "models" }).body, "model list")
54
+ request = Net::HTTP::Get.new(uri)
55
+ headers.each { |name, value| request[name] = value }
56
+ body = parse_json(@http.fetch(uri, request, log_fields: { purpose: "models" }).body, "model list")
55
57
  models.concat(model_entries(body).map { |raw| model_info(raw) })
56
58
  break unless body.is_a?(Hash) && body["has_more"] && body["last_id"]
57
59
 
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../log"
4
+ require_relative "anthropic_wire"
5
+ require_relative "openai_chat"
6
+
7
+ module Samagotchi
8
+ module LLM
9
+ # The preserved-thinking recovery of AnthropicMessages (Claude 5.x: a
10
+ # replayed thinking block is bound to the prefix it was made under, and
11
+ # a prefix that changed gets a signature 400): which step a 400 takes
12
+ # (#advance), the body that step sends (#recovered_body) and the
13
+ # response's ThinkingDrop (#with_thinking_drop). One per adapter; a
14
+ # request's own state is a Recovery.
15
+ class AnthropicBindingRecovery
16
+ # The beta header thinking.block_binding needs, and the behaviour the
17
+ # recovery asks for: the API drops a thinking block whose prefix
18
+ # changed (and every thinking block after it) for that request.
19
+ BINDING_BETA = "thinking-binding-controls-2026-08-01"
20
+ DROP_BLOCK = { prefix_mismatch_behavior: "drop_block" }.freeze
21
+ # A signature 400 that drop_block can settle: the block is valid but
22
+ # bound to another prefix. Without it the signature itself is bad
23
+ # (tampered, unreadable, another provider's): only a strip helps.
24
+ BOUND_ELSEWHERE_RE = /bound to a different conversation/i
25
+ # The block a signature 400 names: an index into the body sent.
26
+ BLOCK_PATH_RE = /messages\.(\d+)\.content\.(\d+)/
27
+ # The only drop the loop settles: a model_binding_mismatch (a /model
28
+ # downgrade) keeps its blocks, a switch back reads them again.
29
+ PREFIX_DROP = "prefix_binding_mismatch"
30
+
31
+ # One request's binding recovery (#advance): the step taken
32
+ # (nil, :drop_block or :strip), the steps tried, where a strip starts
33
+ # ([message, block] in the body), the last signature 400 and the body
34
+ # it answered, and the first 400's last sentence and diagnosis for the log line.
35
+ Recovery = Struct.new(:step, :tried, :strip_from, :signature, :why, :diagnosis)
36
+
37
+ # @param refusals [RefusalMemo] the adapter's (the :binding_controls
38
+ # the host refused is remembered there)
39
+ # @param host_name [String] for the log lines
40
+ # @param budget_form [#call] model -> whether it gets the budget form
41
+ # (AnthropicMessages#budget_form?)
42
+ def initialize(refusals:, host_name:, budget_form:)
43
+ @refusals = refusals
44
+ @host_name = host_name
45
+ @budget_form = budget_form
46
+ end
47
+
48
+ # The step a signature 400 for +body+ takes (set on +recovery+, once
49
+ # each per request), or nil: the error goes on as it is.
50
+ # - drop_block: the same body with thinking.block_binding and the beta
51
+ # header, for a block bound to another prefix, when the host takes
52
+ # the controls and the body's thinking can carry them (adaptive,
53
+ # enabled, or none: then adaptive; not disabled, nor none for a
54
+ # model on the budget form);
55
+ # - strip: the block the 400 names (else the first replayed thinking
56
+ # block) and every thinking block after it removed, for a bad
57
+ # signature, where drop_block can't apply, when the host refused
58
+ # the controls (remembered for the host), or after a second
59
+ # signature 400.
60
+ def advance(error, body, recovery, model)
61
+ return nil if body.nil? || error.status != 400
62
+
63
+ if error.thinking_binding_unsupported? && recovery.step == :drop_block
64
+ unless @refusals.refused?(:binding_controls)
65
+ @refusals.remember(:binding_controls)
66
+ Log.info(:http, "binding_controls_refused", host: @host_name, detail: error.detail.to_s[0, 200])
67
+ end
68
+ return strip_step(recovery)
69
+ end
70
+ return nil unless error.thinking_signature?
71
+
72
+ recovery.diagnosis ||= error.diagnosis
73
+ recovery.signature = [error.detail.to_s, body]
74
+ case recovery.step
75
+ when nil
76
+ return nil if thinking_positions(body).empty?
77
+
78
+ recovery.why = last_sentence(error.detail.to_s)
79
+ return strip_step(recovery) unless drop_block_fits?(error, body, model)
80
+
81
+ recovery.step = :drop_block
82
+ when :drop_block then strip_step(recovery)
83
+ end
84
+ end
85
+
86
+ # +body+ as the recovery step sends it, and the identities a strip
87
+ # removed (nil for none).
88
+ def recovered_body(body, recovery)
89
+ case recovery.step
90
+ when :drop_block then [body.merge(thinking: drop_block_thinking(body[:thinking])), nil]
91
+ when :strip then stripped_body(body, recovery.strip_from)
92
+ else [body, nil]
93
+ end
94
+ end
95
+
96
+ # +response+ with the blocks this request went without
97
+ # (ThinkingDrop), logged: a strip's, or drop_block's
98
+ # prefix_binding_mismatch entries mapped by path into +body+ (the
99
+ # body sent); any other entry is counted as kept, never settled.
100
+ def with_thinking_drop(response, recovery, body, stripped, transformations, model)
101
+ if recovery.step == :strip
102
+ identities = stripped
103
+ kept = 0
104
+ else
105
+ drops = Array(transformations).select { |entry| entry["type"] == "thinking_dropped" }
106
+ prefix, others = drops.partition { |entry| entry["reason"] == PREFIX_DROP }
107
+ identities = prefix.filter_map { |entry| identity_at(body, entry["path"]) }.uniq.freeze
108
+ kept = others.size
109
+ end
110
+ fields = { host: @host_name, model: model.to_s, how: recovery.step.to_s, dropped: identities.size,
111
+ kept: kept, why: recovery.why }
112
+ fields[:diagnosis] = recovery.diagnosis if recovery.diagnosis
113
+ Log.info(:http, "thinking_recovered", **fields)
114
+ return response if identities.empty?
115
+
116
+ response.with(thinking_drop: ThinkingDrop.new(identities: identities, how: recovery.step))
117
+ end
118
+
119
+ private
120
+
121
+ def drop_block_fits?(error, body, model)
122
+ return false unless BOUND_ELSEWHERE_RE.match?(error.detail.to_s) && !@refusals.refused?(:binding_controls)
123
+
124
+ case body[:thinking]
125
+ when nil then !@budget_form.call(model)
126
+ when Hash then %w[adaptive enabled].include?(body[:thinking][:type].to_s)
127
+ else false
128
+ end
129
+ end
130
+
131
+ # The strip, from the block the last signature 400 names in the body
132
+ # it answered (a thinking block there), else the first replayed one.
133
+ def strip_step(recovery)
134
+ return nil if recovery.tried.include?(:strip)
135
+
136
+ detail, body = recovery.signature
137
+ positions = thinking_positions(body)
138
+ named = detail.to_s.match(BLOCK_PATH_RE)&.captures&.map(&:to_i)
139
+ recovery.strip_from = positions.include?(named) ? named : positions.first
140
+ recovery.step = recovery.strip_from && :strip
141
+ end
142
+
143
+ # [message, block] of every thinking or redacted_thinking block in
144
+ # +body+'s messages.
145
+ def thinking_positions(body)
146
+ Array(body[:messages]).each_with_index.flat_map do |message, n|
147
+ Array(message[:content]).each_with_index.filter_map { |block, m| [n, m] if AnthropicWire.thinking_block?(block) }
148
+ end
149
+ end
150
+
151
+ # The body's thinking with block_binding: drop_block (adaptive
152
+ # without one: the binding controls need adaptive or enabled).
153
+ def drop_block_thinking(thinking)
154
+ thinking = thinking.is_a?(Hash) ? thinking.transform_keys(&:to_sym) : {}
155
+ thinking = { type: "adaptive" } unless %w[adaptive enabled].include?(thinking[:type].to_s)
156
+ thinking.merge(block_binding: DROP_BLOCK)
157
+ end
158
+
159
+ # +body+ without the thinking blocks from +from+ on (a trailing run:
160
+ # what Messages takes; a gap in the middle it refuses), and their
161
+ # identities. A message left empty says the placeholder.
162
+ def stripped_body(body, from)
163
+ first_message, first_block = from
164
+ removed = []
165
+ messages = body[:messages].each_with_index.map do |message, n|
166
+ next message if n < first_message
167
+
168
+ content = message[:content].each_with_index.reject do |block, m|
169
+ drop = (n > first_message || m >= first_block) && AnthropicWire.thinking_block?(block)
170
+ removed << AnthropicWire.block_identity(block) if drop
171
+ drop
172
+ end.map(&:first)
173
+ next message if content.size == message[:content].size
174
+
175
+ message.merge(content: content.empty? ? [{ type: "text", text: AnthropicWire::PLACEHOLDER }] : content)
176
+ end
177
+ [body.merge(messages: messages), removed.compact.uniq.freeze]
178
+ end
179
+
180
+ def identity_at(body, path)
181
+ n, m = path.to_s.match(BLOCK_PATH_RE)&.captures&.map(&:to_i)
182
+ return nil unless n
183
+
184
+ block = Array(body[:messages])[n]&.then { |message| Array(message[:content])[m] }
185
+ AnthropicWire.thinking_block?(block) ? AnthropicWire.block_identity(block) : nil
186
+ end
187
+
188
+ # The last sentence of a 400 (what it says changed: "The `system`
189
+ # prompt differs …"), at most 200 characters.
190
+ def last_sentence(detail)
191
+ detail.strip.split(/(?<=\.)\s+/).last.to_s[0, 200]
192
+ end
193
+ end
194
+ end
195
+ end
@@ -13,6 +13,7 @@ require_relative "openai_chat"
13
13
  require_relative "prompt_cache"
14
14
  require_relative "refusal_memo"
15
15
  require_relative "anthropic_assembly"
16
+ require_relative "anthropic_binding_recovery"
16
17
  require_relative "anthropic_wire"
17
18
  require_relative "dialect"
18
19
 
@@ -36,6 +37,9 @@ module Samagotchi
36
37
  # broader refusal paths never see them:
37
38
  # - adaptive thinking (or output_config.effort) refused: the model gets
38
39
  # the budget form instead (#budget_form), per model;
40
+ # - thinking off refused for the `between_tools` form (Claude 5.x:
41
+ # `thinking: {type: "disabled"}` is refused, the 400 names
42
+ # `between_tools`): that form from then on, per model;
39
43
  # - thinking.display refused (a provider whose thinking schema has no
40
44
  # such field, e.g. Fireworks without its dialect): no display for the
41
45
  # host, the thinking itself kept;
@@ -44,7 +48,7 @@ module Samagotchi
44
48
  # - max_tokens over the model's output limit: the limit the message
45
49
  # names, per model.
46
50
  # A signature 400 (Claude 5.x preserved thinking: a replayed block bound
47
- # to a prefix that changed) is recovered here too (#binding_recovery):
51
+ # to a prefix that changed) is recovered here too (AnthropicBindingRecovery):
48
52
  # asked again with drop_block, or with the blocks stripped, and the
49
53
  # dropped blocks handed to the loop (ChatResponse#thinking_drop), which
50
54
  # removes them from the history.
@@ -60,6 +64,10 @@ module Samagotchi
60
64
  # A 400 refusing adaptive thinking or its effort.
61
65
  ADAPTIVE_REFUSED_RE = /adaptive|output_config|effort/i
62
66
  CACHE_CONTROL_RE = /cache_control/i
67
+ # A 400 refusing `thinking: disabled` and naming the form that model
68
+ # takes for off: "To turn thinking off on this model, send "thinking":
69
+ # {"type": "between_tools"} instead of {"type": "disabled"}" (Claude 5.x).
70
+ THINKING_OFF_FORM_RE = /between_tools/i
63
71
  # A 400 refusing the thinking display field: its path (pydantic:
64
72
  # "thinking.adaptive.display"), or the field named as unknown or not
65
73
  # permitted. Not an error that only echoes the input
@@ -76,27 +84,6 @@ module Samagotchi
76
84
  # (configured sampling passes as written).
77
85
  DROPPED_OPTIONS = %i[reasoning_effort chat_template_kwargs stream_options prompt_cache_key logprobs top_logprobs
78
86
  max_completion_tokens].freeze
79
- # The beta header thinking.block_binding needs, and the behaviour the
80
- # recovery asks for: the API drops a thinking block whose prefix
81
- # changed (and every thinking block after it) for that request.
82
- BINDING_BETA = "thinking-binding-controls-2026-08-01"
83
- DROP_BLOCK = { prefix_mismatch_behavior: "drop_block" }.freeze
84
- # A signature 400 that drop_block can settle: the block is valid but
85
- # bound to another prefix. Without it the signature itself is bad
86
- # (tampered, unreadable, another provider's): only a strip helps.
87
- BOUND_ELSEWHERE_RE = /bound to a different conversation/i
88
- # The block a signature 400 names: an index into the body sent.
89
- BLOCK_PATH_RE = /messages\.(\d+)\.content\.(\d+)/
90
- THINKING_TYPES = %w[thinking redacted_thinking].freeze
91
- # The only drop the loop settles: a model_binding_mismatch (a /model
92
- # downgrade) keeps its blocks, a switch back reads them again.
93
- PREFIX_DROP = "prefix_binding_mismatch"
94
-
95
- # One request's binding recovery (#binding_recovery): the step taken
96
- # (nil, :drop_block or :strip), the steps tried, where a strip starts
97
- # ([message, block] in the body), the last signature 400 and the body
98
- # it answered, and the first 400's last sentence and diagnosis for the log line.
99
- Recovery = Struct.new(:step, :tried, :strip_from, :signature, :why, :diagnosis)
100
87
 
101
88
  attr_reader :base_url, :host_name, :api_key_env, :models_ttl, :first_token_timeout, :dialect
102
89
 
@@ -126,6 +113,7 @@ module Samagotchi
126
113
  # The 400 fixes (#refusal_fix): :thinking_form and :output_cap per
127
114
  # model, :thinking_display and :cache_control for the host.
128
115
  @refusals = RefusalMemo.new
116
+ @binding = AnthropicBindingRecovery.new(refusals: @refusals, host_name: @host_name, budget_form: method(:budget_form?))
129
117
  @http = build_http(timeout: timeout, open_timeout: open_timeout, retries: retries, retry_policy: retry_policy,
130
118
  sleeper: sleeper, first_token_timeout: first_token_timeout,
131
119
  api_key: ApiKey.for(api_key_env, host: @host_name, env: env, scheme: auth || @dialect.auth))
@@ -137,12 +125,12 @@ module Samagotchi
137
125
  def chat(messages:, model:, tools: [], cancel_controller: nil, on_delta: nil, on_retry: nil, options: {},
138
126
  session_id: nil)
139
127
  fixed = []
140
- recovery = Recovery.new(tried: [])
128
+ recovery = AnthropicBindingRecovery::Recovery.new(tried: [])
141
129
  shown = false
142
130
  begin
143
131
  # A rebuilt body (a refusal fix's retry) keeps the recovery.
144
- body, stripped = recovered_body(request_body(messages, tools, model, options), recovery)
145
- headers = recovery.step == :drop_block ? { "anthropic-beta" => BINDING_BETA } : {}
132
+ body, stripped = @binding.recovered_body(request_body(messages, tools, model, options), recovery)
133
+ headers = recovery.step == :drop_block ? { "anthropic-beta" => AnthropicBindingRecovery::BINDING_BETA } : {}
146
134
  request = post_request("#{@base_url}/v1/messages", body, session_id: session_id, extra_headers: headers)
147
135
  log_fields = { model: model, purpose: @purpose, sampling: sampling_summary(body), cache: cache_summary(body) }
148
136
  response, transformations =
@@ -151,11 +139,11 @@ module Samagotchi
151
139
  else
152
140
  chat_once(request, cancel_controller, log_fields)
153
141
  end
154
- recovery.step ? with_thinking_drop(response, recovery, body, stripped, transformations, model) : response
142
+ recovery.step ? @binding.with_thinking_drop(response, recovery, body, stripped, transformations, model) : response
155
143
  rescue BadRequest => e
156
144
  # Only before anything was shown: a mid-stream error must not
157
145
  # repeat its deltas.
158
- if !shown && binding_recovery(e, body, recovery, model)
146
+ if !shown && @binding.advance(e, body, recovery, model)
159
147
  recovery.tried << recovery.step
160
148
  retry
161
149
  end
@@ -202,16 +190,18 @@ module Samagotchi
202
190
  # @return [Hash] frozen
203
191
  def thinking_off_refused_fields = thinking_fields(:low)
204
192
 
193
+ # Whether +model+ gets the budget form (it refused adaptive thinking).
194
+ def budget_form?(model) = @refusals.refused?(:thinking_form, model.to_s)
195
+
205
196
  # The host's models, every page: GET {base}/v1/models with
206
197
  # limit=1000 and after_id paging (has_more/last_id). Anthropic's shape
207
198
  # (max_input_tokens, max_tokens, capabilities) and the OpenAI shape a
208
199
  # gateway or Splash serves here are both read (ModelListing).
209
- # Whether +model+ gets the budget form (it refused adaptive thinking).
210
- def budget_form?(model) = @refusals.refused?(:thinking_form, model.to_s)
211
-
212
200
  # @return [Array<ModelInfo>]
213
201
  def list_models
214
- list_model_pages { |after| URI("#{@base_url}/v1/models?#{URI.encode_www_form({ limit: 1000, after_id: after }.compact)}") }
202
+ list_model_pages(headers: { "anthropic-version" => API_VERSION }) do |after|
203
+ URI("#{@base_url}/v1/models?#{URI.encode_www_form({ limit: 1000, after_id: after }.compact)}")
204
+ end
215
205
  end
216
206
 
217
207
  private
@@ -230,6 +220,7 @@ module Samagotchi
230
220
  def request_body(messages, tools, model, options)
231
221
  options = (options || {}).to_h.transform_keys(&:to_sym).except(*DROPPED_OPTIONS)
232
222
  options = budget_form(options) if budget_form?(model)
223
+ options = thinking_between_tools(options) if @refusals.refused?(:thinking_off_form, model.to_s)
233
224
  options = without_display(options) if @refusals.refused?(:thinking_display)
234
225
  cache = PromptCache.control if @purpose == "chat" && @dialect.cache_breakpoints && !@refusals.refused?(:cache_control)
235
226
  translated = AnthropicWire.translate(messages, cache: cache)
@@ -262,6 +253,16 @@ module Samagotchi
262
253
  output_config: config.empty? ? nil : config)
263
254
  end
264
255
 
256
+ # +options+ with thinking off as the form such a model takes:
257
+ # `thinking: {type: "between_tools"}` where `disabled` was asked (a
258
+ # model that refused `disabled`).
259
+ def thinking_between_tools(options)
260
+ thinking = options[:thinking]
261
+ return options unless thinking.is_a?(Hash) && thinking.transform_keys(&:to_sym)[:type].to_s == "disabled"
262
+
263
+ options.merge(thinking: thinking.transform_keys(&:to_sym).merge(type: "between_tools"))
264
+ end
265
+
265
266
  # +options+ with no thinking.display (the host refused the field).
266
267
  def without_display(options)
267
268
  thinking = options[:thinking]
@@ -290,152 +291,6 @@ module Samagotchi
290
291
  body.merge(max_tokens: max_tokens, thinking: thinking.merge(budget_tokens: budget))
291
292
  end
292
293
 
293
- # The step a signature 400 for +body+ takes (set on +recovery+, once
294
- # each per request), or nil: the error goes on as it is.
295
- # - drop_block: the same body with thinking.block_binding and the beta
296
- # header, for a block bound to another prefix, when the host takes
297
- # the controls and the body's thinking can carry them (adaptive,
298
- # enabled, or none: then adaptive; not disabled, nor none for a
299
- # model on the budget form);
300
- # - strip: the block the 400 names (else the first replayed thinking
301
- # block) and every thinking block after it removed, for a bad
302
- # signature, where drop_block can't apply, when the host refused
303
- # the controls (remembered for the host), or after a second
304
- # signature 400.
305
- def binding_recovery(error, body, recovery, model)
306
- return nil if body.nil? || error.status != 400
307
-
308
- if error.thinking_binding_unsupported? && recovery.step == :drop_block
309
- unless @refusals.refused?(:binding_controls)
310
- @refusals.remember(:binding_controls)
311
- Log.info(:http, "binding_controls_refused", host: @host_name, detail: error.detail.to_s[0, 200])
312
- end
313
- return strip_step(recovery)
314
- end
315
- return nil unless error.thinking_signature?
316
-
317
- recovery.diagnosis ||= error.diagnosis
318
- recovery.signature = [error.detail.to_s, body]
319
- case recovery.step
320
- when nil
321
- return nil if thinking_positions(body).empty?
322
-
323
- recovery.why = last_sentence(error.detail.to_s)
324
- return strip_step(recovery) unless drop_block_fits?(error, body, model)
325
-
326
- recovery.step = :drop_block
327
- when :drop_block then strip_step(recovery)
328
- end
329
- end
330
-
331
- def drop_block_fits?(error, body, model)
332
- return false unless BOUND_ELSEWHERE_RE.match?(error.detail.to_s) && !@refusals.refused?(:binding_controls)
333
-
334
- case body[:thinking]
335
- when nil then !budget_form?(model)
336
- when Hash then %w[adaptive enabled].include?(body[:thinking][:type].to_s)
337
- else false
338
- end
339
- end
340
-
341
- # The strip, from the block the last signature 400 names in the body
342
- # it answered (a thinking block there), else the first replayed one.
343
- def strip_step(recovery)
344
- return nil if recovery.tried.include?(:strip)
345
-
346
- detail, body = recovery.signature
347
- positions = thinking_positions(body)
348
- named = detail.to_s.match(BLOCK_PATH_RE)&.captures&.map(&:to_i)
349
- recovery.strip_from = positions.include?(named) ? named : positions.first
350
- recovery.step = recovery.strip_from && :strip
351
- end
352
-
353
- # [message, block] of every thinking or redacted_thinking block in
354
- # +body+'s messages.
355
- def thinking_positions(body)
356
- Array(body[:messages]).each_with_index.flat_map do |message, n|
357
- Array(message[:content]).each_with_index.filter_map { |block, m| [n, m] if thinking_block?(block) }
358
- end
359
- end
360
-
361
- def thinking_block?(block) = block.is_a?(Hash) && THINKING_TYPES.include?((block["type"] || block[:type]).to_s)
362
-
363
- # +body+ as the recovery step sends it, and the identities a strip
364
- # removed (nil for none).
365
- def recovered_body(body, recovery)
366
- case recovery.step
367
- when :drop_block then [body.merge(thinking: drop_block_thinking(body[:thinking])), nil]
368
- when :strip then stripped_body(body, recovery.strip_from)
369
- else [body, nil]
370
- end
371
- end
372
-
373
- # The body's thinking with block_binding: drop_block (adaptive
374
- # without one: the binding controls need adaptive or enabled).
375
- def drop_block_thinking(thinking)
376
- thinking = thinking.is_a?(Hash) ? thinking.transform_keys(&:to_sym) : {}
377
- thinking = { type: "adaptive" } unless %w[adaptive enabled].include?(thinking[:type].to_s)
378
- thinking.merge(block_binding: DROP_BLOCK)
379
- end
380
-
381
- # +body+ without the thinking blocks from +from+ on (a trailing run:
382
- # what Messages takes; a gap in the middle it refuses), and their
383
- # identities. A message left empty says the placeholder.
384
- def stripped_body(body, from)
385
- first_message, first_block = from
386
- removed = []
387
- messages = body[:messages].each_with_index.map do |message, n|
388
- next message if n < first_message
389
-
390
- content = message[:content].each_with_index.reject do |block, m|
391
- drop = (n > first_message || m >= first_block) && thinking_block?(block)
392
- removed << AnthropicWire.block_identity(block) if drop
393
- drop
394
- end.map(&:first)
395
- next message if content.size == message[:content].size
396
-
397
- message.merge(content: content.empty? ? [{ type: "text", text: AnthropicWire::PLACEHOLDER }] : content)
398
- end
399
- [body.merge(messages: messages), removed.compact.uniq.freeze]
400
- end
401
-
402
- # +response+ with the blocks this request went without
403
- # (ThinkingDrop), logged: a strip's, or drop_block's
404
- # prefix_binding_mismatch entries mapped by path into +body+ (the
405
- # body sent); any other entry is counted as kept, never settled.
406
- def with_thinking_drop(response, recovery, body, stripped, transformations, model)
407
- if recovery.step == :strip
408
- identities = stripped
409
- kept = 0
410
- else
411
- drops = Array(transformations).select { |entry| entry["type"] == "thinking_dropped" }
412
- prefix, others = drops.partition { |entry| entry["reason"] == PREFIX_DROP }
413
- identities = prefix.filter_map { |entry| identity_at(body, entry["path"]) }.uniq.freeze
414
- kept = others.size
415
- end
416
- fields = { host: @host_name, model: model.to_s, how: recovery.step.to_s, dropped: identities.size,
417
- kept: kept, why: recovery.why }
418
- fields[:diagnosis] = recovery.diagnosis if recovery.diagnosis
419
- Log.info(:http, "thinking_recovered", **fields)
420
- return response if identities.empty?
421
-
422
- response.with(thinking_drop: ThinkingDrop.new(identities: identities, how: recovery.step))
423
- end
424
-
425
- def identity_at(body, path)
426
- n, m = path.to_s.match(BLOCK_PATH_RE)&.captures&.map(&:to_i)
427
- return nil unless n
428
-
429
- block = Array(body[:messages])[n]&.then { |message| Array(message[:content])[m] }
430
- thinking_block?(block) ? AnthropicWire.block_identity(block) : nil
431
- end
432
-
433
- # The last sentence of a 400 (what it says changed: "The `system`
434
- # prompt differs …"), at most 200 characters.
435
- def last_sentence(detail)
436
- detail.strip.split(/(?<=\.)\s+/).last.to_s[0, 200]
437
- end
438
-
439
294
  # What a 400 for +body+ changes (remembered), or nil: the error goes
440
295
  # on as it is.
441
296
  def refusal_fix(error, body, model)
@@ -456,6 +311,11 @@ module Samagotchi
456
311
  @refusals.remember(:thinking_form, model.to_s)
457
312
  Log.info(:http, "thinking_form_switched", host: @host_name, model: model.to_s, to: "budget", detail: detail[0, 200])
458
313
  :thinking_form
314
+ elsif body.dig(:thinking, :type) == "disabled" && THINKING_OFF_FORM_RE.match?(detail)
315
+ @refusals.remember(:thinking_off_form, model.to_s)
316
+ Log.info(:http, "thinking_off_form_switched", host: @host_name, model: model.to_s, to: "between_tools",
317
+ detail: detail[0, 200])
318
+ :thinking_off_form
459
319
  elsif !@refusals.refused?(:cache_control) && CACHE_CONTROL_RE.match?(detail) && cache_summary(body)
460
320
  @refusals.remember(:cache_control)
461
321
  Log.info(:http, "cache_control_refused", host: @host_name, detail: detail[0, 200])