samagotchi 0.79.0 → 0.81.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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -1
  3. data/README.md +7 -5
  4. data/docs/architecture.md +9 -1
  5. data/docs/cli.md +32 -7
  6. data/docs/configuration.md +10 -4
  7. data/docs/guardrails.md +26 -3
  8. data/docs/internals/anthropic.md +3 -1
  9. data/docs/internals/bundles.md +4 -0
  10. data/docs/internals/engine.md +13 -3
  11. data/docs/plugins.md +149 -3
  12. data/docs/releasing.md +2 -2
  13. data/docs/sessions.md +9 -1
  14. data/docs/testing.md +51 -2
  15. data/lib/samagotchi/bootstrap/config_writer.rb +13 -3
  16. data/lib/samagotchi/bootstrap/probe.rb +153 -31
  17. data/lib/samagotchi/bootstrap_command.rb +93 -16
  18. data/lib/samagotchi/bridge.rb +1 -1
  19. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -4
  20. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  21. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/slow-commands/plugin.rb +2 -1
  23. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  25. data/lib/samagotchi/continue_offer.rb +5 -5
  26. data/lib/samagotchi/engine.rb +68 -39
  27. data/lib/samagotchi/guardrails/approval.rb +6 -4
  28. data/lib/samagotchi/guardrails/data_readers.rb +214 -0
  29. data/lib/samagotchi/guardrails/gate.rb +7 -3
  30. data/lib/samagotchi/guardrails/rules.rb +4 -2
  31. data/lib/samagotchi/guardrails/shell_lex.rb +166 -28
  32. data/lib/samagotchi/guardrails/targets.rb +14 -0
  33. data/lib/samagotchi/hooks/bundle_loader.rb +31 -16
  34. data/lib/samagotchi/hooks/registry.rb +44 -3
  35. data/lib/samagotchi/host_config.rb +8 -8
  36. data/lib/samagotchi/host_registry.rb +12 -4
  37. data/lib/samagotchi/idle_client.rb +41 -11
  38. data/lib/samagotchi/idle_recap.rb +6 -1
  39. data/lib/samagotchi/idle_target.rb +12 -4
  40. data/lib/samagotchi/llm/adapter_common.rb +4 -2
  41. data/lib/samagotchi/llm/anthropic_binding_recovery.rb +3 -0
  42. data/lib/samagotchi/llm/anthropic_messages.rb +2 -1
  43. data/lib/samagotchi/llm/http.rb +12 -7
  44. data/lib/samagotchi/llm/usage.rb +9 -3
  45. data/lib/samagotchi/model_price.rb +16 -0
  46. data/lib/samagotchi/plugin/card.rb +44 -0
  47. data/lib/samagotchi/plugin/context.rb +61 -9
  48. data/lib/samagotchi/plugin/http.rb +154 -0
  49. data/lib/samagotchi/plugin/http_transport.rb +77 -0
  50. data/lib/samagotchi/plugin/loader.rb +29 -10
  51. data/lib/samagotchi/plugin/service.rb +24 -1
  52. data/lib/samagotchi/question_answer.rb +29 -0
  53. data/lib/samagotchi/question_desk.rb +20 -19
  54. data/lib/samagotchi/recap_setup.rb +2 -1
  55. data/lib/samagotchi/relay_verifier.rb +1 -1
  56. data/lib/samagotchi/session_metrics/side_totals.rb +110 -0
  57. data/lib/samagotchi/session_metrics.rb +46 -12
  58. data/lib/samagotchi/session_observer.rb +3 -0
  59. data/lib/samagotchi/side_usage.rb +38 -0
  60. data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
  61. data/lib/samagotchi/terminal_ui/model_segments_text.rb +27 -3
  62. data/lib/samagotchi/testing/boundary.rb +156 -0
  63. data/lib/samagotchi/testing/bundle.rb +349 -0
  64. data/lib/samagotchi/testing/fake_executable.rb +42 -0
  65. data/lib/samagotchi/testing/fake_http.rb +137 -0
  66. data/lib/samagotchi/testing/helpers.rb +56 -0
  67. data/lib/samagotchi/testing/host.rb +76 -0
  68. data/lib/samagotchi/testing/minitest.rb +31 -0
  69. data/lib/samagotchi/testing/recorder.rb +141 -0
  70. data/lib/samagotchi/testing/recording_api.rb +27 -0
  71. data/lib/samagotchi/testing/rspec.rb +31 -0
  72. data/lib/samagotchi/testing/sandbox.rb +165 -0
  73. data/lib/samagotchi/testing/sessions.rb +77 -0
  74. data/lib/samagotchi/testing/verdict.rb +28 -0
  75. data/lib/samagotchi/testing/waiting.rb +36 -0
  76. data/lib/samagotchi/testing.rb +26 -0
  77. data/lib/samagotchi/tools/delegate_relay.rb +7 -6
  78. data/lib/samagotchi/tools/tool_index.rb +3 -0
  79. data/lib/samagotchi/turn_state.rb +28 -1
  80. data/lib/samagotchi/version.rb +1 -1
  81. data/lib/samagotchi/web/app.rb +4 -1
  82. data/lib/samagotchi/web/public/app.js +47 -23
  83. data/lib/samagotchi/web/public/ctx.js +32 -6
  84. data/lib/samagotchi/web/public/images.js +33 -0
  85. data/lib/samagotchi/web/public/index.html +71 -38
  86. data/lib/samagotchi/web/public/info_bar_model.js +1 -1
  87. data/lib/samagotchi/web/public/level_chip.js +11 -11
  88. data/lib/samagotchi/web/public/lists_view.js +20 -15
  89. data/lib/samagotchi/web/public/llm_context_chip.js +15 -15
  90. data/lib/samagotchi/web/public/model_picker.js +1 -1
  91. data/lib/samagotchi/web/public/scope.js +3 -3
  92. data/lib/samagotchi/web/public/stream_handlers.js +7 -0
  93. data/lib/samagotchi/web/public/strip.js +35 -9
  94. data/lib/samagotchi/web/public/strip_grip.js +76 -0
  95. data/lib/samagotchi/web/public/thinking_chip.js +5 -5
  96. data/lib/samagotchi/web/public/timing.js +6 -1
  97. data/lib/samagotchi/web/session_summary.rb +2 -0
  98. metadata +24 -1
