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
@@ -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
@@ -15,17 +15,24 @@ module Samagotchi
15
15
  # conversation) and .empty_retries (the retries spent): the Engine saves
16
16
  # them on the turn's note for the UIs (TurnNote.empty).
17
17
  #
18
+ # .refusal is set when the provider refused the answer (Messages'
19
+ # stop_reason refusal, OpenAI's content_filter): {category:} as the
20
+ # provider names it, {} without one; nil otherwise. Such a turn is an
21
+ # empty answer the loop never asked again.
22
+ #
18
23
  # .run_start is where the turn's run started in the conversation (the
19
24
  # length of the messages the loop got), set by the Engine after the loop
20
25
  # returns; nil from a loop called directly. A step-limit offer keeps it
21
26
  # (TurnFlow), so a Continue resumes that run (Engine#run_turn resume_from:).
22
27
  class ModelResult
23
28
  attr_reader :text, :conversation, :canceled, :cancellation_reason, :exhausted,
24
- :tool_activity, :context_status, :pending_tool_calls, :empty_steps, :empty_retries
29
+ :tool_activity, :context_status, :pending_tool_calls, :empty_steps, :empty_retries, :refusal
25
30
  attr_accessor :run_start
26
31
 
27
32
  def initialize(text:, conversation: nil, canceled: false, cancellation_reason: nil, exhausted: false,
28
- tool_activity: [], context_status: nil, pending_tool_calls: false, empty_steps: [], empty_retries: 0)
33
+ tool_activity: [], context_status: nil, pending_tool_calls: false, empty_steps: [], empty_retries: 0,
34
+ refusal: nil)
35
+ @refusal = refusal
29
36
  @empty_steps = Array(empty_steps)
30
37
  @empty_retries = empty_retries.to_i
31
38
  @text = text
@@ -4,30 +4,16 @@ require "json"
4
4
  require "uri"
5
5
  require_relative "../config"
6
6
  require_relative "../sampling_settings"
7
+ require_relative "../thinking"
7
8
  require_relative "errors"
8
9
  require_relative "http"
10
+ require_relative "model_listing"
9
11
  require_relative "prompt_cache"
10
12
  require_relative "usage"
11
13
  require_relative "utf8_scrub"
12
14
 
13
15
  module Samagotchi
14
16
  module LLM
15
- # A model a host lists. Unknown fields are nil.
16
- ModelInfo = Data.define(:id, :context_window, :supports_tools, :raw) do
17
- # true when the list says the model takes images (OpenRouter's
18
- # architecture.input_modalities, llama.cpp's "multimodal" capability),
19
- # false when it lists input modalities without "image", else nil.
20
- def image_input
21
- return nil unless raw.is_a?(Hash)
22
-
23
- modalities = raw.dig("architecture", "input_modalities")
24
- return modalities.include?("image") if modalities.is_a?(Array)
25
- return true if Array(raw["capabilities"]).include?("multimodal")
26
-
27
- nil
28
- end
29
- end
30
-
31
17
  # A tool call the model made. arguments: the parsed Hash, or the raw
32
18
  # String when it isn't valid JSON (the tool then reports the error).
33
19
  ToolCall = Data.define(:id, :name, :arguments)
@@ -43,9 +29,17 @@ module Samagotchi
43
29
  # +top_logprobs+: the first answer token's likeliest alternatives
44
30
  # (TokenLogprob) when the request asked for logprobs and the server
45
31
  # sent them (a non-streamed answer only); [] otherwise.
32
+ # +provider_content+: the response's content blocks in order, as the
33
+ # server sent them (string keys), for a wire that sends them back
34
+ # verbatim (AnthropicMessages: thinking blocks with their signatures);
35
+ # nil on the OpenAI wire.
36
+ # +stop_details+: why the provider stopped, when it says more than the
37
+ # finish reason (Messages' stop_details on a refusal: type, category,
38
+ # explanation; string keys); nil otherwise.
46
39
  ChatResponse = Data.define(:text, :reasoning, :tool_calls, :usage, :finish_reason, :model, :provider, :cut,
47
- :top_logprobs) do
48
- def initialize(model: nil, provider: nil, cut: nil, top_logprobs: [].freeze, **fields) = super
40
+ :top_logprobs, :provider_content, :stop_details) do
41
+ def initialize(model: nil, provider: nil, cut: nil, top_logprobs: [].freeze, provider_content: nil, stop_details: nil,
42
+ **fields) = super
49
43
  end
50
44
 
51
45
  # One alternative for an answer token: its text and log probability.
