samagotchi 0.78.0 → 0.80.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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -1
  3. data/README.md +7 -5
  4. data/docs/cli.md +24 -7
  5. data/docs/configuration.md +11 -6
  6. data/docs/internals/anthropic.md +12 -5
  7. data/docs/internals/bundles.md +4 -0
  8. data/docs/internals/engine.md +15 -5
  9. data/docs/plugins.md +144 -3
  10. data/docs/releasing.md +2 -2
  11. data/docs/sessions.md +1 -1
  12. data/docs/testing.md +32 -2
  13. data/lib/samagotchi/bootstrap/config_writer.rb +13 -3
  14. data/lib/samagotchi/bootstrap/probe.rb +153 -31
  15. data/lib/samagotchi/bootstrap_command.rb +92 -15
  16. data/lib/samagotchi/bridge.rb +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  19. data/lib/samagotchi/continue_offer.rb +5 -5
  20. data/lib/samagotchi/engine.rb +40 -37
  21. data/lib/samagotchi/guardrails/approval.rb +6 -4
  22. data/lib/samagotchi/guardrails/gate.rb +7 -3
  23. data/lib/samagotchi/hooks/bundle_loader.rb +31 -16
  24. data/lib/samagotchi/hooks/registry.rb +44 -3
  25. data/lib/samagotchi/host_registry.rb +12 -4
  26. data/lib/samagotchi/llm/adapter_common.rb +8 -4
  27. data/lib/samagotchi/llm/anthropic_binding_recovery.rb +198 -0
  28. data/lib/samagotchi/llm/anthropic_messages.rb +39 -178
  29. data/lib/samagotchi/llm/anthropic_wire.rb +7 -1
  30. data/lib/samagotchi/llm/chat_loop.rb +21 -12
  31. data/lib/samagotchi/llm/http.rb +12 -7
  32. data/lib/samagotchi/llm/model_listing.rb +29 -1
  33. data/lib/samagotchi/llm/refusal_memo.rb +3 -2
  34. data/lib/samagotchi/plugin/card.rb +44 -0
  35. data/lib/samagotchi/plugin/context.rb +53 -8
  36. data/lib/samagotchi/plugin/http.rb +154 -0
  37. data/lib/samagotchi/plugin/http_transport.rb +77 -0
  38. data/lib/samagotchi/plugin/loader.rb +29 -10
  39. data/lib/samagotchi/question_answer.rb +29 -0
  40. data/lib/samagotchi/question_desk.rb +30 -22
  41. data/lib/samagotchi/reasoning_history.rb +2 -1
  42. data/lib/samagotchi/relay_verifier.rb +1 -1
  43. data/lib/samagotchi/testing/boundary.rb +156 -0
  44. data/lib/samagotchi/testing/bundle.rb +349 -0
  45. data/lib/samagotchi/testing/fake_executable.rb +42 -0
  46. data/lib/samagotchi/testing/fake_http.rb +137 -0
  47. data/lib/samagotchi/testing/helpers.rb +56 -0
  48. data/lib/samagotchi/testing/host.rb +76 -0
  49. data/lib/samagotchi/testing/minitest.rb +31 -0
  50. data/lib/samagotchi/testing/recorder.rb +138 -0
  51. data/lib/samagotchi/testing/recording_api.rb +27 -0
  52. data/lib/samagotchi/testing/rspec.rb +31 -0
  53. data/lib/samagotchi/testing/sandbox.rb +163 -0
  54. data/lib/samagotchi/testing/sessions.rb +77 -0
  55. data/lib/samagotchi/testing/verdict.rb +28 -0
  56. data/lib/samagotchi/testing/waiting.rb +36 -0
  57. data/lib/samagotchi/testing.rb +26 -0
  58. data/lib/samagotchi/tools/delegate_relay.rb +7 -6
  59. data/lib/samagotchi/tools/tool_index.rb +3 -0
  60. data/lib/samagotchi/turn_state.rb +28 -1
  61. data/lib/samagotchi/version.rb +1 -1
  62. data/lib/samagotchi/web/public/app.js +79 -245
  63. data/lib/samagotchi/web/public/composer_grip.js +231 -0
  64. data/lib/samagotchi/web/public/images.js +33 -0
  65. data/lib/samagotchi/web/public/index.html +73 -40
  66. data/lib/samagotchi/web/public/level_chip.js +11 -11
  67. data/lib/samagotchi/web/public/lists_view.js +19 -14
  68. data/lib/samagotchi/web/public/llm_context_chip.js +15 -15
  69. data/lib/samagotchi/web/public/model_picker.js +5 -6
  70. data/lib/samagotchi/web/public/scope.js +3 -3
  71. data/lib/samagotchi/web/public/stage_view.js +3 -2
  72. data/lib/samagotchi/web/public/storage.js +23 -0
  73. data/lib/samagotchi/web/public/strip.js +35 -9
  74. data/lib/samagotchi/web/public/strip_grip.js +76 -0
  75. data/lib/samagotchi/web/public/thinking_chip.js +5 -5
  76. data/lib/samagotchi/web/public/toasts.js +5 -4
  77. metadata +24 -1
