samagotchi 0.63.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -1
  3. data/docs/architecture.md +32 -5
  4. data/docs/cli.md +41 -9
  5. data/docs/configuration.md +143 -11
  6. data/docs/internals/engine.md +25 -6
  7. data/docs/plugins.md +7 -0
  8. data/docs/sessions.md +14 -10
  9. data/docs/sub-agent.md +21 -4
  10. data/docs/testing.md +6 -0
  11. data/lib/samagotchi/answer_command.rb +5 -1
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  13. data/lib/samagotchi/bridge.rb +29 -5
  14. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -3
  16. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/child_reports.rb +7 -2
  19. data/lib/samagotchi/cli/parent_wait.rb +9 -2
  20. data/lib/samagotchi/client.rb +4 -2
  21. data/lib/samagotchi/composer_route.rb +90 -0
  22. data/lib/samagotchi/config.rb +11 -7
  23. data/lib/samagotchi/continue_offer.rb +17 -3
  24. data/lib/samagotchi/delivery.rb +13 -8
  25. data/lib/samagotchi/engine.rb +41 -7
  26. data/lib/samagotchi/generation_phase.rb +17 -0
  27. data/lib/samagotchi/host_config.rb +33 -12
  28. data/lib/samagotchi/host_model.rb +2 -2
  29. data/lib/samagotchi/host_registry.rb +34 -11
  30. data/lib/samagotchi/idle_client.rb +62 -25
  31. data/lib/samagotchi/idle_recap.rb +5 -2
  32. data/lib/samagotchi/idle_target.rb +30 -7
  33. data/lib/samagotchi/interrupted_turn.rb +7 -3
  34. data/lib/samagotchi/last_turn.rb +5 -2
  35. data/lib/samagotchi/llm/adapters.rb +41 -0
  36. data/lib/samagotchi/llm/anthropic_assembly.rb +210 -0
  37. data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
  38. data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
  39. data/lib/samagotchi/llm/api_key.rb +26 -9
  40. data/lib/samagotchi/llm/chat_loop.rb +113 -14
  41. data/lib/samagotchi/llm/dialect.rb +57 -0
  42. data/lib/samagotchi/llm/errors.rb +81 -12
  43. data/lib/samagotchi/llm/model_listing.rb +111 -0
  44. data/lib/samagotchi/llm/model_result.rb +9 -2
  45. data/lib/samagotchi/llm/openai_chat.rb +46 -93
  46. data/lib/samagotchi/model_profile.rb +1 -1
  47. data/lib/samagotchi/pending_input_queue.rb +10 -3
  48. data/lib/samagotchi/plugin/sessions.rb +27 -5
  49. data/lib/samagotchi/reasoning_history.rb +14 -4
  50. data/lib/samagotchi/recap_setup.rb +2 -1
  51. data/lib/samagotchi/reply_wait.rb +171 -6
  52. data/lib/samagotchi/self_report.rb +18 -28
  53. data/lib/samagotchi/send_command.rb +63 -19
  54. data/lib/samagotchi/session.rb +3 -2
  55. data/lib/samagotchi/session_commands.rb +1 -1
  56. data/lib/samagotchi/session_inbox.rb +63 -7
  57. data/lib/samagotchi/session_manager.rb +49 -20
  58. data/lib/samagotchi/session_metrics.rb +6 -11
  59. data/lib/samagotchi/steer_cut.rb +4 -0
  60. data/lib/samagotchi/terminal_ui/attached_loop.rb +55 -28
  61. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  62. data/lib/samagotchi/terminal_ui/formatting.rb +7 -22
  63. data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
  64. data/lib/samagotchi/terminal_ui.rb +72 -57
  65. data/lib/samagotchi/tool_activity.rb +5 -5
  66. data/lib/samagotchi/tools/delegate.rb +28 -1
  67. data/lib/samagotchi/tools/delegate_cursor.rb +9 -4
  68. data/lib/samagotchi/tools/delegate_wait.rb +17 -5
  69. data/lib/samagotchi/tools/execute.rb +11 -4
  70. data/lib/samagotchi/turn_checkpoint.rb +9 -3
  71. data/lib/samagotchi/turn_note.rb +22 -5
  72. data/lib/samagotchi/version.rb +1 -1
  73. data/lib/samagotchi/web/app.rb +2 -1
  74. data/lib/samagotchi/web/public/activity.js +3 -0
  75. data/lib/samagotchi/web/public/app.js +96 -13
  76. data/lib/samagotchi/web/public/commands.js +8 -8
  77. data/lib/samagotchi/web/public/composer.js +3 -3
  78. data/lib/samagotchi/web/public/delivery_line.js +228 -0
  79. data/lib/samagotchi/web/public/index.html +29 -0
  80. data/lib/samagotchi/web/public/live_turn.js +8 -0
  81. data/lib/samagotchi/web/public/stage_model.js +27 -7
  82. data/lib/samagotchi/web/public/stage_view.js +32 -10
  83. data/lib/samagotchi/web/public/stream_handlers.js +8 -2
  84. data/lib/samagotchi/web/public/timing.js +2 -1
  85. data/lib/samagotchi/web/public/turn_events.js +22 -8
  86. data/lib/samagotchi/web/public/turn_html.js +20 -4
  87. data/lib/samagotchi/web/public/turn_model.js +29 -12
  88. data/lib/samagotchi/web/public/turn_view.js +6 -4
  89. data/lib/samagotchi/worker.rb +7 -5
  90. metadata +9 -1