@@ -49,6 +49,31 @@ module Samagotchi
49
49
  namespace.const_get(File.basename(file.to_s, ".rb").split("_").map(&:capitalize).join, false)
50
50
  end
51
51
 
52
+ # A hook that raised: its label, event and error (#capture_failures).
53
+ Failure = Data.define(:hook, :event, :error)
54
+
55
+ # Thread.current key: the Array #capture_failures fills, or nil.
56
+ FAILURES = :samagotchi_hook_failures
57
+
58
+ # Collect into +sink+ every hook that raises on this thread while the
59
+ # block runs, whatever its policy (:skip ones log nothing, a stream
60
+ # hook's log is rate-limited): the bundle test kit raises them. The
61
+ # policy still applies. Nests; threads the block starts don't inherit it.
62
+ # @param sink [Array<Failure>]
63
+ def self.capture_failures(sink)
64
+ previous = Thread.current[FAILURES]
65
+ Thread.current[FAILURES] = sink
66
+ yield
67
+ ensure
68
+ Thread.current[FAILURES] = previous
69
+ end
70
+
71
+ # Add a raising hook to the sink #capture_failures set, if any.
72
+ def self.record_failure(hook, event, error)
73
+ Thread.current[FAILURES]&.push(Failure.new(hook: hook.to_s, event: event.to_s, error: error))
74
+ nil
75
+ end
76
+
52
77
  # A hook's block wrapped in its error policy: what a loader registers.
53
78
  # When the block raises,
54
79
  # :deny denies the call (event[:guardrail], the Gate's Verdict),
@@ -70,6 +95,7 @@ module Samagotchi
70
95
  rescue Exception => e # rubocop:disable Lint/RescueException -- :deny fails closed on anything
71
96
  raise unless policy == :deny || e.is_a?(StandardError) || e.is_a?(ScriptError)
72
97
 
98
+ Hooks.record_failure(label, event, e)
73
99
  failed = "#{e.class}: #{e.message}"
74
100
  if policy == :deny
75
101
  payload[:guardrail]&.deny!("#{label} raised #{failed}", rule: "guardrail-load", source: "core", decided_by: "core") if payload.is_a?(Hash)
@@ -273,6 +299,7 @@ module Samagotchi
273
299
  hook_proc.call(event)
274
300
  rescue StandardError => e
275
301
  # A failing hook must not break the turn.
302
+ Hooks.record_failure(label, name, e)
276
303
  Log.exception(:hooks, "hook_failed", e, echo: "[samagotchi:hooks] #{label} failed: #{e.class}: #{e.message}",
277
304
  hook: label, event: name.to_s)
278
305
  end
@@ -302,6 +329,7 @@ module Samagotchi
302
329
  hook_proc.call(event)
303
330
  rescue StandardError => e
304
331
  # A failing hook must not break the turn.
332
+ Hooks.record_failure(label, name, e)
305
333
  Log.exception(:hooks, "hook_failed", e, echo: "[samagotchi:hooks] #{label} failed: #{e.class}: #{e.message}",
306
334
  hook: label, event: name.to_s)
307
335
  end
@@ -309,6 +337,16 @@ module Samagotchi
309
337
  end
310
338
  end
311
339
 
340
+ # The bundle hooks registered for +name+, in the order #fire calls
341
+ # them (priority, bundle, hook name).
342
+ # @param name [Symbol]
343
+ # @return [Array<Array(String, String, Integer)>] [bundle, hook_name, priority]
344
+ def bundle_hook_names(name)
345
+ @mutex.synchronize do
346
+ sorted_bundle_hooks(name).map { |h| [h[:bundle], h[:hook_name], h[:priority]] }
347
+ end
348
+ end
349
+
312
350
  # @return [Integer] total number of registered hooks (bundle + plain)
