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,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require_relative "../log"
6
+ require_relative "utf8_scrub"
7
+
8
+ module Samagotchi
9
+ module LLM
10
+ # ChatLoop's wire messages (the OpenAI shape #wire_messages builds) as a
11
+ # Messages request's top-level system blocks and messages
12
+ # (AnthropicMessages):
13
+ # - the head system message → the top-level system, as text blocks: with
14
+ # its cache_split, two blocks, the breakpoint on the first; a later
15
+ # system message → user text (a mid-conversation system role is
16
+ # model-gated);
17
+ # - an assistant message → its saved blocks verbatim (anthropic_content),
18
+ # else a text block and its tool_use blocks (arguments as the input
19
+ # object); one with neither is dropped;
20
+ # - tool messages → tool_result blocks in a user message;
21
+ # - user parts → text blocks and image blocks (a data URL as base64, an
22
+ # http(s) URL as a url source).
23
+ # Consecutive messages of one role go as one message (so a turn's tool
24
+ # results and the "[images from tool results]" message after them make
25
+ # one user message), its tool_result blocks first (Messages wants them
26
+ # before any other block). Empty or blank text blocks are never
27
+ # sent; a user message left with none says PLACEHOLDER. reasoning_content
28
+ # and cache_split never go out.
29
+ module AnthropicWire
30
+ PLACEHOLDER = "(empty)"
31
+ # What Messages takes as a tool_use id.
32
+ TOOL_ID_INVALID = /[^a-zA-Z0-9_-]/
33
+ # What Messages takes as a tool name.
34
+ TOOL_NAME = /\A[a-zA-Z0-9_-]{1,128}\z/
35
+ # Blocks that can't carry a cache_control breakpoint.
36
+ UNMARKABLE = %w[thinking redacted_thinking].freeze
37
+
38
+ # A request's system blocks and messages.
39
+ Translated = Data.define(:system, :messages)
40
+
41
+ module_function
42
+
43
+ # @param wire [Array<Hash>] ChatLoop's wire messages (symbol or string keys)
44
+ # @param cache [Hash, nil] the cache_control the breakpoints carry
45
+ # (PromptCache.control): on the head system's first block and on the
46
+ # last block that didn't come from a later system message (a tail
47
+ # note moves each request, as PromptCache's rule says); nil sets none
48
+ # @return [Translated]
49
+ def translate(wire, cache: nil)
50
+ system = []
51
+ messages = []
52
+ notes = {}.compare_by_identity
53
+ Array(wire).each_with_index do |raw, index|
54
+ message = raw.to_h.transform_keys(&:to_sym)
55
+ role = message[:role].to_s
56
+ if role == "system" && index.zero?
57
+ system = system_blocks(message[:content], message[:cache_split], cache)
58
+ next
59
+ end
60
+
61
+ case role
62
+ when "assistant" then append(messages, "assistant", assistant_blocks(message))
63
+ when "tool" then append(messages, "user", [tool_result(message)])
64
+ when "system"
65
+ blocks = content_blocks(message[:content])
66
+ blocks.each { |block| notes[block] = true }
67
+ append(messages, "user", blocks)
68
+ else append(messages, "user", content_blocks(message[:content]))
69
+ end
70
+ end
71
+ messages.each { |message| message[:content] << text_block(PLACEHOLDER) if message[:content].empty? }
72
+ mark_last(messages, cache, notes) if cache
73
+ Translated.new(system: system, messages: messages)
74
+ end
75
+
76
+ # ChatLoop's function definitions as Messages tools.
77
+ # @param tools [Array<Hash>] {type: "function", function: {name:, description:, parameters:}}
78
+ def tools(tools)
79
+ Array(tools).map do |tool|
80
+ function = (tool[:function] || tool["function"] || tool).to_h.transform_keys(&:to_sym)
81
+ { name: function[:name].to_s, description: function[:description],
82
+ input_schema: function[:parameters] || { type: "object", properties: {} } }.compact
83
+ end
84
+ end
85
+
86
+ # +id+ as Messages takes it (^[a-zA-Z0-9_-]+$), the same in tool_use
87
+ # and tool_result: a valid id as it is, another with its invalid
88
+ # characters replaced and a short hash of it appended (so "a.b" and
89
+ # "a_b" stay apart).
90
+ def tool_id(id)
91
+ text = id.to_s
92
+ return text if !text.empty? && !text.match?(TOOL_ID_INVALID)
93
+
94
+ "#{text.gsub(TOOL_ID_INVALID, "_")}_#{Digest::SHA256.hexdigest(text)[0, 8]}"
95
+ end
96
+
97
+ # A tool name as Messages takes it (TOOL_NAME): a model's malformed one
98
+ # ("get_weather\n</parameter", a streaming glitch) would 400 every
99
+ # later request once sent back.
100
+ def tool_name(name)
101
+ text = name.to_s
102
+ return text if text.match?(TOOL_NAME)
103
+
104
+ text = text.gsub(TOOL_ID_INVALID, "_")[0, 128]
105
+ text.empty? ? "tool" : text
106
+ end
107
+
108
+ # A message joins the one before it when the role is the same; an
109
+ # assistant message with no blocks is dropped.
110
+ def append(messages, role, blocks)
111
+ return if role == "assistant" && blocks.empty?
112
+
113
+ if messages.last && messages.last[:role] == role
114
+ content = messages.last[:content].concat(blocks)
115
+ results, rest = content.partition { |block| type_of(block) == "tool_result" }
116
+ messages.last[:content] = results + rest
117
+ else
118
+ messages << { role: role, content: blocks }
119
+ end
120
+ end
121
+
122
+ def system_blocks(content, split, cache)
123
+ text = scrub(content.to_s)
124
+ return [] if blank?(text)
125
+
126
+ head = split.is_a?(Integer) && split.positive? && split < text.length ? text[0, split] : text
127
+ tail = head.length < text.length ? text[head.length..] : ""
128
+ first = text_block(head)
129
+ first[:cache_control] = cache if cache
130
+ blank?(tail) ? [first] : [first, text_block(tail)]
131
+ end
132
+
133
+ # The saved blocks go back as they came (string keys); otherwise the
134
+ # text, then the calls.
135
+ def assistant_blocks(message)
136
+ saved = message[:anthropic_content]
137
+ return saved.map { |block| valid_tool_use(block) } if saved.is_a?(Array) && !saved.empty?
138
+
139
+ blocks = content_blocks(message[:content])
140
+ Array(message[:tool_calls]).each do |raw|
141
+ call = raw.to_h.transform_keys(&:to_sym)
142
+ function = (call[:function] || {}).to_h.transform_keys(&:to_sym)
143
+ name = function[:name].to_s
144
+ blocks << { type: "tool_use", id: tool_id(call[:id]), name: tool_name(name), input: input_of(function[:arguments], name) }
145
+ end
146
+ blocks
147
+ end
148
+
149
+ # A saved block as it came, but a tool_use's name made valid and its
150
+ # id mapped as its tool_result's is (#tool_id: a provider may answer
151
+ # an id such as "functions.get_weather:0", which Messages refuses
152
+ # sent back, and the result would name another id).
153
+ def valid_tool_use(block)
154
+ return block.dup unless block.is_a?(Hash) && type_of(block) == "tool_use"
155
+
156
+ name_key = block.key?("name") ? "name" : :name
157
+ id_key = block.key?("id") ? "id" : :id
158
+ block.merge(name_key => tool_name(block[name_key]), id_key => tool_id(block[id_key]))
159
+ end
160
+
161
+ # A tool call's arguments as the input object: ChatLoop sends a JSON
162
+ # String (raw when it was invalid, for a paired call): one that isn't
163
+ # an object goes as {}.
164
+ def input_of(arguments, name)
165
+ return arguments if arguments.is_a?(Hash)
166
+
167
+ text = arguments.to_s
168
+ return {} if text.strip.empty?
169
+
170
+ parsed = JSON.parse(text)
171
+ return parsed if parsed.is_a?(Hash)
172
+
173
+ warn_arguments(name)
174
+ rescue JSON::ParserError
175
+ warn_arguments(name)
176
+ end
177
+
178
+ def warn_arguments(name)
179
+ Log.warn(:model, "tool_arguments_invalid", tool: name, detail: "sent as {} to a Messages host")
180
+ {}
181
+ end
182
+
183
+ def tool_result(message)
184
+ block = { type: "tool_result", tool_use_id: tool_id(message[:tool_call_id]) }
185
+ text = scrub(message[:content].to_s)
186
+ block[:content] = text unless blank?(text)
187
+ block
188
+ end
189
+
190
+ # A String or a parts list as blocks.
191
+ def content_blocks(content)
192
+ return blank?(content.to_s) ? [] : [text_block(scrub(content.to_s))] unless content.is_a?(Array)
193
+
194
+ content.filter_map do |raw|
195
+ next unless raw.is_a?(Hash)
196
+
197
+ part = raw.transform_keys(&:to_sym)
198
+ case part[:type].to_s
199
+ when "text" then blank?(part[:text].to_s) ? nil : text_block(scrub(part[:text].to_s))
200
+ when "image_url" then image_block(part[:image_url])
201
+ when "image" then raw
202
+ end
203
+ end
204
+ end
205
+
206
+ def image_block(image_url)
207
+ url = image_url.is_a?(Hash) ? (image_url[:url] || image_url["url"]).to_s : image_url.to_s
208
+ if (match = url.match(/\Adata:([^;,]+);base64,(.*)\z/m))
209
+ { type: "image", source: { type: "base64", media_type: match[1], data: match[2] } }
210
+ elsif url.match?(%r{\Ahttps?://}i)
211
+ { type: "image", source: { type: "url", url: url } }
212
+ end
213
+ end
214
+
215
+ # Breakpoint #2: the last user block that can carry one and didn't
216
+ # come from a later system message (+notes+).
217
+ def mark_last(messages, cache, notes)
218
+ markable = ->(block) { !UNMARKABLE.include?(type_of(block)) && !notes.key?(block) }
219
+ message = messages.reverse_each.find { |m| m[:role] == "user" && m[:content].any?(&markable) } or return
220
+ index = message[:content].rindex(&markable)
221
+ block = message[:content][index]
222
+ key = block.key?("type") ? "cache_control" : :cache_control
223
+ message[:content][index] = block.merge(key => cache)
224
+ end
225
+
226
+ def type_of(block) = (block[:type] || block["type"]).to_s
227
+
228
+ def text_block(text) = { type: "text", text: text }
229
+
230
+ def blank?(text) = text.strip.empty?
231
+
232
+ def scrub(text) = Utf8Scrub.call(text)
233
+
234
+ private_class_method :append, :system_blocks, :assistant_blocks, :input_of, :warn_arguments, :tool_result,
235
+ :content_blocks, :image_block, :mark_last, :valid_tool_use, :type_of, :text_block, :blank?, :scrub
236
+ end
237
+ end
238
+ end
@@ -5,14 +5,23 @@ require_relative "errors"
5
5
  module Samagotchi
6
6
  module LLM
7
7
  # A host's API key: the environment variable its api_key_env: names,
8
- # sent as `Authorization: Bearer <key>` on every request LLM::HTTP makes
9
- # for the host (llama.cpp started with --api-key, or a provider). The
10
- # key itself never goes into a message.
11
- ApiKey = Data.define(:env_name, :host, :env) do
8
+ # sent on every request LLM::HTTP makes for the host (llama.cpp started
9
+ # with --api-key, or a provider) in the header its +scheme+ names:
10
+ # :bearer `Authorization: Bearer <key>`, :x_api_key `x-api-key: <key>`
11
+ # (Anthropic's Messages API, which refuses a request carrying both).
12
+ # The key itself never goes into a message.
13
+ ApiKey = Data.define(:env_name, :host, :env, :scheme) do
12
14
  # nil for a host without api_key_env: its requests carry no header.
13
- def self.for(env_name, host:, env: ENV)
15
+ # @param scheme [Symbol, nil] one of ApiKey::SCHEMES; nil is :bearer
16
+ def self.for(env_name, host:, env: ENV, scheme: nil)
14
17
  name = env_name.to_s.strip
15
- name.empty? ? nil : new(env_name: name, host: host.to_s, env: env)
18
+ name.empty? ? nil : new(env_name: name, host: host.to_s, env: env, scheme: scheme || :bearer)
19
+ end
20
+
21
+ def initialize(env_name:, host:, env:, scheme: :bearer)
22
+ raise ArgumentError, "unknown auth scheme #{scheme.inspect}" unless self.class::SCHEMES.include?(scheme)
23
+
24
+ super
16
25
  end
17
26
 
18
27
  # What to try after a 401/403 from a host that has no api_key_env.
@@ -20,22 +29,30 @@ module Samagotchi
20
29
  "the server may want an API key: put it in an environment variable and name it with api_key_env: on host #{host}"
21
30
  end
22
31
 
23
- # Sets the header, or raises AuthError when the variable is not set.
32
+ # Sets the scheme's header (exactly one of the two), or raises
33
+ # AuthError when the variable is not set.
24
34
  def authorize(request)
25
35
  key = env[env_name].to_s
26
36
  raise AuthError.new("#{host}: set #{env_name} (the API key for host #{host})", host: host) if key.strip.empty?
27
37
 
28
- request["Authorization"] = "Bearer #{key}"
38
+ if scheme == :x_api_key
39
+ request.delete("Authorization")
40
+ request["x-api-key"] = key
41
+ else
42
+ request.delete("x-api-key")
43
+ request["Authorization"] = "Bearer #{key}"
44
+ end
29
45
  end
30
46
 
31
47
  # What to try after a 401/403 with the key sent.
32
48
  def hint = "check #{env_name} (the API key for host #{host})"
33
49
 
34
50
  # Without the environment: it holds this key and every other secret.
35
- def inspect = "#<#{self.class.name} #{env_name} host=#{host}>"
51
+ def inspect = "#<#{self.class.name} #{env_name} host=#{host}#{" scheme=#{scheme}" unless scheme == :bearer}>"
36
52
  alias_method :to_s, :inspect
37
53
 
38
54
  def pretty_print(printer) = printer.text(inspect)
39
55
  end
56
+ ApiKey::SCHEMES = %i[bearer x_api_key].freeze
40
57
  end
41
58
  end
@@ -49,6 +49,10 @@ module Samagotchi
49
49
  class ChatLoop < ModelBackend
50
50
  include LLM::StreamDrops
51
51
 
52
+ # The line after an answer an OpenAI-compatible host's content filter
53
+ # cut (finish_reason content_filter with text): kept, not asked again.
54
+ FILTERED_MARK = "[cut off by the provider's content filter]"
55
+
52
56
  attr_accessor :adapter
53
57
  # The session every request is for; Engine sets it with the adapter.
54
58
  # Goes out as a Session-Id header (OpenAIChat#chat).
@@ -147,6 +151,11 @@ module Samagotchi
147
151
  conversation = llm_context_view.messages(conversation)
148
152
  paired = paired_call_ids(conversation)
149
153
  boundary = reasoning_boundary(conversation, since: since, level: reasoning)
154
+ # The last reply with tool calls keeps its thinking blocks past the
155
+ # boundary: a run stopped between steps (Stop, the step limit) left
156
+ # its tool_use last, and Messages wants the thinking before it.
157
+ last_calls = conversation.rindex { |entry| entry[:role].to_s == "model" }
158
+ last_calls = nil unless last_calls && !Array(conversation[last_calls][:tool_calls]).empty?
150
159
  plan = ImagePlan.new(conversation, vision)
151
160
  tool_images = []
152
161
  wire = []
@@ -162,6 +171,8 @@ module Samagotchi
162
171
  when "model"
163
172
  message = assistant_message(entry, paired)
164
173
  message[:reasoning_content] = entry[:thinking] if boundary && index >= boundary && thought?(entry)
174
+ blocks = replay_blocks(entry, paired, keep_thinking: (boundary && index >= boundary) || index == last_calls)
175
+ message[:anthropic_content] = blocks if blocks
165
176
  wire << message
166
177
  when "tool_response"
167
178
  id = entry[:tool_call_id]
@@ -226,13 +237,20 @@ module Samagotchi
226
237
 
227
238
  # The turn's ReasoningHistory level (the kernel's turn_settings); drop
228
239
  # without settings, and for a +model+ whose host refused it
229
- # (#reasoning_history_refused!).
240
+ # (#reasoning_history_refused!). An adapter that sends its blocks back
241
+ # (#replays_content?) is floored at turn: the running run's thinking
242
+ # blocks always go back (a tool loop with thinking needs the last
243
+ # assistant turn's), whatever the level says.
230
244
  def reasoning_history(model = nil)
231
- return :drop if model && @reasoning_history_refused&.include?(model)
232
-
233
- @kernel.turn_settings&.reasoning_history || :drop
245
+ level = model && @reasoning_history_refused&.include?(model) ? :drop : @kernel.turn_settings&.reasoning_history
246
+ level ||= :drop
247
+ level == :drop && replays_content? ? :turn : level
234
248
  end
235
249
 
250
+ # Whether the adapter sends a model entry's saved blocks back verbatim
251
+ # (AnthropicMessages); the OpenAI wire doesn't.
252
+ def replays_content? = @adapter.respond_to?(:replays_content?) && @adapter.replays_content?
253
+
236
254
  # The host refused +model+'s reasoning sent back: send none from now on
237
255
  # (this worker's lifetime, as #thinking_refused!).
238
256
  def reasoning_history_refused!(model)
@@ -252,12 +270,13 @@ module Samagotchi
252
270
  # The turn's thinking level (the kernel's turn_settings).
253
271
  def thinking = @kernel.turn_settings.thinking || Thinking::DEFAULT
254
272
 
255
- # The request fields the thinking level adds (Thinking.chat_fields);
256
- # none for a model whose host refused them (#thinking_refused!).
273
+ # The request fields the thinking level adds, as the adapter's wire
274
+ # takes them (OpenAIChat#thinking_fields: Thinking.chat_fields); none
275
+ # for a model whose host refused them (#thinking_refused!).
257
276
  def thinking_fields(model = nil)
258
277
  return {} if model && (@thinking_refused ||= Set.new).include?(model)
259
278
 
260
- Thinking.chat_fields(thinking)
279
+ adapter.thinking_fields(thinking)
261
280
  end
262
281
 
263
282
  # The host refused +model+'s thinking fields: leave them out from now on.
@@ -270,14 +289,26 @@ module Samagotchi
270
289
  # under the sampling (a sampling key wins, chat_template_kwargs merges
271
290
  # per sub-key), the empty-answer retry's temperature on top, then every
272
291
  # null dropped at any depth (a sampling null means "don't send it").
292
+ # A +model+ whose host refused a sampling field (#sampling_refused!)
293
+ # gets none of SAMPLING_KEYS.
273
294
  def request_options(retry_generation: false, model: nil)
274
295
  max_tokens = Samagotchi::Config.max_tokens
275
296
  options = deep_merge(thinking_fields(model), sampling)
276
297
  options = { max_tokens: max_tokens }.merge(options) if max_tokens
277
298
  options = EmptyAnswerRetry.sampling(options) if retry_generation
299
+ options = options.except(*SAMPLING_KEYS) if model && @sampling_refused&.include?(model)
278
300
  deep_compact(options)
279
301
  end
280
302
 
303
+ # The fields a sampling refusal (BadRequest#sampling_refused?) drops.
304
+ SAMPLING_KEYS = %i[temperature top_p top_k].freeze
305
+
306
+ # The host refused a sampling field for +model+: send none of
307
+ # SAMPLING_KEYS from now on (this worker's lifetime, as #thinking_refused!).
308
+ def sampling_refused!(model)
309
+ (@sampling_refused ||= Set.new) << model
310
+ end
311
+
281
312
  def strip_model_thought(text) = @kernel.strip_model_thought(text)
282
313
 
283
314
  def tool_runner
@@ -323,6 +354,7 @@ module Samagotchi
323
354
  message[:turn_id] = entry[:turn_id] if entry[:turn_id]
324
355
  message[:turn_start] = true if entry[:turn_start] == true
325
356
  message[:thinking] = entry[:thinking] if entry[:thinking].is_a?(String) && !entry[:thinking].empty?
357
+ message[:anthropic_content] = entry[:anthropic_content] if entry[:anthropic_content].is_a?(Array)
326
358
  ToolResponse::SAVED_KEYS.each { |key| message[key] = entry[key] if entry[key] }
327
359
  message[TurnNote::RETRY_NUDGE] = true if TurnNote.retry_nudge?(entry)
328
360
  marker = TurnNote.empty_answer(entry)
@@ -408,6 +440,24 @@ module Samagotchi
408
440
 
409
441
  def thought?(entry) = entry[:thinking].is_a?(String) && !entry[:thinking].strip.empty?
410
442
 
443
+ THINKING_BLOCKS = %w[thinking redacted_thinking].freeze
444
+
445
+ # A model entry's saved blocks (anthropic_content) to send back
446
+ # verbatim, for an adapter that replays them: nil when there are none,
447
+ # for an interrupted entry (its partial blocks have no signature), and
448
+ # for one with unpaired calls (it goes as flattened text: a tool_use
449
+ # without its tool_result would fail every later request). Before the
450
+ # reasoning boundary (+keep_thinking+ false) the thinking blocks are
451
+ # left out, which removes them only from the front of the history.
452
+ def replay_blocks(entry, paired, keep_thinking:)
453
+ blocks = entry[:anthropic_content]
454
+ return nil unless replays_content? && blocks.is_a?(Array) && !blocks.empty? && !entry[:interrupted]
455
+ return nil unless Array(entry[:tool_calls]).all? { |call| paired.include?(call[:id]) }
456
+
457
+ blocks = blocks.reject { |block| block.is_a?(Hash) && THINKING_BLOCKS.include?((block["type"] || block[:type]).to_s) } unless keep_thinking
458
+ blocks.empty? ? nil : blocks
459
+ end
460
+
411
461
  def scrub(text)
412
462
  text.encoding == Encoding::UTF_8 && !text.valid_encoding? ? text.scrub("?") : text
413
463
  end
@@ -415,6 +465,11 @@ module Samagotchi
415
465
  # One turn's state: the conversation, the stream sink and tool
416
466
  # activity.
417
467
  class Run
468
+ # Messages' refusal; OpenAI's content filter, a refusal only when
469
+ # it left no text (else the text stays, marked FILTERED_MARK).
470
+ REFUSAL = "refusal"
471
+ FILTERED = "content_filter"
472
+
418
473
  # +start+: where the run a step-limit Continue resumes started (nil:
419
474
  # this run starts at the conversation's end).
420
475
  def initialize(loop, conversation, on_stream_event, cancel_controller, model_name, pending_input, start: nil)
@@ -457,13 +512,22 @@ module Samagotchi
457
512
  end
458
513
 
459
514
  last_text = @loop.strip_model_thought(response.text)
460
- if response.tool_calls.empty?
515
+ # A provider's refusal (or its content filter) ends the turn here:
516
+ # tool calls that came with it are not run.
517
+ @refusal = refusal_of(response, last_text)
518
+ stopped = @refusal || response.finish_reason.to_s == FILTERED
519
+ if response.tool_calls.empty? || stopped
520
+ # A refusal is no answer, never asked again; text it streamed
521
+ # before is not kept as one. A filtered answer's text stays,
522
+ # marked as cut by the filter.
523
+ last_text = "" if @refusal
524
+ last_text = "#{last_text}\n#{FILTERED_MARK}" if !@refusal && response.finish_reason.to_s == FILTERED
461
525
  # Only whitespace is no answer either (as in the native loop).
462
526
  empty = last_text.strip.empty?
463
527
  # Kept before a merge too: the model answers the merged line
464
528
  # knowing what it just said.
465
529
  @conversation << with_thinking({ role: "model", content: last_text }, response) unless empty
466
- retry_empty = empty &&
530
+ retry_empty = empty && !stopped &&
467
531
  @empty_retry.retry_empty?(iteration: iteration, cancelled: @cancel_controller&.cancelled?,
468
532
  finish_reason: response.finish_reason,
469
533
  used_tokens: response.usage&.total_tokens, window_tokens: @window&.tokens)
@@ -495,6 +559,18 @@ module Samagotchi
495
559
 
496
560
  private
497
561
 
562
+ # A provider's refusal as ModelResult#refusal ({category:} when its
563
+ # stop_details name one): finish reason refusal, or content_filter
564
+ # with no visible +text+.
565
+ def refusal_of(response, text)
566
+ reason = response.finish_reason.to_s
567
+ return nil unless reason == REFUSAL || (reason == FILTERED && text.strip.empty?)
568
+
569
+ details = response.stop_details
570
+ category = details["category"] || details[:category] if details.is_a?(Hash)
571
+ category.to_s.strip.empty? ? {} : { category: category.to_s }
572
+ end
573
+
498
574
  # The hidden nudge before the next generation (EmptyAnswerRetry),
499
575
  # which runs at the retry temperature. Returns true.
500
576
  def nudge_empty_answer(iteration, response)
@@ -521,10 +597,14 @@ module Samagotchi
521
597
  # The host's reasoning, kept on the model message as +thinking+ for
522
598
  # the web turn view's reload (the whole of it, as the live view
523
599
  # shows), and sent back to the model as ChatLoop#wire_messages says
524
- # (reasoning_history).
600
+ # (reasoning_history). A wire that sends its blocks back keeps them
601
+ # as +anthropic_content+ (ChatResponse#provider_content: string
602
+ # keys, as received, so a resumed session sends the same bytes).
525
603
  def with_thinking(message, response)
526
604
  reasoning = response.reasoning.to_s
527
- reasoning.strip.empty? ? message : message.merge(thinking: reasoning)
605
+ message = message.merge(thinking: reasoning) unless reasoning.strip.empty?
606
+ blocks = response.provider_content
607
+ blocks.is_a?(Array) && !blocks.empty? ? message.merge(anthropic_content: blocks) : message
528
608
  end
529
609
 
530
610
  # One streamed request. Returns [response, nil], or [reason, partial
@@ -551,7 +631,11 @@ module Samagotchi
551
631
  begin
552
632
  ask.call
553
633
  rescue BadRequest => e
554
- if refused?(e) && reasoning_sent? then without_reasoning(iteration, e, ask)
634
+ # A wire that replays its blocks sends them under the turn
635
+ # floor anyway: asking again without reasoning would resend
636
+ # the same body.
637
+ if e.sampling_refused? && sampling_sent?(retry_generation) then without_sampling(iteration, e, ask)
638
+ elsif refused?(e) && reasoning_sent? && !@loop.replays_content? then without_reasoning(iteration, e, ask)
555
639
  elsif thinking_refused?(e) then without_thinking(iteration, e, ask)
556
640
  else raise
557
641
  end
@@ -613,7 +697,7 @@ module Samagotchi
613
697
  event = { type: :generation_chunk, iteration: iteration, content: reasoning + content, text: content,
614
698
  thinking: reasoning, payload: payload }
615
699
  # A tool call streaming: a steer doesn't cut it (Engine#cut_for_steer).
616
- tool_call = OpenAIChat.tool_call_delta?(payload)
700
+ tool_call = @loop.adapter.tool_call_delta?(payload)
617
701
  event[:tool_call] = true if tool_call
618
702
  streamed_any&.call if !content.to_s.empty? || !reasoning.to_s.empty? || tool_call
619
703
  emit(event)
@@ -708,6 +792,21 @@ module Samagotchi
708
792
  @without_reasoning = false
709
793
  end
710
794
 
795
+ # Whether this step's request sends a sampling field.
796
+ def sampling_sent?(retry_generation)
797
+ options = @loop.request_options(retry_generation: retry_generation, model: @model_name)
798
+ SAMPLING_KEYS.any? { |key| options.key?(key) }
799
+ end
800
+
801
+ # Once per model: asked again without the sampling fields (the
802
+ # thinking fields stay: an effort level with the empty-answer
803
+ # retry's temperature keeps thinking on).
804
+ def without_sampling(iteration, error, ask)
805
+ @loop.sampling_refused!(@model_name)
806
+ emit(type: :sampling_refused, iteration: iteration, model: @model_name, detail: error.detail)
807
+ ask.call
808
+ end
809
+
711
810
  # Once per model: asked again without the thinking fields.
712
811
  def without_thinking(iteration, error, ask)
713
812
  @loop.thinking_refused!(@model_name)
@@ -814,7 +913,7 @@ module Samagotchi
814
913
  @loop.apply_llm_context!(@conversation, moment: :turn_end, emit: method(:emit)) unless exhausted
815
914
  ModelResult.new(text: text, conversation: @loop.plain(@conversation), exhausted: exhausted,
816
915
  tool_activity: @tool_activity, empty_steps: @empty_steps, empty_retries: @empty_retry.attempts,
817
- context_status: @context.display)
916
+ context_status: @context.display, refusal: (@refusal unless exhausted))
818
917
  end
819
918
 
820
919
  def emit(event)
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module LLM
5
+ # How a provider's Messages API (`api: anthropic`) departs from
6
+ # Anthropic's own: a small set of overrides on the standard wire, picked
7
+ # per host with `dialect:` (HostConfig#dialect, HostEntry#dialect_profile).
8
+ # A host without one is STANDARD (Anthropic, Splash, a gateway passing
9
+ # Claude through).
10
+ #
11
+ # name: the config value ("standard" for none);
12
+ # auth: the header the key goes in when the host sets no auth:
13
+ # (LLM::ApiKey::SCHEMES);
14
+ # thinking_display: the `display` sent with adaptive or budget thinking,
15
+ # nil for none;
16
+ # cache_breakpoints: whether chat requests carry cache_control;
17
+ # session_header: a header that carries the session id (beside
18
+ # Session-Id), nil for none.
19
+ Dialect = Data.define(:name, :auth, :thinking_display, :cache_breakpoints, :session_header) do
20
+ # The dialect for a `dialect:` name (HostConfig#dialect); STANDARD for
21
+ # nil or an unknown one.
22
+ def self.for(name) = self::BY_NAME.fetch(name.to_s, self::STANDARD)
23
+
24
+ # A hosts entry's `dialect:` as written: the name, or nil when unset.
25
+ # An unknown name, or one on a host whose api isn't anthropic, warns
26
+ # once (naming +where+) and is ignored.
27
+ # @param api [Symbol, nil] the entry's api
28
+ # @return [String, nil]
29
+ def self.parse(value, where, api:)
30
+ text = value.to_s.strip.downcase
31
+ return nil if text.empty?
32
+
33
+ require_relative "../config"
34
+
35
+ unless self::BY_NAME.key?(text)
36
+ ConfigFile.warn_once "Warning: #{where}: dialect must be one of #{self::BY_NAME.keys.join(", ")}; ignored"
37
+ return nil
38
+ end
39
+ return text if api&.to_sym == :anthropic
40
+
41
+ ConfigFile.warn_once "Warning: #{where}: dialect has no effect on api: #{api || "(none)"}; ignored"
42
+ nil
43
+ end
44
+ end
45
+
46
+ Dialect::STANDARD = Dialect.new(name: "standard", auth: :x_api_key, thinking_display: "summarized",
47
+ cache_breakpoints: true, session_header: nil)
48
+ # Fireworks' /v1/messages (docs.fireworks.ai, anthropic-compatibility):
49
+ # Bearer auth, no `display` in its thinking schema (every object refuses
50
+ # extra fields; thinking comes back by default), an automatic prefix
51
+ # cache (no breakpoints), and x-session-affinity to keep a session on
52
+ # one replica, where its cache is.
53
+ Dialect::FIREWORKS = Dialect.new(name: "fireworks", auth: :bearer, thinking_display: nil,
54
+ cache_breakpoints: false, session_header: "x-session-affinity")
55
+ Dialect::BY_NAME = { "standard" => Dialect::STANDARD, "fireworks" => Dialect::FIREWORKS }.freeze
56
+ end
57
+ end