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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +59 -1
- data/README.md +7 -5
- data/docs/architecture.md +9 -1
- data/docs/cli.md +32 -7
- data/docs/configuration.md +10 -4
- data/docs/guardrails.md +26 -3
- data/docs/internals/anthropic.md +3 -1
- data/docs/internals/bundles.md +4 -0
- data/docs/internals/engine.md +13 -3
- data/docs/plugins.md +149 -3
- data/docs/releasing.md +2 -2
- data/docs/sessions.md +9 -1
- data/docs/testing.md +51 -2
- data/lib/samagotchi/bootstrap/config_writer.rb +13 -3
- data/lib/samagotchi/bootstrap/probe.rb +153 -31
- data/lib/samagotchi/bootstrap_command.rb +93 -16
- data/lib/samagotchi/bridge.rb +1 -1
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -4
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/continue_offer.rb +5 -5
- data/lib/samagotchi/engine.rb +68 -39
- data/lib/samagotchi/guardrails/approval.rb +6 -4
- data/lib/samagotchi/guardrails/data_readers.rb +214 -0
- data/lib/samagotchi/guardrails/gate.rb +7 -3
- data/lib/samagotchi/guardrails/rules.rb +4 -2
- data/lib/samagotchi/guardrails/shell_lex.rb +166 -28
- data/lib/samagotchi/guardrails/targets.rb +14 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +31 -16
- data/lib/samagotchi/hooks/registry.rb +44 -3
- data/lib/samagotchi/host_config.rb +8 -8
- data/lib/samagotchi/host_registry.rb +12 -4
- data/lib/samagotchi/idle_client.rb +41 -11
- data/lib/samagotchi/idle_recap.rb +6 -1
- data/lib/samagotchi/idle_target.rb +12 -4
- data/lib/samagotchi/llm/adapter_common.rb +4 -2
- data/lib/samagotchi/llm/anthropic_binding_recovery.rb +3 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +2 -1
- data/lib/samagotchi/llm/http.rb +12 -7
- data/lib/samagotchi/llm/usage.rb +9 -3
- data/lib/samagotchi/model_price.rb +16 -0
- data/lib/samagotchi/plugin/card.rb +44 -0
- data/lib/samagotchi/plugin/context.rb +61 -9
- data/lib/samagotchi/plugin/http.rb +154 -0
- data/lib/samagotchi/plugin/http_transport.rb +77 -0
- data/lib/samagotchi/plugin/loader.rb +29 -10
- data/lib/samagotchi/plugin/service.rb +24 -1
- data/lib/samagotchi/question_answer.rb +29 -0
- data/lib/samagotchi/question_desk.rb +20 -19
- data/lib/samagotchi/recap_setup.rb +2 -1
- data/lib/samagotchi/relay_verifier.rb +1 -1
- data/lib/samagotchi/session_metrics/side_totals.rb +110 -0
- data/lib/samagotchi/session_metrics.rb +46 -12
- data/lib/samagotchi/session_observer.rb +3 -0
- data/lib/samagotchi/side_usage.rb +38 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
- data/lib/samagotchi/terminal_ui/model_segments_text.rb +27 -3
- data/lib/samagotchi/testing/boundary.rb +156 -0
- data/lib/samagotchi/testing/bundle.rb +349 -0
- data/lib/samagotchi/testing/fake_executable.rb +42 -0
- data/lib/samagotchi/testing/fake_http.rb +137 -0
- data/lib/samagotchi/testing/helpers.rb +56 -0
- data/lib/samagotchi/testing/host.rb +76 -0
- data/lib/samagotchi/testing/minitest.rb +31 -0
- data/lib/samagotchi/testing/recorder.rb +141 -0
- data/lib/samagotchi/testing/recording_api.rb +27 -0
- data/lib/samagotchi/testing/rspec.rb +31 -0
- data/lib/samagotchi/testing/sandbox.rb +165 -0
- data/lib/samagotchi/testing/sessions.rb +77 -0
- data/lib/samagotchi/testing/verdict.rb +28 -0
- data/lib/samagotchi/testing/waiting.rb +36 -0
- data/lib/samagotchi/testing.rb +26 -0
- data/lib/samagotchi/tools/delegate_relay.rb +7 -6
- data/lib/samagotchi/tools/tool_index.rb +3 -0
- data/lib/samagotchi/turn_state.rb +28 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +4 -1
- data/lib/samagotchi/web/public/app.js +47 -23
- data/lib/samagotchi/web/public/ctx.js +32 -6
- data/lib/samagotchi/web/public/images.js +33 -0
- data/lib/samagotchi/web/public/index.html +71 -38
- data/lib/samagotchi/web/public/info_bar_model.js +1 -1
- data/lib/samagotchi/web/public/level_chip.js +11 -11
- data/lib/samagotchi/web/public/lists_view.js +20 -15
- data/lib/samagotchi/web/public/llm_context_chip.js +15 -15
- data/lib/samagotchi/web/public/model_picker.js +1 -1
- data/lib/samagotchi/web/public/scope.js +3 -3
- data/lib/samagotchi/web/public/stream_handlers.js +7 -0
- data/lib/samagotchi/web/public/strip.js +35 -9
- data/lib/samagotchi/web/public/strip_grip.js +76 -0
- data/lib/samagotchi/web/public/thinking_chip.js +5 -5
- data/lib/samagotchi/web/public/timing.js +6 -1
- data/lib/samagotchi/web/session_summary.rb +2 -0
- 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 = (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
180
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
data/lib/samagotchi/llm/http.rb
CHANGED
|
@@ -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)
|
|
47
|
-
|
|
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[
|
|
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
|
data/lib/samagotchi/llm/usage.rb
CHANGED
|
@@ -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
|
-
|
|
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
|