313
351
  def size
314
352
  @mutex.synchronize do
@@ -323,15 +361,18 @@ module Samagotchi
323
361
  # turn-scoped hooks, each in registration order.
324
362
  def ordered_procs(name)
325
363
  @mutex.synchronize do
326
- bundle_procs = (@bundle_hooks[name] || [])
327
- .sort_by { |h| [h[:priority].to_i, h[:bundle].to_s, h[:hook_name].to_s] }
328
- .map { |h| ["#{h[:hook_name]} (bundle #{h[:bundle]})", h[:proc]] }
364
+ bundle_procs = sorted_bundle_hooks(name).map { |h| ["#{h[:hook_name]} (bundle #{h[:bundle]})", h[:proc]] }
329
365
  config_procs = (@persistent_hooks[name] || []).map { |h| [h[:label], h[:proc]] }
330
366
  turn_procs = (@hooks[name] || []).map { |hook_proc| [TURN_HOOK_LABEL, hook_proc] }
331
367
  bundle_procs + config_procs + turn_procs
332
368
  end
333
369
  end
334
370
 
371
+ # The bundle hooks for an event in fire order; call under @mutex.
372
+ def sorted_bundle_hooks(name)
373
+ (@bundle_hooks[name] || []).sort_by { |h| [h[:priority].to_i, h[:bundle].to_s, h[:hook_name].to_s] }
374
+ end
375
+
335
376
  # The helpers, once per fire; a fire site's own keys stay.
336
377
  def with_runtime(event)
337
378
  return unless event.is_a?(Hash)
@@ -23,11 +23,6 @@ module Samagotchi
23
23
  :first_token_timeout, :vision, :sampling, :thinking, :reasoning_history, :remote,
24
24
  :window_tokens, :steer_cut_after, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
25
25
  :models) do
26
- # An api: anthropic host without url/host talks to Anthropic's own API.
27
- ANTHROPIC_URL = "https://api.anthropic.com"
28
- # auth: as written => the LLM::ApiKey scheme.
29
- AUTH_SCHEMES = { "bearer" => :bearer, "x-api-key" => :x_api_key }.freeze
30
-
31
26
  # Every field but name may be left out: nil, models {}.
32
27
  def initialize(**fields)
33
28
  super(**members.to_h { |m| [m, nil] }, models: {}, **fields)
@@ -77,8 +72,8 @@ module Samagotchi
77
72
  end
78
73
 
79
74
  auth = value.call(:auth).to_s.strip.downcase
80
- fields[:auth] = AUTH_SCHEMES[auth] unless auth.empty?
81
- return invalid(name, "auth must be one of #{AUTH_SCHEMES.keys.join(", ")}") if !auth.empty? && fields[:auth].nil?
75
+ fields[:auth] = HostConfig::AUTH_SCHEMES[auth] unless auth.empty?
76
+ return invalid(name, "auth must be one of #{HostConfig::AUTH_SCHEMES.keys.join(", ")}") if !auth.empty? && fields[:auth].nil?
82
77
 
83
78
  if fields[:auth] && fields[:api_key_env].nil?
84
79
  ConfigFile.warn_once "Warning: #{where}: auth has no effect without api_key_env (no key is sent)"
@@ -121,7 +116,7 @@ module Samagotchi
121
116
  host = value.call(:host)
122
117
  port = value.call(:port)
123
118
  url = value.call(:url).to_s.strip
124
- url = ANTHROPIC_URL if url.empty? && api == :anthropic && host.to_s.strip.empty? && port.to_s.strip.empty?
119
+ url = HostConfig::ANTHROPIC_URL if url.empty? && api == :anthropic && host.to_s.strip.empty? && port.to_s.strip.empty?
125
120
  scheme = "http"
126
121
  unless url.empty?
127
122
  return invalid(name, "give url or host/port, not both") unless host.to_s.strip.empty? && port.to_s.strip.empty?
@@ -178,4 +173,9 @@ module Samagotchi
178
173
  end
179
174
  private_class_method :invalid
180
175
  end
176
+
177
+ # An api: anthropic host without url/host talks to Anthropic's own API.
178
+ HostConfig::ANTHROPIC_URL = "https://api.anthropic.com"
179
+ # auth: as written => the LLM::ApiKey scheme.
180
+ HostConfig::AUTH_SCHEMES = { "bearer" => :bearer, "x-api-key" => :x_api_key }.freeze
181
181
  end
@@ -229,15 +229,23 @@ module Samagotchi
229
229
  # ModelRef names a host only when it is one of ours
230
230
  return [find_entry(ref.host_name), ref.id] if ref.host_name
231
231
 
232
- down = ref.id.to_s.strip.downcase
233
- listed = @mutex.synchronize { @model_index }&.fetch(down, nil) || []
234
- candidates = @entries.values.select { |e| listed.include?(e.name) || e.models&.key?(down) }.map(&:name)
235
- candidates = saved_hosts_for(down) if candidates.empty?
232
+ candidates = hosts_with_model(ref.id)
236
233
  default = default_entry