@@ -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
@@ -28,7 +28,8 @@ module Samagotchi
28
28
  # OverBudget 402 metadata.reason weight_exceeds_budget: the
29
29
  # request alone exceeds the key's budget; an
30
30
  # OutOfCredits, never retried
31
- # ServerError 5xx or a server's error event mid-stream
31
+ # ServerError 5xx, or a mid-stream error event with no status or
32
+ # Anthropic error.type
32
33
  # AuthError 401/403, or an API key variable that is not set
33
34
  # BadRequest other 4xx (a context overflow is one, whatever status;
34
35
  # so is a model that can't take tools)
@@ -136,11 +137,14 @@ module Samagotchi
136
137
  TOOLS_HINT = "this model can't use tools, and chi needs them: pick another model (/model) or host"
137
138
 
138
139
  # @param hint [String, nil] what to try, added to the summary
139
- def initialize(message = nil, context_overflow: false, tools_unsupported: false, reasoning_refused: false, hint: nil,
140
- **)
140
+ def initialize(message = nil, context_overflow: false, tools_unsupported: false, reasoning_refused: false,
141
+ thinking_signature: false, thinking_binding_unsupported: false, sampling_refused: false, hint: nil, **)
142
+ @sampling_refused = sampling_refused
141
143
  @context_overflow = context_overflow
142
144
  @tools_unsupported = tools_unsupported
143
145
  @reasoning_refused = reasoning_refused
146
+ @thinking_signature = thinking_signature
147
+ @thinking_binding_unsupported = thinking_binding_unsupported
144
148
  @hint = hint
145
149
  super(message, **)
146
150
  end
@@ -158,6 +162,24 @@ module Samagotchi
158
162
  # be what the host refuses (gpt-oss: "Reasoning is mandatory").
159
163
  def reasoning_refused? = @reasoning_refused
160
164
 
165
+ # A 400 about a replayed thinking block's signature (Anthropic: "Invalid
166
+ # `signature` in `thinking` block", bound to a different conversation
167
+ # or unreadable). The block is what's wrong, not the thinking fields:
168
+ # never #reasoning_refused?.
169
+ def thinking_signature? = @thinking_signature
170
+
171
+ # A 400 refusing the thinking binding controls: the
172
+ # thinking.block_binding field ("Extra inputs are not permitted") or
173
+ # the thinking-binding-controls beta header. Never #reasoning_refused?.
174
+ def thinking_binding_unsupported? = @thinking_binding_unsupported
175
+
176
+ # A 400 that refuses a sampling field (temperature, top_p, top_k):
177
+ # Claude's "temperature may only be set to 1 when thinking is
178
+ # enabled" for the empty-answer retry's temperature. Not
179
+ # reasoning_refused? although it says "thinking": ChatLoop asks again
180
+ # without those fields instead.
181
+ def sampling_refused? = @sampling_refused
182
+
161
183
  def kind = :bad_request