@@ -49,6 +49,31 @@ module Samagotchi
49
49
  namespace.const_get(File.basename(file.to_s, ".rb").split("_").map(&:capitalize).join, false)
50
50
  end
51
51
 
52
+ # A hook that raised: its label, event and error (#capture_failures).
53
+ Failure = Data.define(:hook, :event, :error)
54
+
55
+ # Thread.current key: the Array #capture_failures fills, or nil.
56
+ FAILURES = :samagotchi_hook_failures
57
+
58
+ # Collect into +sink+ every hook that raises on this thread while the
59
+ # block runs, whatever its policy (:skip ones log nothing, a stream
60
+ # hook's log is rate-limited): the bundle test kit raises them. The
61
+ # policy still applies. Nests; threads the block starts don't inherit it.
62
+ # @param sink [Array<Failure>]
63
+ def self.capture_failures(sink)
64
+ previous = Thread.current[FAILURES]
65
+ Thread.current[FAILURES] = sink
66
+ yield
67
+ ensure
68
+ Thread.current[FAILURES] = previous
69
+ end
70
+
71
+ # Add a raising hook to the sink #capture_failures set, if any.
72
+ def self.record_failure(hook, event, error)
73
+ Thread.current[FAILURES]&.push(Failure.new(hook: hook.to_s, event: event.to_s, error: error))
74
+ nil
75
+ end
76
+
52
77
  # A hook's block wrapped in its error policy: what a loader registers.
53
78
  # When the block raises,
54
79
  # :deny denies the call (event[:guardrail], the Gate's Verdict),
@@ -70,6 +95,7 @@ module Samagotchi
70
95
  rescue Exception => e # rubocop:disable Lint/RescueException -- :deny fails closed on anything
71
96
  raise unless policy == :deny || e.is_a?(StandardError) || e.is_a?(ScriptError)
72
97
 
98
+ Hooks.record_failure(label, event, e)
73
99
  failed = "#{e.class}: #{e.message}"
74
100
  if policy == :deny
75
101
  payload[:guardrail]&.deny!("#{label} raised #{failed}", rule: "guardrail-load", source: "core", decided_by: "core") if payload.is_a?(Hash)
@@ -273,6 +299,7 @@ module Samagotchi
273
299
  hook_proc.call(event)
274
300
  rescue StandardError => e
275
301
  # A failing hook must not break the turn.
302
+ Hooks.record_failure(label, name, e)
276
303
  Log.exception(:hooks, "hook_failed", e, echo: "[samagotchi:hooks] #{label} failed: #{e.class}: #{e.message}",
277
304
  hook: label, event: name.to_s)
278
305
  end
@@ -302,6 +329,7 @@ module Samagotchi
302
329
  hook_proc.call(event)
303
330
  rescue StandardError => e
304
331
  # A failing hook must not break the turn.
332
+ Hooks.record_failure(label, name, e)
305
333
  Log.exception(:hooks, "hook_failed", e, echo: "[samagotchi:hooks] #{label} failed: #{e.class}: #{e.message}",
306
334
  hook: label, event: name.to_s)
307
335
  end