237
234
  name = candidates.include?(default.name) ? default.name : candidates.first
238
235
  [(name && find_entry(name)) || default, ref.id]
239
236
  end
240
237
 
238
+ # The hosts that list or declare the model id +id+, in hosts: order: by
239
+ # the cached lists and hosts.<name>.models, else by the saved lists
240
+ # (ModelListStore). None asked: no discovery here.
241
+ # @return [Array<String>] host names, empty when no host has it
242
+ def hosts_with_model(id)
243
+ down = id.to_s.strip.downcase
244
+ listed = @mutex.synchronize { @model_index }&.fetch(down, nil) || []
245
+ names = @entries.values.select { |e| listed.include?(e.name) || e.models&.key?(down) }.map(&:name)
246
+ names.empty? ? saved_hosts_for(down) : names
247
+ end
248
+
241
249
  # The chat adapter for a host (one per host, so its cached model list
242
250
  # serves the context window), as LLM::Adapters builds it for the host's
243
251
  # api. Only chat hosts use it for turns.
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require_relative "llm/adapters"
5
5
  require_relative "log"
6
+ require_relative "side_usage"
6
7
 
7
8
  module Samagotchi
8
9
  # Standalone summarizer for the idle session-recap feature.
@@ -54,9 +55,12 @@ module Samagotchi
54
55
  # @param api [Symbol] the wire to talk (LLM::Adapters; IdleTarget#api)
55
56
  # @param auth [Symbol, nil] the header the key goes in (IdleTarget#auth)
56
57
  # @param dialect [String, nil] a Messages host's dialect (IdleTarget#dialect)
58
+ # @param price [ModelPrice, nil] the model's price on its host
59
+ # (IdleTarget#price), on each SideUsage the on_usage callbacks get
57
60
  def initialize(model:, base_url: nil, api_key_env: nil, timeout: DEFAULT_TIMEOUT_SECONDS, env: ENV, purpose: "recap",
58
- api: :openai, auth: nil, dialect: nil)
61
+ api: :openai, auth: nil, dialect: nil, price: nil)
59
62
  @model = model
63
+ @price = price
60
64
  # A recap is best-effort: one short attempt, no retries. The idle job
61
65
  # tries again after the next activity, never on its own. Only the
62
66
  # Messages adapter takes a dialect.
@@ -70,7 +74,7 @@ module Samagotchi
70
74
  # @return [IdleClient]
71
75
  def self.for(target, **)
72
76
  new(model: target.model, base_url: target.base_url, api_key_env: target.api_key_env, api: target.api,
73
- auth: target.auth, dialect: target.dialect, **)
77
+ auth: target.auth, dialect: target.dialect, price: target.price, **)
74
78
  end
75
79
 
76
80
  THINK_RE = /<\|think\|.*?\|think\|>/m
@@ -100,11 +104,13 @@ module Samagotchi
100
104
  # @return [Summary, nil]
101
105
  # +session_id+: the session it is about, sent as the turns send it
102
106
  # (Session-Id, a dialect's affinity header), nil for none.
103
- def summarize(prompt, session_id: nil)
107
+ # +on_usage+: called with the request's SideUsage (kind "recap") once a
108
+ # reply came back, see #ask.
109
+ def summarize(prompt, session_id: nil, on_usage: nil)
104
110
  messages = prompt.is_a?(Array) ? prompt : [{ role: "user", content: prompt.to_s.strip }]
105
111
  return nil if messages.all? { |m| m[:content].to_s.strip.empty? }
106
112
 
107
- response = generate(messages, session_id: session_id)
113
+ response = generate(messages, session_id: session_id, on_usage: on_usage)
108
114
  cleaned = response.text.to_s.strip
109
115
  cleaned.empty? ? nil : Summary.new(text: cleaned, model: response.model)
110
116
  rescue SummarizeError
@@ -121,14 +127,18 @@ module Samagotchi
121
127
  # @param options [Hash] more request body fields (logprobs: true,
122
128
  # top_logprobs: 5)
123
129
  # @param session_id [String, nil] the session it is about (#summarize)
130
+ # @param on_usage [#call, nil] called with the request's SideUsage
131
+ # (+kind+, the counts, the reported cost, the client's price) once a
132
+ # reply came back, a billed one with no usable text too; never for a
133
+ # request that got no reply. Its errors are logged, never raised.
124
134
  # @return [Summary] the answer ("" when the model said nothing)
125
135
  # @raise [SummarizeError] any failure but a cancel; its #cause is an
126
136
  # LLM::BadRequest when the host refused the request (+options+ it
127
137
  # doesn't take, say)
128
138
  # @raise [LLM::RequestCancelled] +cancel_controller+ was cancelled
