samagotchi 0.64.0 → 0.65.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/docs/architecture.md +20 -3
  4. data/docs/cli.md +25 -1
  5. data/docs/configuration.md +128 -3
  6. data/docs/internals/engine.md +15 -3
  7. data/docs/sessions.md +3 -2
  8. data/lib/samagotchi/bridge.rb +11 -1
  9. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  10. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  11. data/lib/samagotchi/client.rb +4 -2
  12. data/lib/samagotchi/config.rb +5 -4
  13. data/lib/samagotchi/engine.rb +39 -7
  14. data/lib/samagotchi/generation_phase.rb +17 -0
  15. data/lib/samagotchi/host_config.rb +27 -6
  16. data/lib/samagotchi/host_registry.rb +34 -11
  17. data/lib/samagotchi/idle_client.rb +62 -25
  18. data/lib/samagotchi/idle_recap.rb +5 -2
  19. data/lib/samagotchi/idle_target.rb +30 -7
  20. data/lib/samagotchi/llm/adapters.rb +41 -0
  21. data/lib/samagotchi/llm/anthropic_assembly.rb +210 -0
  22. data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
  23. data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
  24. data/lib/samagotchi/llm/api_key.rb +26 -9
  25. data/lib/samagotchi/llm/chat_loop.rb +113 -14
  26. data/lib/samagotchi/llm/dialect.rb +57 -0
  27. data/lib/samagotchi/llm/errors.rb +81 -12
  28. data/lib/samagotchi/llm/model_listing.rb +111 -0
  29. data/lib/samagotchi/llm/model_result.rb +9 -2
  30. data/lib/samagotchi/llm/openai_chat.rb +46 -93
  31. data/lib/samagotchi/model_profile.rb +1 -1
  32. data/lib/samagotchi/reasoning_history.rb +14 -4
  33. data/lib/samagotchi/recap_setup.rb +2 -1
  34. data/lib/samagotchi/self_report.rb +18 -28
  35. data/lib/samagotchi/session.rb +3 -2
  36. data/lib/samagotchi/session_metrics.rb +6 -11
  37. data/lib/samagotchi/steer_cut.rb +4 -0
  38. data/lib/samagotchi/terminal_ui/attached_loop.rb +8 -2
  39. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  40. data/lib/samagotchi/terminal_ui/formatting.rb +4 -3
  41. data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
  42. data/lib/samagotchi/turn_note.rb +22 -5
  43. data/lib/samagotchi/version.rb +1 -1
  44. data/lib/samagotchi/web/app.rb +2 -1
  45. data/lib/samagotchi/web/public/activity.js +3 -0
  46. data/lib/samagotchi/web/public/app.js +90 -13
  47. data/lib/samagotchi/web/public/delivery_line.js +228 -0
  48. data/lib/samagotchi/web/public/index.html +29 -0
  49. data/lib/samagotchi/web/public/live_turn.js +8 -0
  50. data/lib/samagotchi/web/public/stage_model.js +27 -7
  51. data/lib/samagotchi/web/public/stage_view.js +32 -10
  52. data/lib/samagotchi/web/public/stream_handlers.js +8 -2
  53. data/lib/samagotchi/web/public/turn_events.js +22 -8
  54. data/lib/samagotchi/web/public/turn_html.js +20 -4
  55. data/lib/samagotchi/web/public/turn_model.js +16 -9
  56. data/lib/samagotchi/web/public/turn_view.js +6 -4
  57. metadata +8 -1
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "openai_chat"
4
+ require_relative "usage"
5
+
6
+ module Samagotchi
7
+ module LLM
8
+ # A Messages response put together as ChatResponse, from a whole body
9
+ # (.from_message) or a stream's events (#add, by content-block index):
10
+ # the content blocks
11
+ # in order (kept as received, string keys: ChatResponse#provider_content,
12
+ # which ChatLoop saves and sends back), the text and reasoning they
13
+ # carry, the tool calls, the usage and the stop reason in the chat
14
+ # loop's terms.
15
+ #
16
+ # Usage: Messages counts input_tokens without the cache, so the prompt
17
+ # is input + cache reads + cache writes, in the OpenAI shape the rest of
18
+ # chi reads (TokenUsage: prompt_tokens_details.cached_tokens /
19
+ # cache_write_tokens). Every field is optional (Splash sends no cache
20
+ # writes), and a later value wins per field.
21
+ class AnthropicAssembly
22
+ # stop_reason → ChatResponse#finish_reason (the chat loop's words);
23
+ # pause_turn and anything else pass as they are.
24
+ FINISH_REASONS = {
25
+ "end_turn" => "stop", "stop_sequence" => "stop", "max_tokens" => "length",
26
+ "model_context_window_exceeded" => "length", "tool_use" => "tool_calls", "refusal" => "refusal"
27
+ }.freeze
28
+ USAGE_KEYS = %w[input_tokens output_tokens cache_read_input_tokens cache_creation_input_tokens].freeze
29
+ # Blocks whose text is the answer or the reasoning.
30
+ THINKING = "thinking"
31
+ TEXT = "text"
32
+
33
+ def self.finish_reason(stop_reason) = stop_reason && FINISH_REASONS.fetch(stop_reason.to_s, stop_reason.to_s)
34
+
35
+ # A non-streamed body (a `message` object).
36
+ def self.from_message(body) = new.tap { |assembly| assembly.add_message(body) }
37
+
38
+ def initialize
39
+ @blocks = []
40
+ # A streamed block by its index, and a tool_use's input JSON so far.
41
+ @by_index = {}
42
+ @json = {}
43
+ @usage = {}
44
+ @stop_reason = nil
45
+ @stop_details = nil
46
+ @model = nil
47
+ end
48
+
49
+ # A whole message: its model, usage, stop reason and blocks.
50
+ def add_message(message)
51
+ return unless message.is_a?(Hash)
52
+
53
+ @model = served(message["model"]) || @model
54
+ merge_usage(message["usage"])
55
+ @stop_reason = message["stop_reason"] if message["stop_reason"]
56
+ @stop_details = message["stop_details"] if message["stop_details"].is_a?(Hash)
57
+ Array(message["content"]).each { |block| @blocks << block.dup if block.is_a?(Hash) }
58
+ end
59
+
60
+ # One streamed event: message_start (served model, usage),
61
+ # content_block_start (text / thinking / redacted_thinking / tool_use),
62
+ # content_block_delta (text_delta, thinking_delta, signature_delta,
63
+ # input_json_delta), content_block_stop, message_delta (stop reason,
64
+ # stop_details, usage); ping, message_stop and anything else change nothing.
65
+ # @return [Array(String, String)] the event's text and reasoning
66
+ def add(event)
67
+ case event["type"]
68
+ when "message_start" then start_message(event["message"])
69
+ when "content_block_start" then start_block(event["index"], event["content_block"])
70
+ when "content_block_delta" then add_delta(@by_index[event["index"]], event["index"], event["delta"])
71
+ when "message_delta"
72
+ if event["delta"].is_a?(Hash)
73
+ @stop_reason = event.dig("delta", "stop_reason") || @stop_reason
74
+ @stop_details = event.dig("delta", "stop_details") if event.dig("delta", "stop_details").is_a?(Hash)
75
+ end
76
+ merge_usage(event["usage"])
77
+ ["", ""]
78
+ else ["", ""]
79
+ end
80
+ end
81
+
82
+ # Whether a streamed +event+ shows something (a block started, a delta):
83
+ # what the first-token limit waits for.
84
+ def self.shows?(event) = %w[content_block_start content_block_delta].include?(event["type"])
85
+
86
+ # Whether a streamed +event+ carries part of a tool call.
87
+ def self.tool_call_delta?(event)
88
+ case event["type"]
89
+ when "content_block_start" then event.dig("content_block", "type") == "tool_use"
90
+ when "content_block_delta" then event.dig("delta", "type") == "input_json_delta"
91
+ else false
92
+ end
93
+ end
94
+
95
+ # +event+ as on_delta passes it on: one that carries usage gets the
96
+ # usage so far in the OpenAI shape as its `usage` (SessionMetrics,
97
+ # ContextStatus and ctx.js read that).
98
+ def with_usage(event)
99
+ return event unless %w[message_start message_delta].include?(event["type"]) && (payload = usage_payload)
100
+
101
+ event.merge("usage" => payload)
102
+ end
103
+
104
+ # The usage so far as an OpenAI-shaped `usage` object, nil when the
105
+ # server sent no counts.
106
+ def usage_payload
107
+ return nil if @usage.empty?
108
+
109
+ read = @usage["cache_read_input_tokens"].to_i
110
+ write = @usage["cache_creation_input_tokens"].to_i
111
+ { "prompt_tokens" => @usage["input_tokens"].to_i + read + write, "completion_tokens" => @usage["output_tokens"].to_i,
112
+ "prompt_tokens_details" => { "cached_tokens" => read, "cache_write_tokens" => write } }
113
+ end
114
+
115
+ def response
116
+ blocks = content
117
+ ChatResponse.new(text: joined(blocks, TEXT, "text"), reasoning: joined(blocks, THINKING, "thinking"),
118
+ tool_calls: tool_calls, usage: usage, finish_reason: self.class.finish_reason(@stop_reason),
119
+ model: @model, provider_content: blocks, stop_details: @stop_details)
120
+ end
121
+
122
+ private
123
+
124
+ def usage
125
+ payload = usage_payload
126
+ (payload && Usage.from_payload({ "usage" => payload })) || Usage.none
127
+ end
128
+
129
+ def start_message(message)
130
+ return ["", ""] unless message.is_a?(Hash)
131
+
132
+ @model = served(message["model"]) || @model
133
+ merge_usage(message["usage"])
134
+ ["", ""]
135
+ end
136
+
137
+ def start_block(index, block)
138
+ return ["", ""] unless block.is_a?(Hash)
139
+
140
+ block = block.dup
141
+ @blocks << block
142
+ @by_index[index] = block
143
+ case block["type"]
144
+ when TEXT then [block["text"].to_s, ""]
145
+ when THINKING then ["", block["thinking"].to_s]
146
+ else ["", ""]
147
+ end
148
+ end
149
+
150
+ def add_delta(block, index, delta)
151
+ return ["", ""] unless block && delta.is_a?(Hash)
152
+
153
+ case delta["type"]
154
+ when "text_delta"
155
+ block["text"] = block["text"].to_s + delta["text"].to_s
156
+ [delta["text"].to_s, ""]
157
+ when "thinking_delta"
158
+ block["thinking"] = block["thinking"].to_s + delta["thinking"].to_s
159
+ ["", delta["thinking"].to_s]
160
+ when "signature_delta"
161
+ block["signature"] = block["signature"].to_s + delta["signature"].to_s
162
+ ["", ""]
163
+ when "input_json_delta"
164
+ (@json[index] ||= +"") << delta["partial_json"].to_s
165
+ block["input"] = @json[index]
166
+ ["", ""]
167
+ else ["", ""]
168
+ end
169
+ end
170
+
171
+ def merge_usage(usage)
172
+ return unless usage.is_a?(Hash)
173
+
174
+ USAGE_KEYS.each { |key| @usage[key] = usage[key] if usage[key].is_a?(Integer) }
175
+ end
176
+
177
+ # The blocks to keep and send back: an empty or blank text block is
178
+ # no text (Splash opens one after a thinking cut by max_tokens, and
179
+ # Messages refuses one sent back), a tool_use's input is an object,
180
+ # and a thinking block has a signature, "" when it came without one
181
+ # (a sent-back thinking block needs the key; Splash sends "", a
182
+ # provider may send none).
183
+ def content
184
+ @blocks.filter_map do |block|
185
+ type = block["type"].to_s
186
+ next if type == TEXT && block["text"].to_s.strip.empty?
187
+ next block.merge("signature" => "") if type == THINKING && !block.key?("signature")
188
+ next block unless type == "tool_use"
189
+
190
+ input = OpenAIChat.parse_arguments(block["input"])
191
+ block.merge("input" => input.is_a?(Hash) ? input : {})
192
+ end
193
+ end
194
+
195
+ def joined(blocks, type, key)
196
+ blocks.select { |block| block["type"] == type }.map { |block| block[key].to_s }.join
197
+ end
198
+
199
+ # The calls with their arguments as OpenAIChat.parse_arguments reads
200
+ # them: invalid JSON stays a String, so the tool reports it.
201
+ def tool_calls
202
+ @blocks.select { |block| block["type"] == "tool_use" }.map do |block|
203
+ ToolCall.new(id: block["id"], name: block["name"].to_s, arguments: OpenAIChat.parse_arguments(block["input"]))
204
+ end
205
+ end
206
+
207
+ def served(model) = model.is_a?(String) && !model.strip.empty? ? model : nil
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,416 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+ require_relative "../config"
6
+ require_relative "../sampling_settings"
7
+ require_relative "../thinking"
8
+ require_relative "errors"
9
+ require_relative "http"
10
+ require_relative "model_listing"
11
+ require_relative "openai_chat"
12
+ require_relative "prompt_cache"
13
+ require_relative "anthropic_assembly"
14
+ require_relative "anthropic_wire"
15
+ require_relative "dialect"
16
+
17
+ module Samagotchi
18
+ module LLM
19
+ # Anthropic's Messages API (`POST {base}/v1/messages`: Anthropic's own,
20
+ # a gateway, or a local server such as Splash) on our own HTTP layer, for
21
+ # the same chat loop as OpenAIChat: ChatLoop's OpenAI-shaped wire
22
+ # messages are translated here (AnthropicWire), and the answer comes back
23
+ # as a ChatResponse whose provider_content holds the content blocks to
24
+ # send back verbatim (#replays_content?).
25
+ #
26
+ # A host's dialect (LLM::Dialect, `dialect:`) overrides parts of the
27
+ # standard wire: its default auth header, the thinking `display`, the
28
+ # cache breakpoints and a session-affinity header.
29
+ #
30
+ # Nothing here keys on a model id: fields are sent explicitly, read
31
+ # leniently, and a host's 400 for a field it refuses is what changes a
32
+ # request. Those 400s are handled here, once per request, and
33
+ # remembered for the adapter's life (a host's worker), so ChatLoop's
34
+ # broader refusal paths never see them:
35
+ # - adaptive thinking (or output_config.effort) refused: the model gets
36
+ # the budget form instead (#budget_form), per model;
37
+ # - thinking.display refused (a provider whose thinking schema has no
38
+ # such field, e.g. Fireworks without its dialect): no display for the
39
+ # host, the thinking itself kept;
40
+ # - cache_control refused (Fireworks lists it unsupported): no
41
+ # breakpoints for the host;
42
+ # - max_tokens over the model's output limit: the limit the message
43
+ # names, per model.
44
+ class AnthropicMessages
45
+ include ModelListing
46
+
47
+ API_VERSION = "2023-06-01"
48
+ DEFAULT_MODELS_TTL = 60
49
+ MAX_MODEL_PAGES = 20
50
+ # The budget form's budget_tokens per level (a model that takes no
51
+ # adaptive thinking), and Messages' smallest budget.
52
+ BUDGETS = { low: 2048, medium: 8192, high: 24_576 }.freeze
53
+ MIN_BUDGET = 1024
54
+ # A 400 refusing adaptive thinking or its effort.
55
+ ADAPTIVE_REFUSED_RE = /adaptive|output_config|effort/i
56
+ CACHE_CONTROL_RE = /cache_control/i
57
+ # A 400 refusing the thinking display field: its path (pydantic:
58
+ # "thinking.adaptive.display"), or the field named as unknown or not
59
+ # permitted. Not an error that only echoes the input
60
+ # (input_value={'type': 'adaptive', 'display': ...}).
61
+ DISPLAY_RE = /thinking\.[\w.]*\bdisplay\b|
62
+ (?:unknown|unexpected|unrecognized|extra|invalid)\s+(?:field|parameter|key|input)s?\W{0,4}display\b|
63
+ \bdisplay\W{0,4}(?:extra\s+inputs\s+are\s+not\s+permitted|is\s+not\s+(?:permitted|allowed|supported))/xi
64
+ # "max_tokens: 200000 > 128000, which is the maximum allowed number of output tokens for …"
65
+ OUTPUT_CAP_RE = /max_tokens`?:?\s*\d+\s*>\s*(\d+)/i
66
+ # How much of a 200's non-stream body is read as a possible JSON error.
67
+ PLAIN_ERROR_LIMIT = 64 * 1024
68
+ # max_tokens when default.max_tokens is unset and the model list names
69
+ # none (a listed one is capped by it too).
70
+ DEFAULT_MAX_TOKENS = 64_000
71
+ # chi's OpenAI-only request fields: never sent to a Messages host
72
+ # (configured sampling passes as written).
73
+ DROPPED_OPTIONS = %i[reasoning_effort chat_template_kwargs stream_options prompt_cache_key logprobs top_logprobs
74
+ max_completion_tokens].freeze
75
+
76
+ attr_reader :base_url, :host_name, :api_key_env, :models_ttl, :first_token_timeout, :dialect
77
+
78
+ # @param entry [HostRegistry::HostEntry]
79
+ def self.for(entry, **)
80
+ new(base_url: entry.api_base_url, host_name: entry.name, api_key_env: entry.api_key_env, auth: entry.auth,
81
+ dialect: entry.dialect, remote: entry.remote?, **)
82
+ end
83
+
84
+ # @param base_url [String] the API root without /v1 (HostEntry#api_base_url)
85
+ # @param auth [Symbol, nil] the header the key goes in (ApiKey::SCHEMES);
86
+ # nil: the dialect's (x-api-key on the standard one)
87
+ # @param dialect [String, nil] the host's dialect: name (Dialect.for)
88
+ # The rest as OpenAIChat#initialize takes them.
89
+ def initialize(base_url:, host_name:, api_key_env: nil, auth: nil, dialect: nil, stream: true, retries: true,
90
+ timeout: nil, env: ENV, sleeper: nil, retry_policy: nil, models_ttl: DEFAULT_MODELS_TTL, remote: false,
91
+ first_token_timeout: nil, purpose: "chat", open_timeout: nil)
92
+ @dialect = Dialect.for(dialect)
93
+ @remote = remote
94
+ @purpose = purpose
95
+ @first_token_timeout = first_token_timeout
96
+ @base_url = base_url.to_s.chomp("/")
97
+ @host_name = host_name.to_s
98
+ @api_key_env = api_key_env
99
+ @stream = stream
100
+ init_model_listing(models_ttl)
101
+ @thinking_form_refused = Set.new
102
+ @output_caps = {}
103
+ @cache_control_refused = false
104
+ @thinking_display_refused = false
105
+ open_timeout, read_timeout = timeouts(timeout, open_timeout)
106
+ policy = retry_policy || (retries ? nil : HTTP::RetryPolicy.none)
107
+ @http = HTTP.new(label: @host_name, open_timeout: open_timeout, read_timeout: read_timeout,
108
+ retry_policy: policy, sleeper: sleeper, first_token_timeout: first_token_timeout,
109
+ api_key: ApiKey.for(api_key_env, host: @host_name, env: env, scheme: auth || @dialect.auth))
110
+ end
111
+
112
+ # The same contract as OpenAIChat#chat; +messages+ are ChatLoop's
113
+ # OpenAI-shaped wire messages.
114
+ # @return [ChatResponse]
115
+ def chat(messages:, model:, tools: [], cancel_controller: nil, on_delta: nil, on_retry: nil, options: {},
116
+ session_id: nil)
117
+ fixed = []
118
+ begin
119
+ body = request_body(messages, tools, model, options)
120
+ request = post_request("#{@base_url}/v1/messages", body, session_id: session_id)
121
+ log_fields = { model: model, purpose: @purpose, sampling: sampling_summary(body), cache: cache_summary(body) }
122
+ return chat_once(request, cancel_controller, log_fields) unless @stream
123
+
124
+ chat_streamed(request, cancel_controller, on_delta, on_retry, log_fields)
125
+ rescue BadRequest => e
126
+ fix = refusal_fix(e, body, model)
127
+ raise if fix.nil? || fixed.include?(fix)
128
+
129
+ fixed << fix
130
+ retry
131
+ end
132
+ end
133
+
134
+ # A provider on the network: no llama.cpp /props to ask for the window.
135
+ def remote? = @remote
136
+
137
+ # The response's blocks go back verbatim (ChatLoop#wire_messages
138
+ # copies a model entry's anthropic_content onto its wire message).
139
+ def replays_content? = true
140
+
141
+ # true when a streamed event (as #chat's on_delta gets it) carries
142
+ # part of a tool call.
143
+ def tool_call_delta?(payload) = AnthropicAssembly.tool_call_delta?(payload)
144
+
145
+ # The request fields a thinking +level+ (Thinking::LEVELS) adds on this
146
+ # wire: default nothing (the provider's own default), off
147
+ # `thinking: disabled`, low/medium/high adaptive thinking at that
148
+ # effort, shown the dialect's way (summarized; no display field on a
149
+ # dialect that has none).
150
+ # @return [Hash] frozen
151
+ def thinking_fields(level)
152
+ case level
153
+ when :off then { thinking: { type: "disabled" }.freeze }.freeze
154
+ when *Thinking::EFFORTS
155
+ thinking = { type: "adaptive", display: @dialect.thinking_display }.compact.freeze
156
+ { thinking: thinking, output_config: { effort: level.to_s }.freeze }.freeze
157
+ else {}.freeze
158
+ end
159
+ end
160
+
161
+ # The thinking fields for a short side answer (a recap, ctx.ask_model,
162
+ # a triage) from a model that refused thinking off: an always-thinking
163
+ # model can't skip it, so it thinks at low effort (IdleClient leaves
164
+ # room for the answer).
165
+ # @return [Hash] frozen
166
+ def thinking_off_refused_fields = thinking_fields(:low)
167
+
168
+ # The host's models, every page: GET {base}/v1/models with
169
+ # limit=1000 and after_id paging (has_more/last_id). Anthropic's shape
170
+ # (max_input_tokens, max_tokens, capabilities) and the OpenAI shape a
171
+ # gateway or Splash serves here are both read (ModelListing).
172
+ # Whether +model+ gets the budget form (it refused adaptive thinking).
173
+ def budget_form?(model) = @thinking_form_refused.include?(model.to_s)
174
+
175
+ # @return [Array<ModelInfo>]
176
+ def list_models
177
+ models = []
178
+ after = nil
179
+ MAX_MODEL_PAGES.times do
180
+ query = URI.encode_www_form({ limit: 1000, after_id: after }.compact)
181
+ uri = URI("#{@base_url}/v1/models?#{query}")
182
+ body = parse_json(@http.fetch(uri, Net::HTTP::Get.new(uri), log_fields: { purpose: "models" }).body, "model list")
183
+ models.concat(model_entries(body).map { |raw| model_info(raw) })
184
+ break unless body.is_a?(Hash) && body["has_more"] && body["last_id"]
185
+
186
+ after = body["last_id"].to_s
187
+ end
188
+ models
189
+ end
190
+
191
+ private
192
+
193
+ # Anthropic lists the window as max_input_tokens; else the fields an
194
+ # OpenAI-shaped list carries.
195
+ def window_of(raw)
196
+ window = raw["max_input_tokens"]
197
+ window.is_a?(Integer) && window.positive? ? window : super
198
+ end
199
+
200
+ def timeouts(timeout, open_limit = nil)
201
+ open_timeout = Samagotchi::Config.positive_seconds("server.open_timeout")
202
+ read_timeout = Samagotchi::Config.positive_seconds("server.read_timeout")
203
+ open_timeout = [open_timeout, open_limit].min if open_limit
204
+ return [open_timeout, read_timeout] unless timeout
205
+
206
+ [[open_timeout, timeout].min, timeout]
207
+ end
208
+
209
+ # The Messages body: model, max_tokens, the system blocks, the
210
+ # messages, the stream flag, the tools (tool_choice auto with them),
211
+ # then the options as Messages takes them (chi's OpenAI-only fields
212
+ # dropped; a null drops a field).
213
+ def request_body(messages, tools, model, options)
214
+ options = (options || {}).to_h.transform_keys(&:to_sym).except(*DROPPED_OPTIONS)
215
+ options = budget_form(options) if budget_form?(model)
216
+ options = without_display(options) if @thinking_display_refused
217
+ cache = PromptCache.control if @purpose == "chat" && @dialect.cache_breakpoints && !@cache_control_refused
218
+ translated = AnthropicWire.translate(messages, cache: cache)
219
+ max_tokens = options.delete(:max_tokens) || default_max_tokens(model)
220
+ max_tokens = [max_tokens, @output_caps[model.to_s]].min if @output_caps[model.to_s]
221
+ body = { model: model, max_tokens: max_tokens }
222
+ body[:system] = translated.system unless translated.system.empty?
223
+ body[:messages] = translated.messages
224
+ body[:stream] = @stream
225
+ unless Array(tools).empty?
226
+ body[:tools] = AnthropicWire.tools(tools)
227
+ body[:tool_choice] = { type: "auto" }
228
+ end
229
+ with_budget_room(body.merge(options).compact)
230
+ end
231
+
232
+ # Adaptive thinking at a level as the budget form: thinking enabled
233
+ # with BUDGETS' budget (medium without an effort), shown as asked (no
234
+ # display when it asked none), and no effort (a model without
235
+ # adaptive thinking may take none).
236
+ def budget_form(options)
237
+ thinking = options[:thinking].is_a?(Hash) ? options[:thinking].transform_keys(&:to_sym) : nil
238
+ return options unless thinking && thinking[:type].to_s == "adaptive"
239
+
240
+ config = options[:output_config].is_a?(Hash) ? options[:output_config].transform_keys(&:to_sym) : {}
241
+ budget = BUDGETS.fetch(config[:effort].to_s.to_sym, BUDGETS[:medium])
242
+ config = config.except(:effort)
243
+ options.merge(thinking: { type: "enabled", budget_tokens: budget, display: thinking[:display] }.compact,
244
+ output_config: config.empty? ? nil : config)
245
+ end
246
+
247
+ # +options+ with no thinking.display (the host refused the field).
248
+ def without_display(options)
249
+ thinking = options[:thinking]
250
+ return options unless thinking.is_a?(Hash)
251
+
252
+ options.merge(thinking: thinking.transform_keys(&:to_sym).except(:display))
253
+ end
254
+
255
+ # Messages wants max_tokens > budget_tokens (and a budget of at least
256
+ # MIN_BUDGET): a budget without that room is lowered to leave
257
+ # MIN_BUDGET for the answer; below MIN_BUDGET itself, max_tokens is
258
+ # raised to 2 * MIN_BUDGET with the smallest budget.
259
+ def with_budget_room(body)
260
+ thinking = body[:thinking]
261
+ return body unless thinking.is_a?(Hash) && thinking[:budget_tokens].is_a?(Integer)
262
+
263
+ budget = thinking[:budget_tokens]
264
+ max_tokens = body[:max_tokens].to_i
265
+ return body if max_tokens > budget + MIN_BUDGET
266
+
267
+ budget = max_tokens - MIN_BUDGET
268
+ if budget < MIN_BUDGET
269
+ budget = MIN_BUDGET
270
+ max_tokens = 2 * MIN_BUDGET
271
+ end
272
+ body.merge(max_tokens: max_tokens, thinking: thinking.merge(budget_tokens: budget))
273
+ end
274
+
275
+ # What a 400 for +body+ changes (remembered), or nil: the error goes
276
+ # on as it is.
277
+ def refusal_fix(error, body, model)
278
+ return nil if error.context_overflow? || error.status != 400 || body.nil?
279
+ # Those are the loop's (a sampling field) or P3's (signatures,
280
+ # binding controls): a mention of "effort" in them is no form refusal.
281
+ return nil if error.sampling_refused? || error.thinking_signature? || error.thinking_binding_unsupported?
282
+
283
+ detail = error.detail.to_s
284
+ # Before the adaptive check: the refused field's path
285
+ # ("thinking.adaptive.display") names adaptive too.
286
+ if !@thinking_display_refused && body[:thinking].is_a?(Hash) && body[:thinking].key?(:display) &&
287
+ DISPLAY_RE.match?(detail)
288
+ @thinking_display_refused = true
289
+ Log.info(:http, "thinking_display_refused", host: @host_name, detail: detail[0, 200])
290
+ :thinking_display
291
+ elsif body.dig(:thinking, :type) == "adaptive" && ADAPTIVE_REFUSED_RE.match?(detail)
292
+ @thinking_form_refused << model.to_s
293
+ Log.info(:http, "thinking_form_switched", host: @host_name, model: model.to_s, to: "budget", detail: detail[0, 200])
294
+ :thinking_form
295
+ elsif !@cache_control_refused && CACHE_CONTROL_RE.match?(detail) && cache_summary(body)
296
+ @cache_control_refused = true
297
+ Log.info(:http, "cache_control_refused", host: @host_name, detail: detail[0, 200])
298
+ :cache_control
299
+ elsif (cap = detail[OUTPUT_CAP_RE, 1]&.to_i) && cap.positive? && body[:max_tokens].to_i > cap
300
+ @output_caps[model.to_s] = cap
301
+ Log.info(:http, "output_cap", host: @host_name, model: model.to_s, max_tokens: cap)
302
+ :max_tokens
303
+ end
304
+ end
305
+
306
+ # The model list's max_tokens for +model+ (capped), else the cap.
307
+ def default_max_tokens(model)
308
+ listed = listed_model(model)&.raw&.dig("max_tokens")
309
+ listed.is_a?(Integer) && listed.positive? ? [listed, DEFAULT_MAX_TOKENS].min : DEFAULT_MAX_TOKENS
310
+ rescue StandardError
311
+ DEFAULT_MAX_TOKENS
312
+ end
313
+
314
+ # The log line's cache= : on for breakpoints with the default TTL, 1h
315
+ # for cache.ttl 1h, nil for none.
316
+ def cache_summary(body)
317
+ controls = [*Array(body[:system]), *Array(body[:messages]).flat_map { |m| Array(m[:content]) }]
318
+ .filter_map { |block| block.is_a?(Hash) && (block[:cache_control] || block["cache_control"]) }
319
+ return nil if controls.empty?
320
+
321
+ controls.any? { |control| control[:ttl] == "1h" } ? "1h" : "on"
322
+ end
323
+
324
+ def sampling_summary(body)
325
+ SamplingSettings.log_text(body.except(:model, :system, :messages, :stream, :tools, :tool_choice))
326
+ end
327
+
328
+ def chat_once(request, cancel_controller, log_fields)
329
+ response = @http.fetch(URI(request.uri.to_s), request, cancel_controller: cancel_controller, log_fields: log_fields)
330
+ body = parse_json(response.body, "chat response")
331
+ error = ProviderErrors.from_error_body(response.body.to_s, host: @host_name)
332
+ raise error if error
333
+ raise ProtocolError.new("#{@host_name}: no content in the Messages response", host: @host_name) unless message?(body)
334
+
335
+ AnthropicAssembly.from_message(body).response
336
+ end
337
+
338
+ def message?(body) = body.is_a?(Hash) && body["content"].is_a?(Array)
339
+
340
+ # The SSE stream: each `data:` event goes through the assembly (the
341
+ # `event:` lines are not needed, so a gateway that drops them works);
342
+ # on_delta gets every event but pings, with its text and reasoning.
343
+ def chat_streamed(request, cancel_controller, on_delta, on_retry, log_fields)
344
+ assembly = AnthropicAssembly.new
345
+ events = 0
346
+ other = +""
347
+ plain = +""
348
+ # A retry streams the answer from the start again.
349
+ restart = lambda do |**event|
350
+ assembly = AnthropicAssembly.new
351
+ plain = +""
352
+ on_retry&.call(**event)
353
+ end
354
+ @http.stream_lines(URI(request.uri.to_s), request, cancel_controller: cancel_controller, on_retry: restart,
355
+ log_fields: log_fields) do |line, shown|
356
+ events += 1 if line.start_with?("data:")
357
+ other << line[0, 200] if !line.start_with?("data:") && other.length < 200
358
+ raise_plain_error(plain, line) if events.zero?
359
+ event = parse_line(line)
360
+ next unless event
361
+
362
+ content, reasoning = assembly.add(event)
363
+ shown.call if AnthropicAssembly.shows?(event)
364
+ on_delta&.call(content: content, reasoning: reasoning, payload: assembly.with_usage(event)) unless event["type"] == "ping"
365
+ end
366
+ raise ProtocolError.new("#{@host_name}: the response had no stream events: #{other}", host: @host_name) if events.zero?
367
+
368
+ assembly.response
369
+ end
370
+
371
+ # A 200 whose body is a plain JSON error instead of a stream: raised
372
+ # from inside the stream (so it is retried by its kind) once the lines
373
+ # so far parse as an error object (OpenAIChat's rule).
374
+ def raise_plain_error(plain, line)
375
+ return if line.empty? || line.start_with?(":") || (plain.empty? && !line.start_with?("{"))
376
+ return if plain.bytesize > PLAIN_ERROR_LIMIT
377
+
378
+ plain << line << "\n"
379
+ error = ProviderErrors.from_error_body(plain, host: @host_name)
380
+ raise error if error
381
+ end
382
+
383
+ # The parsed event of a `data:` line; nil for `event:` lines, blank
384
+ # lines and comments. An error event raises its ProviderError.
385
+ def parse_line(line)
386
+ error = HTTP.sse_error(line, host: @host_name)
387
+ raise error if error
388
+ return nil unless line.start_with?("data:")
389
+
390
+ data = line.delete_prefix("data:").strip
391
+ return nil if data.empty? || data == "[DONE]"
392
+
393
+ event = parse_json(data, "stream event")
394
+ event.is_a?(Hash) ? event : nil
395
+ end
396
+
397
+ def parse_json(text, what)
398
+ JSON.parse(text.to_s)
399
+ rescue JSON::ParserError => e
400
+ raise ProtocolError.new("#{@host_name}: malformed #{what}: #{e.message[0, 200]}", host: @host_name)
401
+ end
402
+
403
+ def post_request(url, body, session_id: nil)
404
+ Net::HTTP::Post.new(URI(url)).tap do |request|
405
+ request["Content-Type"] = "application/json"
406
+ request["anthropic-version"] = API_VERSION
407
+ unless session_id.to_s.empty?
408
+ request["Session-Id"] = session_id.to_s
409
+ request[@dialect.session_header] = session_id.to_s if @dialect.session_header
410
+ end
411
+ request.body = JSON.generate(body)
412
+ end
413
+ end
414
+ end
415
+ end
416
+ end