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 +4 -4
- data/CHANGELOG.md +15 -1
- data/docs/configuration.md +4 -2
- data/docs/internals/anthropic.md +9 -4
- data/docs/internals/engine.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/llm/adapter_common.rb +4 -2
- data/lib/samagotchi/llm/anthropic_binding_recovery.rb +195 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +37 -177
- data/lib/samagotchi/llm/anthropic_wire.rb +7 -1
- data/lib/samagotchi/llm/chat_loop.rb +21 -12
- data/lib/samagotchi/llm/model_listing.rb +29 -1
- data/lib/samagotchi/llm/refusal_memo.rb +3 -2
- data/lib/samagotchi/question_desk.rb +10 -3
- data/lib/samagotchi/reasoning_history.rb +2 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +32 -222
- data/lib/samagotchi/web/public/composer_grip.js +231 -0
- data/lib/samagotchi/web/public/index.html +2 -2
- data/lib/samagotchi/web/public/model_picker.js +4 -5
- data/lib/samagotchi/web/public/stage_view.js +3 -2
- data/lib/samagotchi/web/public/storage.js +23 -0
- data/lib/samagotchi/web/public/toasts.js +5 -4
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fc74f7069433e0f6be4252973309183e17c3ecc7ad06800684bf9be6fe40015f
|
|
4
|
+
data.tar.gz: 12a8d7e7c8002a8762d7752f6cae9bd20fae6565cb2d81636cc1ca9a6ff2469d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
data/docs/configuration.md
CHANGED
|
@@ -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` (
|
|
648
|
-
|
|
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
|
data/docs/internals/anthropic.md
CHANGED
|
@@ -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): #
|
|
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"}
|
|
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
|
-
`
|
|
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
|
|
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`) |
|
data/docs/internals/engine.md
CHANGED
|
@@ -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
|
|
277
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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 (
|
|
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 &&
|
|
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 {
|
|
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])
|