129
- def ask(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, kind: "ask", options: {}, session_id: nil)
139
+ def ask(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, kind: "ask", options: {}, session_id: nil, on_usage: nil)
130
140
  response = generate(messages, max_tokens: max_tokens, cancel_controller: cancel_controller, whole_sentences: false,
131
- kind: kind, options: options, session_id: session_id)
141
+ kind: kind, options: options, session_id: session_id, on_usage: on_usage)
132
142
  Summary.new(text: response.text.to_s.strip, model: response.model, top_logprobs: response.top_logprobs)
133
143
  rescue SummarizeError, LLM::RequestCancelled
134
144
  raise
@@ -146,7 +156,7 @@ module Samagotchi
146
156
  # +max_tokens+, the text keeps its finished sentences (+whole_sentences+)
147
157
  # or all of it, with "…".
148
158
  def generate(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, whole_sentences: true, kind: "recap", options: {},
149
- session_id: nil)
159
+ session_id: nil, on_usage: nil)
150
160
  response = begin
151
161
  request(messages, max_tokens, cancel_controller, options, session_id)
152
162
  rescue LLM::BadRequest => e
@@ -156,6 +166,7 @@ module Samagotchi
156
166
  retry
157
167
  end
158
168
  log_usage(response, kind)
169
+ report_usage(on_usage, response, kind)
159
170
  content = response.text
160
171
  reasoning = response.reasoning
161
172
  raise SummarizeError, "server returned no parseable assistant content" if content.empty? && reasoning.empty?
@@ -176,14 +187,33 @@ module Samagotchi
176
187
  raise SummarizeError, "server returned no parseable assistant content (#{e.message})"
177
188
  end
178
189
 
179
- # The request's prompt-cache counts (a side request on the session's
180
- # host can evict the session's cached prompt there).
190
+ # The request's token counts and reported cost (a broadcast's triage
191
+ # has no session to count them in). The cache counts show whether the
192
+ # host reused a cached prefix. A side request evicts a session's cached
193
+ # prompt only on a llama.cpp with no host-memory cache (--cache-ram 0)
194
+ # or an old single-slot build, or when pinned to the session's slot,
195
+ # which chi never does; paid hosts and current llama.cpp aren't
196
+ # affected (docs/internals/prompt-caching.md).
181
197
  def log_usage(response, kind)
182
- fields = response.usage&.cache_fields || {}
198
+ usage = response.usage
199
+ fields = usage&.cache_fields || {}
183
200
  return if fields.empty?
184
201
 
185
202
  Log.info(:recap, "request_usage", kind: kind, model: @model, prompt: fields[:prompt_tokens],
186
- cached: fields[:cached_tokens], cache_write: fields[:cache_write_tokens])
203
+ cached: fields[:cached_tokens], cache_write: fields[:cache_write_tokens],
204
+ # A cost as written: Log rounds Floats to 3 places.
205
+ completion: usage.completion_tokens, cost: usage.cost&.to_s)
206
+ end
207
+
208
+ # The request's SideUsage to +on_usage+ (a session counts it in its side
209
+ # line); a reply without counts is one request with zero tokens.
210
+ # Counting never fails the side answer: a callback's error is logged.
211
+ def report_usage(on_usage, response, kind)
212
+ return unless on_usage
213
+
214
+ on_usage.call(SideUsage.from_usage(response.usage, kind: kind, model: @model, price: @price))
215
+ rescue StandardError => e
216
+ Log.warn(:recap, "usage_callback_failed", kind: kind, model: @model, error: "#{e.class}: #{e.message}")
187
217
  end
188
218
 
189
219
  # After a host refused this step's thinking fields: the next step, kept
@@ -288,12 +288,17 @@ module Samagotchi
288
288
  client: nil,
289
289
  store: nil,
290
290
  session_id: -> {},
291
+ on_usage: nil,
291
292
  clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
292
293
  raise ArgumentError, "IdleRecap requires an engine" unless engine
293
294
 
294
295
  @engine = engine
295
296
  # The recapped session's id, sent with the request (IdleClient#summarize).
296
297
  @session_id = session_id
298
+ # Called with each recap request's SideUsage (IdleClient#summarize),
299
+ # a dropped recap's too: it was billed (RecapSetup: the Engine's
300
+ # side line).
301
+ @on_usage = on_usage
297
302
  @target = target || lambda {
298
303
  IdleTarget.new(base_url: base_url, api_key_env: api_key_env, model: model, label: model)
299
304
  }
@@ -547,7 +552,7 @@ module Samagotchi
547
552
 
548
553
  def spawn_summarize(client, prompt)
549
554
  Thread.new do
550
- client.summarize(prompt, session_id: @session_id.call)
555
+ client.summarize(prompt, session_id: @session_id.call, on_usage: @on_usage)
551
556
  rescue StandardError => e
552
557
  # No recap this time (the next idle window tries again); say why.
553
558
  Log.exception(:recap, "summarize_failed", e)
@@ -14,8 +14,14 @@ module Samagotchi
14
14
  # auth:, LLM::ApiKey::SCHEMES); nil: the api's own default