@@ -75,6 +69,8 @@ module Samagotchi
75
69
  # default, with errors mapped to ProviderError. It speaks the wire format
76
70
  # only; the chat loop owns the conversation and the tools.
77
71
  class OpenAIChat
72
+ include ModelListing
73
+
78
74
  MAX_MODEL_PAGES = 20
79
75
  # How much of a 200's non-stream body is read as a possible JSON error.
80
76
  PLAIN_ERROR_LIMIT = 64 * 1024
@@ -90,14 +86,11 @@ module Samagotchi
90
86
 
91
87
  # @param entry [HostRegistry::HostEntry]
92
88
  def self.for(entry, **)
93
- new(base_url: entry.openai_base_url, host_name: entry.name, api_key_env: entry.api_key_env,
94
- remote: entry.remote?, default_max_tokens: (OPENROUTER_MAX_TOKENS if openrouter?(entry.openai_base_url)),
89
+ new(base_url: entry.api_base_url, host_name: entry.name, api_key_env: entry.api_key_env, auth: entry.auth,
90
+ remote: entry.remote?, default_max_tokens: (OPENROUTER_MAX_TOKENS if openrouter?(entry.api_base_url)),
95
91
  **)
96
92
  end
97
93
 
98
- # true when a streamed +payload+ carries a tool-call delta.
99
- def self.tool_call_delta?(payload) = Assembly.tool_call_delta?(payload)
100
-
101
94
  # true when +url+ is OpenRouter's API (openrouter.ai or a subdomain).
102
95
  def self.openrouter?(url)
103
96
  host = URI(url.to_s).host.to_s.downcase
@@ -134,19 +127,23 @@ module Samagotchi
134
127
  # @param host_name [String] names the host in errors
135
128
  # @param api_key_env [String, nil] the variable holding the API key;
136
129
  # nil sends no Authorization header (a local server)
130
+ # @param auth [Symbol, nil] the header the key goes in
131
+ # (ApiKey::SCHEMES); nil: Authorization: Bearer
137
132
  # @param stream [Boolean] stream the reply (false: one JSON body)
138
133
  # @param retries [Boolean] false: one attempt
139
134
  # @param timeout [Numeric, nil] read timeout in seconds (default
140
135
  # server.read_timeout); the connect timeout is capped by it
136
+ # @param open_timeout [Numeric, nil] connect timeout in seconds, when
137
+ # shorter than that (a reachability probe's)
141
138
  # @param models_ttl [Numeric] how long #context_window reuses the list
142
139
  # @param remote [Boolean] a provider on the network, not a local server
143
140
  # @param first_token_timeout [Numeric, nil] seconds a streamed answer
144
141
  # may take to show something (LLM::HTTP); nil: no limit
145
142
  # @param default_max_tokens [Integer, nil] max_tokens for a request
146
143
  # whose options name none (OPENROUTER_MAX_TOKENS); nil sends none
147
- def initialize(base_url:, host_name:, api_key_env: nil, stream: true, retries: true, timeout: nil,
144
+ def initialize(base_url:, host_name:, api_key_env: nil, auth: nil, stream: true, retries: true, timeout: nil,
148
145
  env: ENV, sleeper: nil, retry_policy: nil, models_ttl: DEFAULT_MODELS_TTL, remote: false,
149
- first_token_timeout: nil, purpose: "chat", default_max_tokens: nil)
146
+ first_token_timeout: nil, purpose: "chat", default_max_tokens: nil, open_timeout: nil)
150
147
  @remote = remote
151
148
  @default_max_tokens = default_max_tokens
152
149
  # What its requests are for, in the log (LLM::HTTP::LOGGED_PURPOSES).
@@ -156,13 +153,12 @@ module Samagotchi
156
153
  @host_name = host_name.to_s
157
154
  @api_key_env = api_key_env
158
155
  @stream = stream
159
- @models_ttl = models_ttl
160
- @models_mutex = Mutex.new
161
- open_timeout, read_timeout = timeouts(timeout)
156
+ init_model_listing(models_ttl)
157
+ open_timeout, read_timeout = timeouts(timeout, open_timeout)
162
158
  policy = retry_policy || (retries ? nil : HTTP::RetryPolicy.none)
163
159
  @http = HTTP.new(label: @host_name, open_timeout: open_timeout, read_timeout: read_timeout,
164
160
  retry_policy: policy, sleeper: sleeper, first_token_timeout: first_token_timeout,
165
- api_key: ApiKey.for(api_key_env, host: @host_name, env: env))
161
+ api_key: ApiKey.for(api_key_env, host: @host_name, env: env, scheme: auth))
166
162
  end