162
184
 
163
185
  def summary
@@ -277,15 +299,35 @@ module Samagotchi
277
299
  # Builds the ProviderError for an HTTP error response or a server's error
278
300
  # event.
279
301
  module ProviderErrors
280
- CONTEXT_OVERFLOW_RE = /exceeds? (the )?(available )?context (size|length|window)|context[_ ]length[_ ]exceeded|exceed_context_size|maximum context length/i
302
+ # A prompt larger than the model's window; Anthropic: "prompt is too
303
+ # long: 208310 tokens > 200000 maximum", or "input length and
304
+ # `max_tokens` exceed context limit: 188240 + 21333 > 200000".
305
+ CONTEXT_OVERFLOW_RE = Regexp.union(
306
+ /exceeds? (the )?(available )?context (size|length|window)|context[_ ]length[_ ]exceeded|exceed_context_size/i,
307
+ /maximum context length|prompt is too long: \d+ tokens/i,
308
+ /input length and `?max_tokens`? exceed context limit: \d+ \+ \d+ > \d+/i
309
+ )
281
310
  # A model that takes no tools: OpenRouter when no endpoint of the model
282
311
  # does ("No endpoints found that support tool use", 404), Ollama ("…
283
312
  # does not support tools"), vLLM started without a tool parser.