15
15
  # @!attribute dialect [String, nil] an api: anthropic host's dialect:
16
16
  # (LLM::Dialect), so a side request gets its auth and fields
17
- IdleTarget = Data.define(:base_url, :api_key_env, :model, :label, :api, :auth, :dialect) do
18
- def initialize(api: :openai, auth: nil, dialect: nil, **) = super
17
+ # @!attribute price [ModelPrice, nil] the host's models: price for the
18
+ # id asked for (as EffectiveModel#price), what a side request's cost is
19
+ # estimated from; nil for none, and for a bare base_url target
20
+ IdleTarget = Data.define(:base_url, :api_key_env, :model, :label, :api, :auth, :dialect, :price) do
21
+ def initialize(api: :openai, auth: nil, dialect: nil, price: nil, **) = super
22
+
23
+ # +entry+'s models: price for +id+ (the id asked for, case-insensitively).
24
+ def self.price_of(entry, id) = entry.models&.dig(id.to_s.strip.downcase)&.price
19
25
 
20
26
  # The api a side request to +entry+ talks: a chat host's own, else the
21
27
  # OpenAI API (a raw host's llama.cpp serves /v1 too).
@@ -49,7 +55,8 @@ module Samagotchi
49
55
  resolved = host_registry.resolve(name)
50
56
  entry = resolved.entry
51
57
  new(base_url: entry.api_base_url, api_key_env: entry.api_key_env, model: resolved.bare_model,
52
- label: name.to_s.strip, api: api_of(entry), auth: entry.auth, dialect: dialect_of(entry))
58
+ label: name.to_s.strip, api: api_of(entry), auth: entry.auth, dialect: dialect_of(entry),
59
+ price: price_of(entry, resolved.bare_model))
53
60
  end
54
61
 
55
62
  # A side model's settings (recap.*, broadcast.triage_*) as a target:
@@ -74,6 +81,7 @@ module Samagotchi
74
81
  api = :openai
75
82
  auth = nil
76
83
  dialect = nil
84
+ entry = nil
77
85
  if host_ref
78
86
  entry = host_registry.find_entry(host_ref)
79
87
  raise self::Unresolved.new(:host_ref_unknown, model: model, host_ref: host_ref) unless entry
@@ -95,7 +103,7 @@ module Samagotchi
95
103
  raise self::Unresolved.new(:incomplete, model: model, host_ref: host_ref) if blank(base_url).nil? || blank(model).nil?
96
104
 
97
105
  new(base_url: base_url.to_s.strip, api_key_env: api_key_env, model: model.to_s.strip, label: label.strip, api: api, auth: auth,
98
- dialect: dialect)
106
+ dialect: dialect, price: entry && price_of(entry, model))
99
107
  end
100
108
 
101
109
  # Whether the settings fix a host (+base_url+, +host_ref+, or a model
@@ -24,11 +24,13 @@ module Samagotchi
24
24
 
25
25
  # The adapter's LLM::HTTP: the timeouts (#timeouts), one attempt when
26
26
  # +retries+ is false and no +retry_policy+ is given.
27
- def build_http(timeout:, open_timeout:, retries:, retry_policy:, sleeper:, first_token_timeout:, api_key:)
27
+ def build_http(timeout:, open_timeout:, retries:, retry_policy:, sleeper:, first_token_timeout:, api_key:,
28
+ diagnosis_header: nil)
28
29
  open_timeout, read_timeout = timeouts(timeout, open_timeout)
29
30
  policy = retry_policy || (retries ? nil : HTTP::RetryPolicy.none)
30
31
  HTTP.new(label: @host_name, open_timeout: open_timeout, read_timeout: read_timeout, retry_policy: policy,
31
- sleeper: sleeper, first_token_timeout: first_token_timeout, api_key: api_key)
32
+ sleeper: sleeper, first_token_timeout: first_token_timeout, api_key: api_key,
33
+ diagnosis_header: diagnosis_header)
32
34
  end
33
35
 
34
36
  # [open, read] seconds: server.open_timeout / server.read_timeout,
@@ -27,6 +27,9 @@ module Samagotchi
27
27
  # The only drop the loop settles: a model_binding_mismatch (a /model
28
28
  # downgrade) keeps its blocks, a switch back reads them again.
29
29
  PREFIX_DROP = "prefix_binding_mismatch"
30
+ # The 400's undocumented diagnosis header, kept on the ProviderError and
31
+ # logged as `diagnosis`.
32
+ DIAGNOSIS_HEADER = "anthropic-thinking-prefix-mismatch"
30
33
 
31
34
  # One request's binding recovery (#advance): the step taken
32
35
  # (nil, :drop_block or :strip), the steps tried, where a strip starts
@@ -116,7 +116,8 @@ module Samagotchi
116
116
  @binding = AnthropicBindingRecovery.new(refusals: @refusals, host_name: @host_name, budget_form: method(:budget_form?))