167
163
 
168
164
  # @param messages [Array<Hash>] wire messages (role, content, tool_calls,
@@ -216,6 +212,25 @@ module Samagotchi
216
212
  # A provider on the network: no llama.cpp /props to ask for the window.
217
213
  def remote? = @remote
218
214
 
215
+ # Model entries go back as text, tool_calls and reasoning_content,
216
+ # never as saved provider blocks (AnthropicMessages does).
217
+ def replays_content? = false
218
+
219
+ # true when a streamed chunk's +payload+ (as #chat's on_delta gets it)
220
+ # carries part of a tool call (ChatLoop marks the chunk's event).
221
+ def tool_call_delta?(payload) = Assembly.tool_call_delta?(payload)
222
+
223
+ # The request fields a thinking +level+ (Thinking::LEVELS) adds on
224
+ # this wire: Thinking.chat_fields's OpenAI-style switches.
225
+ # @return [Hash] frozen
226
+ def thinking_fields(level) = Thinking.chat_fields(level)
227
+
228
+ # The thinking fields for a short side answer from a model that
229
+ # refused thinking off (gpt-oss: "Reasoning is mandatory"): none, the
230
+ # model's own default.
231
+ # @return [Hash] frozen
232
+ def thinking_off_refused_fields = {}.freeze
233
+
219
234
  # @return [Array<ModelInfo>] the host's models (every page)
220
235
  def list_models
221
236
  models = []
@@ -231,26 +246,6 @@ module Samagotchi
231
246
  models
232
247
  end
233
248
 
234
- # The window (tokens) the host lists for +model+, or nil. The list is
235
- # read once per models_ttl; a failed listing answers nil.
236
- def context_window(model:)
237
- info = cached_models.find { |m| m.id == model.to_s } ||
238
- cached_models.find { |m| m.id.casecmp?(model.to_s) }
239
- info&.context_window
240
- rescue StandardError
241
- nil
242
- end
243
-
244
- # Whether the host's list says +model+ takes images (ModelInfo#image_input),
245
- # or nil when it doesn't say or the listing fails.
246
- def image_input(model:)
247
- info = cached_models.find { |m| m.id == model.to_s } ||
248
- cached_models.find { |m| m.id.casecmp?(model.to_s) }
249
- info&.image_input
250
- rescue StandardError
251
- nil
252
- end
253
-
254
249
  def self.served_model(payload)
255
250
  model = payload.is_a?(Hash) ? payload["model"] : nil
256
251
  model.is_a?(String) && !model.strip.empty? ? model : nil
@@ -265,9 +260,10 @@ module Samagotchi
265
260
 
266
261
  private
267
262
 
268
- def timeouts(timeout)
263
+ def timeouts(timeout, open_limit = nil)
269
264
  open_timeout = Samagotchi::Config.positive_seconds("server.open_timeout")
270
265
  read_timeout = Samagotchi::Config.positive_seconds("server.read_timeout")
266
+ open_timeout = [open_timeout, open_limit].min if open_limit
271
267
  return [open_timeout, read_timeout] unless timeout
272
268
 
273
269
  [[open_timeout, timeout].min, timeout]
@@ -411,49 +407,6 @@ module Samagotchi
411
407
 
412
408
  def get_request(uri) = Net::HTTP::Get.new(uri)
413
409
 
414
- def model_entries(body)
415
- return [] unless body.is_a?(Hash)
416
-
417
- entries = body["data"].is_a?(Array) ? body["data"] : body["models"]
418
- Array(entries).select { |entry| entry.is_a?(Hash) }
419
- end
420
-
421
- def model_info(raw)
422
- id = (raw["id"] || raw["model"] || raw["name"]).to_s
423
- ModelInfo.new(id: id, context_window: window_of(raw), supports_tools: tools_support(raw), raw: raw)
424
- end
425
-
426
- # The running window when the list says: context_length, context_window,
427
- # max_model_len, or llama.cpp's meta.n_ctx (not n_ctx_train).
428
- def window_of(raw)
429
- [raw["context_length"], raw["context_window"], raw["max_model_len"], raw.dig("meta", "n_ctx")].each do |value|
430
- return value if value.is_a?(Integer) && value.positive?
431
- end
432
- nil
433
- end
434
-
435
- def tools_support(raw)
436
- listed = raw["supported_parameters"] || raw["capabilities"]
437
- return nil unless listed.is_a?(Array)
438
-
439
- listed.include?("tools") || nil
440
- end
441
-
442
- def cached_models
443
- @models_mutex.synchronize do
444
- fresh = @models_at && (monotonic - @models_at) < @models_ttl
445
- return @models if fresh
446
- end
447
- models = list_models
448
- @models_mutex.synchronize do
449
- @models = models
450
- @models_at = monotonic
451
- end
452
- models
453
- end
454
-
455
- def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
456
-
457
410
  # Streamed deltas put together: text, reasoning, tool calls by index,