@@ -309,6 +337,16 @@ module Samagotchi
309
337
  end
310
338
  end
311
339
 
340
+ # The bundle hooks registered for +name+, in the order #fire calls
341
+ # them (priority, bundle, hook name).
342
+ # @param name [Symbol]
343
+ # @return [Array<Array(String, String, Integer)>] [bundle, hook_name, priority]
344
+ def bundle_hook_names(name)
345
+ @mutex.synchronize do
346
+ sorted_bundle_hooks(name).map { |h| [h[:bundle], h[:hook_name], h[:priority]] }
347
+ end
348
+ end
349
+
312
350
  # @return [Integer] total number of registered hooks (bundle + plain)
313
351
  def size
314
352
  @mutex.synchronize do
@@ -323,15 +361,18 @@ module Samagotchi
323
361
  # turn-scoped hooks, each in registration order.
324
362
  def ordered_procs(name)
325
363
  @mutex.synchronize do
326
- bundle_procs = (@bundle_hooks[name] || [])
327
- .sort_by { |h| [h[:priority].to_i, h[:bundle].to_s, h[:hook_name].to_s] }
328
- .map { |h| ["#{h[:hook_name]} (bundle #{h[:bundle]})", h[:proc]] }
364
+ bundle_procs = sorted_bundle_hooks(name).map { |h| ["#{h[:hook_name]} (bundle #{h[:bundle]})", h[:proc]] }
329
365
  config_procs = (@persistent_hooks[name] || []).map { |h| [h[:label], h[:proc]] }
330
366
  turn_procs = (@hooks[name] || []).map { |hook_proc| [TURN_HOOK_LABEL, hook_proc] }
331
367
  bundle_procs + config_procs + turn_procs
332
368
  end
333
369
  end
334
370
 
371
+ # The bundle hooks for an event in fire order; call under @mutex.
372
+ def sorted_bundle_hooks(name)
373
+ (@bundle_hooks[name] || []).sort_by { |h| [h[:priority].to_i, h[:bundle].to_s, h[:hook_name].to_s] }
374
+ end
375
+
335
376
  # The helpers, once per fire; a fire site's own keys stay.
336
377
  def with_runtime(event)
337
378
  return unless event.is_a?(Hash)
@@ -229,15 +229,23 @@ module Samagotchi
229
229
  # ModelRef names a host only when it is one of ours
230
230
  return [find_entry(ref.host_name), ref.id] if ref.host_name
231
231
 
232
- down = ref.id.to_s.strip.downcase
233
- listed = @mutex.synchronize { @model_index }&.fetch(down, nil) || []
234
- candidates = @entries.values.select { |e| listed.include?(e.name) || e.models&.key?(down) }.map(&:name)
235
- candidates = saved_hosts_for(down) if candidates.empty?
232
+ candidates = hosts_with_model(ref.id)
236
233
  default = default_entry
237
234
  name = candidates.include?(default.name) ? default.name : candidates.first
238
235
  [(name && find_entry(name)) || default, ref.id]
239
236
  end
240
237
 
238
+ # The hosts that list or declare the model id +id+, in hosts: order: by
239
+ # the cached lists and hosts.<name>.models, else by the saved lists
240
+ # (ModelListStore). None asked: no discovery here.
241
+ # @return [Array<String>] host names, empty when no host has it
242
+ def hosts_with_model(id)
243
+ down = id.to_s.strip.downcase
244
+ listed = @mutex.synchronize { @model_index }&.fetch(down, nil) || []
245
+ names = @entries.values.select { |e| listed.include?(e.name) || e.models&.key?(down) }.map(&:name)
246
+ names.empty? ? saved_hosts_for(down) : names
247
+ end
248
+
241
249
  # The chat adapter for a host (one per host, so its cached model list
242
250
  # serves the context window), as LLM::Adapters builds it for the host's
243
251
  # api. Only chat hosts use it for turns.
@@ -24,11 +24,13 @@ module Samagotchi
24
24
 
25
25
  # The adapter's LLM::HTTP: the timeouts (#timeouts), one attempt when
26
26
  # +retries+ is false and no +retry_policy+ is given.