117
117
  @http = build_http(timeout: timeout, open_timeout: open_timeout, retries: retries, retry_policy: retry_policy,
118
118
  sleeper: sleeper, first_token_timeout: first_token_timeout,
119
- api_key: ApiKey.for(api_key_env, host: @host_name, env: env, scheme: auth || @dialect.auth))
119
+ api_key: ApiKey.for(api_key_env, host: @host_name, env: env, scheme: auth || @dialect.auth),
120
+ diagnosis_header: AnthropicBindingRecovery::DIAGNOSIS_HEADER)
120
121
  end
121
122
 
122
123
  # The same contract as OpenAIChat#chat; +messages+ are ChatLoop's
@@ -43,8 +43,9 @@ module Samagotchi
43
43
 
44
44
  # Requests of these purposes are the ones a turn waits on (or, for the
45
45
  # turn-end warm-up, prepares the next one): INFO lines (and ERROR when
46
- # they fail). Probes and model lists are DEBUG.
47
- LOGGED_PURPOSES = %w[chat recap warmup].freeze
46
+ # they fail); so are a plugin's ctx.http calls ("plugin"). Probes and
47
+ # model lists are DEBUG.
48
+ LOGGED_PURPOSES = %w[chat recap warmup plugin].freeze
48
49
 
49
50
  # Exponential backoff: base_delay * 2^(attempt-1), capped at max_delay,
50
51
  # for up to +max+ retries (max + 1 attempts).
@@ -103,10 +104,13 @@ module Samagotchi
103
104
  # show something (see #stream_lines); nil: no limit
104
105
  # @param api_key [ApiKey, nil] sent on every request; nil: no
105
106
  # Authorization header (a local server)
107
+ # @param diagnosis_header [String, nil] a response header the adapter
108
+ # wants kept on a ProviderError as its diagnosis; nil: none
106
109
  def initialize(label:, open_timeout:, read_timeout:, retry_policy: nil, sleeper: nil, first_token_timeout: nil,
107
- api_key: nil)
110
+ api_key: nil, diagnosis_header: nil)
108
111
  @label = label
109
112
  @api_key = api_key
113
+ @diagnosis_header = diagnosis_header
110
114
  @open_timeout = open_timeout
111
115
  @read_timeout = read_timeout
112
116
  @first_token_timeout = first_token_timeout&.positive? ? first_token_timeout : nil
@@ -187,11 +191,11 @@ module Samagotchi
187
191
  # @raise [RequestCancelled] when +cancel_controller+ cancels (with or
188
192
  # without retries)
189
193
  def fetch(uri, request, retries: true, check_status: true, open_timeout: nil, read_timeout: nil,
190
- cancel_controller: nil, log_fields: {})
194
+ write_timeout: nil, cancel_controller: nil, log_fields: {})
191
195
  identify(request)
192
196
  current = new_attempt_state(uri, request, log_fields, stream: false)
193
197
  attempt = lambda do |state|
194
- start(uri, open_timeout: open_timeout, read_timeout: read_timeout, max_retries: 0) do |http|
198
+ start(uri, open_timeout: open_timeout, read_timeout: read_timeout, write_timeout: write_timeout, max_retries: 0) do |http|
195
199
  state[:http] = http
196
200
  http.request(request).tap do |response|
197
201
  state[:status] = response.code.to_i
@@ -221,8 +225,9 @@ module Samagotchi
221
225
  @api_key&.authorize(request)
222
226
  end
223
227
 
224
- def start(uri, open_timeout: nil, read_timeout: nil, max_retries: nil, &)
228
+ def start(uri, open_timeout: nil, read_timeout: nil, write_timeout: nil, max_retries: nil, &)
225
229
  options = { open_timeout: open_timeout || @open_timeout, read_timeout: read_timeout || @read_timeout }
230
+ options[:write_timeout] = write_timeout if write_timeout
226
231
  # Net::HTTP retries an idempotent request once on its own (max_retries
227
232
  # defaults to 1) when the connection drops before an answer: chi's
228
233
  # RetryPolicy owns retries (and the log line says how many were sent),
@@ -418,7 +423,7 @@ module Samagotchi
418
423
 
419
424
  error = ProviderErrors.from_response(status: status, body: response.body.to_s, host: @label,
420
425
  retry_after: response["Retry-After"],
421
- diagnosis: response["anthropic-thinking-prefix-mismatch"])
426
+ diagnosis: @diagnosis_header && response[@diagnosis_header])
422
427
  error.hint ||= @api_key ? @api_key.hint : ApiKey.missing_hint(@label) if error.is_a?(AuthError)
423
428
  raise error
424
429
  end
@@ -8,7 +8,11 @@ module Samagotchi
8
8
  # (:server), else zeros (:none). +cached_tokens+ is the prompt the server
9
9
  # reused from its cache, +cache_write_tokens+ what it wrote to it
10
10
  # (OpenRouter reports those for Anthropic models); 0 when not reported.