458
411
  # the finish reason, the usage chunk, the served model and provider.
459
412
  class Assembly
@@ -484,7 +484,7 @@ module Samagotchi
484
484
  private_class_method :configured
485
485
 
486
486
  # Only a native llama.cpp host has /props with a chat template; a chat
487
- # host (api: openai) barely uses a profile, and mlx/oMLX expose none.
487
+ # host (api: openai or anthropic) barely uses a profile, and mlx/oMLX expose none.
488
488
  def self.probe?(entry, client)
489
489
  return false if client.nil? || !client.respond_to?(:server_props)
490
490
  return false if entry&.chat?
@@ -16,12 +16,17 @@ module Samagotchi
16
16
  #
17
17
  # Order (#resolve): the session's own level (/reasoning-history), then
18
18
  # SAMAGOTCHI_REASONING_HISTORY, models.<key>.reasoning_history,
19
- # hosts.<name>.reasoning_history, then turn. Native hosts ignore it:
19
+ # hosts.<name>.reasoning_history, then the host's api default: all for
20
+ # api: anthropic (its thinking blocks go back verbatim, and removing
21
+ # earlier turns' would rewrite the cached prefix every turn; ChatLoop
22
+ # floors drop at turn there), turn elsewhere. Native hosts ignore it:
20
23
  # KernelLoop keeps the running turn's reasoning on its own, from the
21
24
  # run's start (.run_start).
22
25
  module ReasoningHistory
23
26
  LEVELS = %i[drop turn all].freeze
24
27
  DEFAULT = :turn
28
+ # The default for a host's api when it isn't DEFAULT.
29
+ API_DEFAULTS = { anthropic: :all }.freeze
25
30
  KEY = "reasoning_history"
26
31
  ENV_NAME = "SAMAGOTCHI_REASONING_HISTORY"
27
32
  # The source a session's own level reads as.
@@ -82,7 +87,7 @@ module Samagotchi
82
87
  host = target.entry&.reasoning_history
83
88
  return [host, "hosts.#{target.entry.name}"] if host
84
89
 
85
- [DEFAULT, nil]
90
+ [API_DEFAULTS.fetch(target.entry&.api&.to_sym, DEFAULT), nil]
86
91
  end
87
92
 
88
93
  # #resolve, as a SettingExplained with the session's own level beside it.
@@ -112,10 +117,15 @@ module Samagotchi
112
117
  end
113
118
 
114
119
  # Whether +conversation+ has a model entry with thinking (what all
115
- # sends back as reasoning_content; LLM::ChatLoop#thought?).
120
+ # sends back as reasoning_content; LLM::ChatLoop#thought?), or with a
121
+ # saved thinking block (a Messages host's, which may carry no text:
122
+ # Claude's omitted display).
116
123
  def earlier_thought?(conversation)
117
124
  Array(conversation).any? do |entry|
118
- entry[:role].to_s == "model" && entry[:thinking].is_a?(String) && !entry[:thinking].strip.empty?
125
+ next false unless entry[:role].to_s == "model"
126
+
127
+ (entry[:thinking].is_a?(String) && !entry[:thinking].strip.empty?) ||
128
+ Array(entry[:anthropic_content]).any? { |block| block.is_a?(Hash) && %w[thinking redacted_thinking].include?(block["type"].to_s) }
119
129
  end
120
130
  end
121
131
 
@@ -65,7 +65,8 @@ module Samagotchi
65
65
  min_user_turns: recap_number_setting(kwarg_config, :min_user_turns, "recap.min_user_turns", IdleRecap::DEFAULT_MIN_USER_TURNS, :int),
66
66
  timeout: recap_number_setting(kwarg_config, :timeout, "recap.timeout", IdleRecap::DEFAULT_TIMEOUT_SECONDS, :float),
67
67
  sentences: recap_sentences(string_config(kwarg_config, :sentences) || registry_string("recap.sentences")),
68
- store: RecapStore.new(session_id_lookup: -> { session_id.call }, state_dir_lookup: -> { state_dir.call })
68
+ store: RecapStore.new(session_id_lookup: -> { session_id.call }, state_dir_lookup: -> { state_dir.call }),
69
+ session_id: -> { session_id.call }
69
70
  )
70
71
  end
71
72