27
- def build_http(timeout:, open_timeout:, retries:, retry_policy:, sleeper:, first_token_timeout:, api_key:)
27
+ def build_http(timeout:, open_timeout:, retries:, retry_policy:, sleeper:, first_token_timeout:, api_key:,
28
+ diagnosis_header: nil)
28
29
  open_timeout, read_timeout = timeouts(timeout, open_timeout)
29
30
  policy = retry_policy || (retries ? nil : HTTP::RetryPolicy.none)
30
31
  HTTP.new(label: @host_name, open_timeout: open_timeout, read_timeout: read_timeout, retry_policy: policy,
31
- sleeper: sleeper, first_token_timeout: first_token_timeout, api_key: api_key)
32
+ sleeper: sleeper, first_token_timeout: first_token_timeout, api_key: api_key,
33
+ diagnosis_header: diagnosis_header)
32
34
  end
33
35
 
34
36
  # [open, read] seconds: server.open_timeout / server.read_timeout,
@@ -46,12 +48,14 @@ module Samagotchi
46
48
  # Every page of the host's model list (MAX_MODEL_PAGES at most): the
47
49
  # block gives a page's URI for the last page's last_id (nil first).
48
50
  # @return [Array<ModelInfo>]
49
- def list_model_pages
51
+ def list_model_pages(headers: {})
50
52
  models = []
51
53
  after = nil
52
54
  MAX_MODEL_PAGES.times do
53
55
  uri = yield(after)
54
- body = parse_json(@http.fetch(uri, Net::HTTP::Get.new(uri), log_fields: { purpose: "models" }).body, "model list")
56
+ request = Net::HTTP::Get.new(uri)
57
+ headers.each { |name, value| request[name] = value }
58
+ body = parse_json(@http.fetch(uri, request, log_fields: { purpose: "models" }).body, "model list")
55
59
  models.concat(model_entries(body).map { |raw| model_info(raw) })
56
60
  break unless body.is_a?(Hash) && body["has_more"] && body["last_id"]
57
61
 