11
- Usage = Data.define(:prompt_tokens, :completion_tokens, :source, :cached_tokens, :cache_write_tokens) do
11
+ # +reasoning_tokens+ is the completion's reasoning part (0 when not
12
+ # reported), +cost+ the provider's reported cost in USD (OpenRouter's
13
+ # usage.cost; nil when not reported, a reported 0 stays 0).
14
+ Usage = Data.define(:prompt_tokens, :completion_tokens, :source, :cached_tokens, :cache_write_tokens,
15
+ :reasoning_tokens, :cost) do
12
16
  # @return [Usage, nil] nil when the payload carries no counts
13
17
  def self.from_payload(payload)
14
18
  counts = TokenUsage.from_payload(payload)
@@ -16,12 +20,14 @@ module Samagotchi
16
20
 
17
21
  new(prompt_tokens: counts.prompt_tokens.to_i, completion_tokens: counts.completion_tokens.to_i,
18
22
  source: :server, cached_tokens: counts.cached_tokens.to_i,
19
- cache_write_tokens: counts.cache_write_tokens.to_i)
23
+ cache_write_tokens: counts.cache_write_tokens.to_i, reasoning_tokens: counts.reasoning_tokens.to_i,
24
+ cost: counts.cost)
20
25
  end
21
26
 
22
27
  def self.none = new(prompt_tokens: 0, completion_tokens: 0, source: :none)
23
28
 
24
- def initialize(prompt_tokens:, completion_tokens:, source:, cached_tokens: 0, cache_write_tokens: 0)
29
+ def initialize(prompt_tokens:, completion_tokens:, source:, cached_tokens: 0, cache_write_tokens: 0,
30
+ reasoning_tokens: 0, cost: nil)
25
31
  super
26
32
  end
27
33
 
@@ -34,6 +34,22 @@ module Samagotchi
34
34
  end
35
35
  private_class_method :warn_once
36
36
 
37
+ # The cost rule, one place for a turn's generation (SessionMetrics) and
38
+ # a side request (SessionMetrics::SideTotals): a reported cost wins; a
39
+ # reported 0, or none, with a price is estimated from the price (a
40
+ # gateway that doesn't bill per call reports 0). The reported cost
41
+ # itself is summed apart, a 0 included.
42
+ # @param price [ModelPrice, nil]
43
+ # @param reported_cost [Numeric, nil] the provider's cost (usage.cost)
44
+ # @return [Float, nil] the estimate, nil when none applies (no price, or
45
+ # a positive reported cost), so a sum without one stays as it was
46
+ def self.estimate(price, reported_cost:, prompt_tokens:, cached_tokens:, cache_write_tokens:, completion_tokens:)
47
+ return nil unless price && (reported_cost.nil? || reported_cost.zero?)
48
+
49
+ price.cost(prompt_tokens: prompt_tokens, cached_tokens: cached_tokens, cache_write_tokens: cache_write_tokens,
50
+ completion_tokens: completion_tokens)
51
+ end
52
+
37
53
  # USD for one generation, OpenAI usage semantics: +prompt_tokens+ holds
38
54
  # the cached reads and the cache writes (OpenRouter keeps that for
39
55
  # Anthropic models), and reasoning is inside +completion_tokens+.
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Samagotchi
6
+ module Plugin
7
+ # A card's checked event (docs/plugins.md, Cards): what Engine#show_card
8
+ # routes and the bundle test kit records.
9
+ module Card
10
+ LEVELS = %i[info warn].freeze
11
+ MAX_ACTIONS = 6
12
+
13
+ module_function
14
+
15
+ # @return [Hash] {type: :card, id:, source:, title:, body:, level:,
16
+ # actions: [{label:, command:}]}; a new id when none is given
17
+ # @raise [ArgumentError] a card without a title, a bad level or action
18
+ def build(source:, title:, body:, actions:, level:, id:)
19
+ title = title.to_s.strip
20
+ raise ArgumentError, "a card needs a title" if title.empty?
21
+
22
+ level = level.to_s.to_sym
23
+ raise ArgumentError, "card level must be one of #{LEVELS.join(", ")}" unless LEVELS.include?(level)
24
+
25
+ actions = Array(actions)
26
+ raise ArgumentError, "a card has at most #{MAX_ACTIONS} actions" if actions.size > MAX_ACTIONS
27
+
28
+ actions = actions.map do |action|
29
+ raise ArgumentError, "a card action is a Hash {label:, command:}" unless action.is_a?(Hash)
30
+
31
+ action = action.transform_keys(&:to_sym)
32
+ command = action[:command].to_s.strip
33
+ raise ArgumentError, "a card action needs a command" if command.empty? || command.include?("\n")
34
+
35
+ label = action[:label].to_s.strip
36
+ { label: label.empty? ? command : label, command: command }
37
+ end
38
+ id = id.to_s.strip
39
+ { type: :card, id: id.empty? ? SecureRandom.hex(4) : id, source: source.to_s, title: title, body: body.to_s,
40
+ level: level, actions: actions }
41
+ end
42
+ end
43
+ end
44
+ end