284
313
  TOOLS_UNSUPPORTED_RE = /support tool use|(does not|doesn't) support tools|tool choice requires --enable-auto-tool-choice/i
285
314
  # A model or server that can't take images (see VisionUnsupported).
286
315
  VISION_UNSUPPORTED_RE = /support image input|image input is not supported|mmproj/i
287
- # A 400 about the thinking fields chi sent (Thinking.chat_fields).
316
+ # A 400 about the thinking fields chi sent (Thinking.chat_fields). Broad;
317
+ # a signature or binding-control 400 (below) is not one.
288
318
  REASONING_REFUSED_RE = /reasoning|thinking/i
319
+ # Messages' 400 for an assistant turn sent back without its thinking
320
+ # block before tool_use: about the history sent, not a refusal of
321
+ # reasoning (it would turn thinking off for the model).
322
+ THINKING_BLOCK_MISSING_RE = /expected `?thinking`? or `?redacted_thinking`?|must start with a thinking block/i
323
+ # A 400 about a sampling field (BadRequest#sampling_refused?).
324
+ SAMPLING_REFUSED_RE = /\b(temperature|top_p|top_k)\b/i
325
+ # A 400 about a thinking block's signature (BadRequest#thinking_signature?).
326
+ THINKING_SIGNATURE_RE = /invalid `?signature`? in `?thinking`? block|bound to a different conversation/i
327
+ # A 400 refusing the binding controls (BadRequest#thinking_binding_unsupported?):
328
+ # the field without the beta header, or the beta header itself.
329
+ THINKING_BINDING_UNSUPPORTED_RE = Regexp.union(/block_binding:? extra inputs are not permitted/i,
330
+ /unexpected value\(s\) `?thinking-binding-controls/i)
289
331
  # A 402 about credit held by in-flight requests (see CreditsHeld).
290
332
  CREDITS_HELD_RE = /in-flight requests/i
291
333
  # A 402's error.metadata.reason when the request can never fit the
@@ -294,6 +336,20 @@ module Samagotchi
294
336
  # Seconds to wait before retrying a CreditsHeld without a Retry-After.
295
337
  CREDITS_SETTLE_DELAY = 20.0
296
338
  RETRYABLE_SERVER_STATUSES = [500, 502, 503, 504, 529].freeze
339
+ # The HTTP status an Anthropic error.type stands for, for an error
340
+ # object that carries no numeric code (a Messages stream's error event,
341
+ # an OpenAI error whose code is null or a name).
342
+ ERROR_TYPE_STATUS = {
343
+ "invalid_request_error" => 400, "authentication_error" => 401, "billing_error" => 402,
344
+ "permission_error" => 403, "not_found_error" => 404, "request_too_large" => 413,
345
+ "rate_limit_error" => 429, "api_error" => 500, "overloaded_error" => 529, "timeout_error" => 504
346
+ }.freeze
347
+ # The HTTP status a Google-style error.code stands for (Fireworks'
348
+ # errors before a request reaches a model: {"error":{"code":"NOT_FOUND"}}).
349
+ ERROR_CODE_STATUS = {
350
+ "INVALID_ARGUMENT" => 400, "UNAUTHENTICATED" => 401, "PERMISSION_DENIED" => 403, "NOT_FOUND" => 404,
351
+ "RESOURCE_EXHAUSTED" => 429
352
+ }.freeze
297
353
 
298
354
  module_function
299
355
 
@@ -322,10 +378,7 @@ module Samagotchi
322
378
  else
323
379
  OutOfCredits.new(text, **options)
324
380
  end
325
- when 400..499
326
- tools = TOOLS_UNSUPPORTED_RE.match?(message)
327
- BadRequest.new(text, tools_unsupported: tools,
328
- reasoning_refused: status == 400 && !tools && REASONING_REFUSED_RE.match?(message), **options)
381
+ when 400..499 then bad_request(text, status, message, options)
329
382
  when 500..599
330
383
  ServerError.new(text, retryable: RETRYABLE_SERVER_STATUSES.include?(status),
331
384
  retry_after: parse_retry_after(retry_after), **options)
@@ -359,15 +412,31 @@ module Samagotchi
359
412
  from_error_json(body.to_s, host: host)
360
413
  end
361
414
 
415
+ # A BadRequest for a 4xx, with its predicates from +message+.
416
+ def bad_request(text, status, message, options)
417
+ tools = TOOLS_UNSUPPORTED_RE.match?(message)
418
+ signature = THINKING_SIGNATURE_RE.match?(message)
419
+ binding = !signature && THINKING_BINDING_UNSUPPORTED_RE.match?(message)
420
+ sampling = status == 400 && !tools && !signature && !binding && SAMPLING_REFUSED_RE.match?(message)
421
+ missing = THINKING_BLOCK_MISSING_RE.match?(message)
422
+ refused = status == 400 && !tools && !signature && !binding && !sampling && !missing &&
423
+ REASONING_REFUSED_RE.match?(message)
424
+ BadRequest.new(text, tools_unsupported: tools, thinking_signature: signature, thinking_binding_unsupported: binding,
425
+ sampling_refused: sampling, reasoning_refused: refused, **options)
426
+ end
427
+
362
428
  # The ProviderError for a JSON error object, by its error.code (an HTTP
363
- # status); 500 when it has none.
429
+ # status, or a name in ERROR_CODE_STATUS), else by its error.type
430
+ # (ERROR_TYPE_STATUS); 500 when it has neither.
364
431
  def from_error_json(raw, host:)
365
432
  parsed = parse_json(raw)
366
433
  error = parsed.is_a?(Hash) && parsed["error"].is_a?(Hash) ? parsed["error"] : parsed
367
434
  code = error.is_a?(Hash) ? error["code"] : nil
368
435
  code = code.to_i if code.is_a?(String) && code.match?(/\A\d{3}\z/)
369
- status = code.is_a?(Integer) && code.between?(400, 599) ? code : 500
370
- from_response(status: status, body: raw, host: host)
436
+ status = code if code.is_a?(Integer) && code.between?(400, 599)
437
+ status ||= ERROR_CODE_STATUS[code] if code.is_a?(String)
438
+ status ||= ERROR_TYPE_STATUS[error["type"]] if error.is_a?(Hash)
439
+ from_response(status: status || 500, body: raw, host: host)
371
440
  end
372
441
 
373
442
  # The human part of an error body: error.message, message, error (a
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module LLM
5
+ # A model a host lists. Unknown fields are nil.
6
+ ModelInfo = Data.define(:id, :context_window, :supports_tools, :raw) do
7
+ # true when the list says the model takes images, false when it says
8
+ # it doesn't, else nil: OpenRouter's architecture.input_modalities,
9
+ # Anthropic's capabilities.image_input.supported, Splash's top-level
10
+ # input_modalities or vision, Fireworks' supports_image_input,
11
+ # llama.cpp's "multimodal" capability.
12
+ def image_input
13
+ return nil unless raw.is_a?(Hash)
14
+
15
+ modalities = raw.dig("architecture", "input_modalities")
16
+ return modalities.include?("image") if modalities.is_a?(Array)
17
+
18
+ capabilities = raw["capabilities"]
19
+ supported = capabilities.dig("image_input", "supported") if capabilities.is_a?(Hash)
20
+ return supported if [true, false].include?(supported)
21
+ return raw["input_modalities"].include?("image") if raw["input_modalities"].is_a?(Array)
22
+ return raw["vision"] if [true, false].include?(raw["vision"])
23
+ return raw["supports_image_input"] if [true, false].include?(raw["supports_image_input"])
24
+ return true if capabilities.is_a?(Array) && capabilities.include?("multimodal")
25
+
26
+ nil
27
+ end
28
+ end
29
+
30
+ # A chat adapter's model list, kept per models_ttl: what #context_window
31
+ # and #image_input read. The adapter defines #list_models (its wire's
32
+ # GET) and calls #init_model_listing from its initializer.
33
+ module ModelListing
34
+ # The window (tokens) the host lists for +model+, or nil. The list is
35
+ # read once per models_ttl; a failed listing answers nil.
36
+ def context_window(model:)
37
+ listed_model(model)&.context_window
38
+ rescue StandardError
39
+ nil
40
+ end
41
+
42
+ # Whether the host's list says +model+ takes images (ModelInfo#image_input),
43
+ # or nil when it doesn't say or the listing fails.
44
+ def image_input(model:)
45
+ listed_model(model)&.image_input
46
+ rescue StandardError
47
+ nil
48
+ end
49
+
50
+ private
51
+
52
+ def init_model_listing(models_ttl)
53
+ @models_ttl = models_ttl
54
+ @models_mutex = Mutex.new
55
+ end
56
+
57
+ # +model+'s entry in the cached list: its exact id, else any case.
58
+ def listed_model(model)
59
+ cached_models.find { |m| m.id == model.to_s } || cached_models.find { |m| m.id.casecmp?(model.to_s) }
60
+ end
61
+
62
+ def model_entries(body)
63
+ return [] unless body.is_a?(Hash)
64
+
65
+ entries = body["data"].is_a?(Array) ? body["data"] : body["models"]
66
+ Array(entries).select { |entry| entry.is_a?(Hash) }
67
+ end
68
+
69
+ def model_info(raw)
70
+ id = (raw["id"] || raw["model"] || raw["name"]).to_s
71
+ ModelInfo.new(id: id, context_window: window_of(raw), supports_tools: tools_support(raw), raw: raw)
72
+ end
73
+
74
+ # The running window when the list says: context_length, context_window,
75
+ # max_model_len, or llama.cpp's meta.n_ctx (not n_ctx_train).
76
+ def window_of(raw)
77
+ [raw["context_length"], raw["context_window"], raw["max_model_len"], raw.dig("meta", "n_ctx")].each do |value|
78
+ return value if value.is_a?(Integer) && value.positive?
79
+ end
80
+ nil
81
+ end
82
+
83
+ # Whether the list says the model takes tools: OpenRouter's
84
+ # supported_parameters or llama.cpp's capabilities naming "tools"
85
+ # (true, else nil), or Fireworks' supports_tools (true or false).
86
+ def tools_support(raw)
87
+ return raw["supports_tools"] if [true, false].include?(raw["supports_tools"])
88
+
89
+ listed = raw["supported_parameters"] || raw["capabilities"]
90
+ return nil unless listed.is_a?(Array)
91
+
92
+ listed.include?("tools") || nil
93
+ end
94
+
95
+ def cached_models
96
+ @models_mutex.synchronize do
97
+ fresh = @models_at && (monotonic - @models_at) < @models_ttl
98
+ return @models if fresh
99
+ end
100
+ models = list_models
101
+ @models_mutex.synchronize do
102
+ @models = models
103
+ @models_at = monotonic
104
+ end
105
+ models
106
+ end
107
+
108
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
109
+ end
110
+ end
111
+ end