@@ -0,0 +1,198 @@
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
+ # The 400's undocumented diagnosis header, kept on the ProviderError and
31
+ # logged as `diagnosis`.
32
+ DIAGNOSIS_HEADER = "anthropic-thinking-prefix-mismatch"
33
+
34
+ # One request's binding recovery (#advance): the step taken
35
+ # (nil, :drop_block or :strip), the steps tried, where a strip starts
36
+ # ([message, block] in the body), the last signature 400 and the body
37
+ # it answered, and the first 400's last sentence and diagnosis for the log line.
38
+ Recovery = Struct.new(:step, :tried, :strip_from, :signature, :why, :diagnosis)
39
+
40
+ # @param refusals [RefusalMemo] the adapter's (the :binding_controls
41
+ # the host refused is remembered there)
42
+ # @param host_name [String] for the log lines
43
+ # @param budget_form [#call] model -> whether it gets the budget form
44
+ # (AnthropicMessages#budget_form?)
45
+ def initialize(refusals:, host_name:, budget_form:)
46
+ @refusals = refusals
47
+ @host_name = host_name
48
+ @budget_form = budget_form
49
+ end
50
+
51
+ # The step a signature 400 for +body+ takes (set on +recovery+, once
52
+ # each per request), or nil: the error goes on as it is.
53
+ # - drop_block: the same body with thinking.block_binding and the beta
54
+ # header, for a block bound to another prefix, when the host takes
55
+ # the controls and the body's thinking can carry them (adaptive,
56
+ # enabled, or none: then adaptive; not disabled, nor none for a
57
+ # model on the budget form);
58
+ # - strip: the block the 400 names (else the first replayed thinking
59
+ # block) and every thinking block after it removed, for a bad
60
+ # signature, where drop_block can't apply, when the host refused
61
+ # the controls (remembered for the host), or after a second
62
+ # signature 400.
63
+ def advance(error, body, recovery, model)
64
+ return nil if body.nil? || error.status != 400
65
+
66
+ if error.thinking_binding_unsupported? && recovery.step == :drop_block
67
+ unless @refusals.refused?(:binding_controls)
68
+ @refusals.remember(:binding_controls)
69
+ Log.info(:http, "binding_controls_refused", host: @host_name, detail: error.detail.to_s[0, 200])
70
+ end
71
+ return strip_step(recovery)
72
+ end
73
+ return nil unless error.thinking_signature?
74
+
75
+ recovery.diagnosis ||= error.diagnosis
76
+ recovery.signature = [error.detail.to_s, body]
77
+ case recovery.step
78
+ when nil
79
+ return nil if thinking_positions(body).empty?
80
+
81
+ recovery.why = last_sentence(error.detail.to_s)
82
+ return strip_step(recovery) unless drop_block_fits?(error, body, model)
83
+
84
+ recovery.step = :drop_block
85
+ when :drop_block then strip_step(recovery)
86
+ end
87
+ end
88
+
89
+ # +body+ as the recovery step sends it, and the identities a strip
90
+ # removed (nil for none).
91
+ def recovered_body(body, recovery)
92
+ case recovery.step
93
+ when :drop_block then [body.merge(thinking: drop_block_thinking(body[:thinking])), nil]
94
+ when :strip then stripped_body(body, recovery.strip_from)
95
+ else [body, nil]
96
+ end
97
+ end
98
+
99
+ # +response+ with the blocks this request went without
100
+ # (ThinkingDrop), logged: a strip's, or drop_block's
101
+ # prefix_binding_mismatch entries mapped by path into +body+ (the
102
+ # body sent); any other entry is counted as kept, never settled.
103
+ def with_thinking_drop(response, recovery, body, stripped, transformations, model)
104
+ if recovery.step == :strip
105
+ identities = stripped
106
+ kept = 0
107
+ else
108
+ drops = Array(transformations).select { |entry| entry["type"] == "thinking_dropped" }
109
+ prefix, others = drops.partition { |entry| entry["reason"] == PREFIX_DROP }
110
+ identities = prefix.filter_map { |entry| identity_at(body, entry["path"]) }.uniq.freeze
111
+ kept = others.size
112
+ end
113
+ fields = { host: @host_name, model: model.to_s, how: recovery.step.to_s, dropped: identities.size,
114
+ kept: kept, why: recovery.why }
115
+ fields[:diagnosis] = recovery.diagnosis if recovery.diagnosis
116
+ Log.info(:http, "thinking_recovered", **fields)
117
+ return response if identities.empty?
118
+
119
+ response.with(thinking_drop: ThinkingDrop.new(identities: identities, how: recovery.step))
120
+ end
121
+
122
+ private
123
+
124
+ def drop_block_fits?(error, body, model)
125
+ return false unless BOUND_ELSEWHERE_RE.match?(error.detail.to_s) && !@refusals.refused?(:binding_controls)
126
+
127
+ case body[:thinking]
128
+ when nil then !@budget_form.call(model)
129
+ when Hash then %w[adaptive enabled].include?(body[:thinking][:type].to_s)
130
+ else false
131
+ end
132
+ end
133
+
134
+ # The strip, from the block the last signature 400 names in the body
135
+ # it answered (a thinking block there), else the first replayed one.
136
+ def strip_step(recovery)
137
+ return nil if recovery.tried.include?(:strip)
138
+
139
+ detail, body = recovery.signature
140
+ positions = thinking_positions(body)
141
+ named = detail.to_s.match(BLOCK_PATH_RE)&.captures&.map(&:to_i)
142
+ recovery.strip_from = positions.include?(named) ? named : positions.first
143
+ recovery.step = recovery.strip_from && :strip
144
+ end
145
+
146
+ # [message, block] of every thinking or redacted_thinking block in
147
+ # +body+'s messages.
148
+ def thinking_positions(body)
149
+ Array(body[:messages]).each_with_index.flat_map do |message, n|
150
+ Array(message[:content]).each_with_index.filter_map { |block, m| [n, m] if AnthropicWire.thinking_block?(block) }
151
+ end
152
+ end
153
+
154
+ # The body's thinking with block_binding: drop_block (adaptive
155
+ # without one: the binding controls need adaptive or enabled).
156
+ def drop_block_thinking(thinking)
157
+ thinking = thinking.is_a?(Hash) ? thinking.transform_keys(&:to_sym) : {}
158
+ thinking = { type: "adaptive" } unless %w[adaptive enabled].include?(thinking[:type].to_s)
159
+ thinking.merge(block_binding: DROP_BLOCK)
160
+ end
161
+
162
+ # +body+ without the thinking blocks from +from+ on (a trailing run:
163
+ # what Messages takes; a gap in the middle it refuses), and their
164
+ # identities. A message left empty says the placeholder.
165
+ def stripped_body(body, from)
166
+ first_message, first_block = from
167
+ removed = []
168
+ messages = body[:messages].each_with_index.map do |message, n|
169
+ next message if n < first_message
170
+
171
+ content = message[:content].each_with_index.reject do |block, m|
172
+ drop = (n > first_message || m >= first_block) && AnthropicWire.thinking_block?(block)
173
+ removed << AnthropicWire.block_identity(block) if drop
174
+ drop
175
+ end.map(&:first)
176
+ next message if content.size == message[:content].size
177
+
178
+ message.merge(content: content.empty? ? [{ type: "text", text: AnthropicWire::PLACEHOLDER }] : content)
179
+ end
180
+ [body.merge(messages: messages), removed.compact.uniq.freeze]
181
+ end
182
+
183
+ def identity_at(body, path)
184
+ n, m = path.to_s.match(BLOCK_PATH_RE)&.captures&.map(&:to_i)
185
+ return nil unless n
186
+
187
+ block = Array(body[:messages])[n]&.then { |message| Array(message[:content])[m] }
188
+ AnthropicWire.thinking_block?(block) ? AnthropicWire.block_identity(block) : nil
189
+ end
190
+
191
+ # The last sentence of a 400 (what it says changed: "The `system`
192
+ # prompt differs …"), at most 200 characters.
193
+ def last_sentence(detail)
194
+ detail.strip.split(/(?<=\.)\s+/).last.to_s[0, 200]
195
+ end
196
+ end
197
+ end
198
+ 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,9 +113,11 @@ 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
- api_key: ApiKey.for(api_key_env, host: @host_name, env: env, scheme: auth || @dialect.auth))
119
+ api_key: ApiKey.for(api_key_env, host: @host_name, env: env, scheme: auth || @dialect.auth),
120
+ diagnosis_header: AnthropicBindingRecovery::DIAGNOSIS_HEADER)
132
121
  end
