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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -1
- data/README.md +7 -5
- data/docs/cli.md +24 -7
- data/docs/configuration.md +11 -6
- data/docs/internals/anthropic.md +12 -5
- data/docs/internals/bundles.md +4 -0
- data/docs/internals/engine.md +15 -5
- data/docs/plugins.md +144 -3
- data/docs/releasing.md +2 -2
- data/docs/sessions.md +1 -1
- data/docs/testing.md +32 -2
- data/lib/samagotchi/bootstrap/config_writer.rb +13 -3
- data/lib/samagotchi/bootstrap/probe.rb +153 -31
- data/lib/samagotchi/bootstrap_command.rb +92 -15
- data/lib/samagotchi/bridge.rb +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/continue_offer.rb +5 -5
- data/lib/samagotchi/engine.rb +40 -37
- data/lib/samagotchi/guardrails/approval.rb +6 -4
- data/lib/samagotchi/guardrails/gate.rb +7 -3
- data/lib/samagotchi/hooks/bundle_loader.rb +31 -16
- data/lib/samagotchi/hooks/registry.rb +44 -3
- data/lib/samagotchi/host_registry.rb +12 -4
- data/lib/samagotchi/llm/adapter_common.rb +8 -4
- data/lib/samagotchi/llm/anthropic_binding_recovery.rb +198 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +39 -178
- data/lib/samagotchi/llm/anthropic_wire.rb +7 -1
- data/lib/samagotchi/llm/chat_loop.rb +21 -12
- data/lib/samagotchi/llm/http.rb +12 -7
- data/lib/samagotchi/llm/model_listing.rb +29 -1
- data/lib/samagotchi/llm/refusal_memo.rb +3 -2
- data/lib/samagotchi/plugin/card.rb +44 -0
- data/lib/samagotchi/plugin/context.rb +53 -8
- data/lib/samagotchi/plugin/http.rb +154 -0
- data/lib/samagotchi/plugin/http_transport.rb +77 -0
- data/lib/samagotchi/plugin/loader.rb +29 -10
- data/lib/samagotchi/question_answer.rb +29 -0
- data/lib/samagotchi/question_desk.rb +30 -22
- data/lib/samagotchi/reasoning_history.rb +2 -1
- data/lib/samagotchi/relay_verifier.rb +1 -1
- data/lib/samagotchi/testing/boundary.rb +156 -0
- data/lib/samagotchi/testing/bundle.rb +349 -0
- data/lib/samagotchi/testing/fake_executable.rb +42 -0
- data/lib/samagotchi/testing/fake_http.rb +137 -0
- data/lib/samagotchi/testing/helpers.rb +56 -0
- data/lib/samagotchi/testing/host.rb +76 -0
- data/lib/samagotchi/testing/minitest.rb +31 -0
- data/lib/samagotchi/testing/recorder.rb +138 -0
- data/lib/samagotchi/testing/recording_api.rb +27 -0
- data/lib/samagotchi/testing/rspec.rb +31 -0
- data/lib/samagotchi/testing/sandbox.rb +163 -0
- data/lib/samagotchi/testing/sessions.rb +77 -0
- data/lib/samagotchi/testing/verdict.rb +28 -0
- data/lib/samagotchi/testing/waiting.rb +36 -0
- data/lib/samagotchi/testing.rb +26 -0
- data/lib/samagotchi/tools/delegate_relay.rb +7 -6
- data/lib/samagotchi/tools/tool_index.rb +3 -0
- data/lib/samagotchi/turn_state.rb +28 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +79 -245
- data/lib/samagotchi/web/public/composer_grip.js +231 -0
- data/lib/samagotchi/web/public/images.js +33 -0
- data/lib/samagotchi/web/public/index.html +73 -40
- data/lib/samagotchi/web/public/level_chip.js +11 -11
- data/lib/samagotchi/web/public/lists_view.js +19 -14
- data/lib/samagotchi/web/public/llm_context_chip.js +15 -15
- data/lib/samagotchi/web/public/model_picker.js +5 -6
- data/lib/samagotchi/web/public/scope.js +3 -3
- data/lib/samagotchi/web/public/stage_view.js +3 -2
- data/lib/samagotchi/web/public/storage.js +23 -0
- data/lib/samagotchi/web/public/strip.js +35 -9
- data/lib/samagotchi/web/public/strip_grip.js +76 -0
- data/lib/samagotchi/web/public/thinking_chip.js +5 -5
- data/lib/samagotchi/web/public/toasts.js +5 -4
- 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 = (
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 &&
|
|
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 {
|
|
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])
|