133
122
 
134
123
  # The same contract as OpenAIChat#chat; +messages+ are ChatLoop's
@@ -137,12 +126,12 @@ module Samagotchi
137
126
  def chat(messages:, model:, tools: [], cancel_controller: nil, on_delta: nil, on_retry: nil, options: {},
138
127
  session_id: nil)
139
128
  fixed = []
140
- recovery = Recovery.new(tried: [])
129
+ recovery = AnthropicBindingRecovery::Recovery.new(tried: [])
141
130
  shown = false
142
131
  begin
143
132
  # 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 } : {}
133
+ body, stripped = @binding.recovered_body(request_body(messages, tools, model, options), recovery)
134
+ headers = recovery.step == :drop_block ? { "anthropic-beta" => AnthropicBindingRecovery::BINDING_BETA } : {}
146
135
  request = post_request("#{@base_url}/v1/messages", body, session_id: session_id, extra_headers: headers)
147
136
  log_fields = { model: model, purpose: @purpose, sampling: sampling_summary(body), cache: cache_summary(body) }
148
137
  response, transformations =
@@ -151,11 +140,11 @@ module Samagotchi
151
140
  else
152
141
  chat_once(request, cancel_controller, log_fields)
153
142
  end
154
- recovery.step ? with_thinking_drop(response, recovery, body, stripped, transformations, model) : response
143
+ recovery.step ? @binding.with_thinking_drop(response, recovery, body, stripped, transformations, model) : response
155
144
  rescue BadRequest => e
156
145
  # Only before anything was shown: a mid-stream error must not
157
146
  # repeat its deltas.
158
- if !shown && binding_recovery(e, body, recovery, model)
147
+ if !shown && @binding.advance(e, body, recovery, model)
159
148
  recovery.tried << recovery.step
160
149
  retry
161
150
  end
@@ -202,16 +191,18 @@ module Samagotchi
202
191
  # @return [Hash] frozen
203
192
  def thinking_off_refused_fields = thinking_fields(:low)
204
193
 
194
+ # Whether +model+ gets the budget form (it refused adaptive thinking).
195
+ def budget_form?(model) = @refusals.refused?(:thinking_form, model.to_s)
196
+
205
197
  # The host's models, every page: GET {base}/v1/models with
206
198
  # limit=1000 and after_id paging (has_more/last_id). Anthropic's shape
207
199
  # (max_input_tokens, max_tokens, capabilities) and the OpenAI shape a
208
200
  # 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
201
  # @return [Array<ModelInfo>]
213
202
  def list_models
214
- list_model_pages { |after| URI("#{@base_url}/v1/models?#{URI.encode_www_form({ limit: 1000, after_id: after }.compact)}") }
203
+ list_model_pages(headers: { "anthropic-version" => API_VERSION }) do |after|
204
+ URI("#{@base_url}/v1/models?#{URI.encode_www_form({ limit: 1000, after_id: after }.compact)}")
205
+ end
215
206
  end
216
207
 
217
208
  private
@@ -230,6 +221,7 @@ module Samagotchi
230
221
  def request_body(messages, tools, model, options)
231
222
  options = (options || {}).to_h.transform_keys(&:to_sym).except(*DROPPED_OPTIONS)
232
223
  options = budget_form(options) if budget_form?(model)
224
+ options = thinking_between_tools(options) if @refusals.refused?(:thinking_off_form, model.to_s)
233
225
  options = without_display(options) if @refusals.refused?(:thinking_display)
234
226
  cache = PromptCache.control if @purpose == "chat" && @dialect.cache_breakpoints && !@refusals.refused?(:cache_control)
235
227
  translated = AnthropicWire.translate(messages, cache: cache)
@@ -262,6 +254,16 @@ module Samagotchi
262
254
  output_config: config.empty? ? nil : config)
263
255
  end
264
256
 
257
+ # +options+ with thinking off as the form such a model takes:
258
+ # `thinking: {type: "between_tools"}` where `disabled` was asked (a
259
+ # model that refused `disabled`).
260
+ def thinking_between_tools(options)
261
+ thinking = options[:thinking]
262
+ return options unless thinking.is_a?(Hash) && thinking.transform_keys(&:to_sym)[:type].to_s == "disabled"
263
+
264
+ options.merge(thinking: thinking.transform_keys(&:to_sym).merge(type: "between_tools"))
265
+ end
266
+
265
267
  # +options+ with no thinking.display (the host refused the field).
266
268
  def without_display(options)
267
269
  thinking = options[:thinking]
@@ -290,152 +292,6 @@ module Samagotchi
290
292
  body.merge(max_tokens: max_tokens, thinking: thinking.merge(budget_tokens: budget))
291
293
  end
292
294
 
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
295
  # What a 400 for +body+ changes (remembered), or nil: the error goes
440
296
  # on as it is.
441
297
  def refusal_fix(error, body, model)
@@ -456,6 +312,11 @@ module Samagotchi
456
312
  @refusals.remember(:thinking_form, model.to_s)
457
313
  Log.info(:http, "thinking_form_switched", host: @host_name, model: model.to_s, to: "budget", detail: detail[0, 200])
458
314
  :thinking_form
315
+ elsif body.dig(:thinking, :type) == "disabled" && THINKING_OFF_FORM_RE.match?(detail)
316
+ @refusals.remember(:thinking_off_form, model.to_s)
317
+ Log.info(:http, "thinking_off_form_switched", host: @host_name, model: model.to_s, to: "between_tools",
318
+ detail: detail[0, 200])
319
+ :thinking_off_form
459
320
  elsif !@refusals.refused?(:cache_control) && CACHE_CONTROL_RE.match?(detail) && cache_summary(body)
460
321
  @refusals.remember(:cache_control)
461
322
  Log.info(:http, "cache_control_refused", host: @host_name, detail: detail[0, 200])