samagotchi 0.80.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -1
  3. data/docs/architecture.md +9 -1
  4. data/docs/cli.md +8 -0
  5. data/docs/configuration.md +3 -0
  6. data/docs/guardrails.md +26 -3
  7. data/docs/plugins.md +8 -3
  8. data/docs/sessions.md +8 -0
  9. data/docs/testing.md +19 -0
  10. data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
  11. data/lib/samagotchi/bootstrap_command.rb +1 -1
  12. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -4
  13. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  14. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/slow-commands/plugin.rb +2 -1
  16. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  17. data/lib/samagotchi/engine.rb +31 -5
  18. data/lib/samagotchi/guardrails/data_readers.rb +214 -0
  19. data/lib/samagotchi/guardrails/rules.rb +4 -2
  20. data/lib/samagotchi/guardrails/shell_lex.rb +166 -28
  21. data/lib/samagotchi/guardrails/targets.rb +14 -0
  22. data/lib/samagotchi/host_config.rb +8 -8
  23. data/lib/samagotchi/idle_client.rb +41 -11
  24. data/lib/samagotchi/idle_recap.rb +6 -1
  25. data/lib/samagotchi/idle_target.rb +12 -4
  26. data/lib/samagotchi/llm/usage.rb +9 -3
  27. data/lib/samagotchi/model_price.rb +16 -0
  28. data/lib/samagotchi/plugin/context.rb +10 -3
  29. data/lib/samagotchi/plugin/service.rb +24 -1
  30. data/lib/samagotchi/recap_setup.rb +2 -1
  31. data/lib/samagotchi/session_metrics/side_totals.rb +110 -0
  32. data/lib/samagotchi/session_metrics.rb +46 -12
  33. data/lib/samagotchi/session_observer.rb +3 -0
  34. data/lib/samagotchi/side_usage.rb +38 -0
  35. data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
  36. data/lib/samagotchi/terminal_ui/model_segments_text.rb +27 -3
  37. data/lib/samagotchi/testing/host.rb +2 -2
  38. data/lib/samagotchi/testing/recorder.rb +7 -4
  39. data/lib/samagotchi/testing/sandbox.rb +5 -3
  40. data/lib/samagotchi/version.rb +1 -1
  41. data/lib/samagotchi/web/app.rb +4 -1
  42. data/lib/samagotchi/web/public/ctx.js +32 -6
  43. data/lib/samagotchi/web/public/info_bar_model.js +1 -1
  44. data/lib/samagotchi/web/public/lists_view.js +1 -1
  45. data/lib/samagotchi/web/public/stream_handlers.js +7 -0
  46. data/lib/samagotchi/web/public/timing.js +6 -1
  47. data/lib/samagotchi/web/session_summary.rb +2 -0
  48. metadata +4 -1
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "data_readers"
4
+
3
5
  module Samagotchi
4
6
  module Guardrails
5
7
  # A small shell lexer for the guardrails' text checks (ShellGitDirs
@@ -8,8 +10,9 @@ module Samagotchi
8
10
  # backticks (quoted or not) as one opaque SUBST marker inside a word. A
9
11
  # heredoc's body (<<EOF, <<'EOF', <<-EOF) is one word after its <<TAG
10
12
  # word and lexing goes on after the terminator line: HEREDOC when cat or
11
- # tee read it as data, SUBST when an unquoted tag's body holds $(…) or
12
- # backticks, else the body text itself (a script, as sh -c '…' gives).
13
+ # tee read it as data (when the command ends at a plain separator,
14
+ # outside any ( or { group), SUBST when an unquoted tag's body holds $(…)
15
+ # or backticks, else the body text itself (a script, as sh -c '…' gives).
13
16
  # An unterminated heredoc is no heredoc: the rest is lexed as commands.
14
17
  # It never runs anything, and it does its best on odd input (an
15
18
  # unbalanced quote).
@@ -18,12 +21,15 @@ module Samagotchi
18
21
  OPERATORS = ["&&", "||", ";;", "|&", ";", "|", "&", "\n", "(", ")"].freeze
19
22
  # What a $(…) or `…` becomes inside a word.
20
23
  SUBST = "\0SUBST"
21
- # What a heredoc body that cat or tee read as data becomes.
22
- HEREDOC = "\0HEREDOC"
23
- # Commands that read a heredoc as data (when not piped on).
24
- DATA_SINKS = %w[cat tee].freeze
24
+ # What a heredoc body that a data reader reads as data becomes (its
25
+ # command ends at a plain separator, outside any ( or { group).
26
+ HEREDOC = DataReaders::HEREDOC
27
+ # The operators that may follow a data heredoc's command; any other
28
+ # (|, |&, &, (, ), ;;) may hand its output on, and & also starts
29
+ # &>file.
30
+ DATA_ENDS = ["\n", ";", "&&", "||"].freeze
25
31
  # A word that is a redirection (>, >>, 2>&1, <).
26
- REDIRECT = /\A\d*[<>]/
32
+ REDIRECT = DataReaders::REDIRECT
27
33
 
28
34
  # Words and operators ([:word, "x", specials] / [:op, "&&"]). A word's
29
35
  # specials are the characters the shell would act on in it, unquoted
@@ -57,6 +63,146 @@ module Samagotchi
57
63
  commands.reject { |c| c.is_a?(Array) && c.empty? }
58
64
  end
59
65
 
66
+ # Whether an ( or { group is still open at token +at+: a pipe after
67
+ # the group's ) or } takes what the group prints. The counters never go
68
+ # below 0 (a case pattern's "x)" closes nothing); ((…)) arithmetic and
69
+ # <(…) / >(…) count as open parens (the conservative reading).
70
+ def self.in_group?(tokens, at)
71
+ parens = 0
72
+ braces = 0
73
+ tokens[0...at].each do |kind, value|
74
+ if kind == :op
75
+ parens += 1 if value == "("
76
+ parens -= 1 if value == ")" && parens.positive?
77
+ elsif value == "{"
78
+ braces += 1
79
+ elsif value == "}" && braces.positive?
80
+ braces -= 1
81
+ end
82
+ end
83
+ parens.positive? || braces.positive?
84
+ end
85
+
86
+ # +text+ as code: the lines of each heredoc body its command only reads
87
+ # as data (DataReaders) cut out, the <<TAG word and the terminator line
88
+ # kept as written. This is what a rule's command: reads. A text the
89
+ # lexer isn't sure of (unterminated) comes back as it was, and so does
90
+ # one it can't read: the safe default.
91
+ def self.code_text(text)
92
+ lexer = Lexer.new(text)
93
+ tokens = lexer.tokens
94
+ return text if lexer.unterminated?
95
+
96
+ bodies = lexer.heredoc_spans.map { |doc| doc[:span] }
97
+ cuts = []
98
+ each_command(tokens) do |indices|
99
+ words = indices.map { |i| tokens[i][1] }
100
+ indices.each_with_index do |i, position|
101
+ kind, value, = tokens[i]
102
+ next unless kind == :word
103
+
104
+ span = lexer.spans[i]
105
+ cut = if bodies.include?(span)
106
+ body_cut(text, span, words) if value == HEREDOC
107
+ elsif value == SUBST
108
+ substitution_cut(text, tokens, i, span, words, position)
109
+ end
110
+ cuts << cut if cut
111
+ end
112
+ end
113
+ cuts.sort_by { |span, _| -span.begin }.each_with_object(+text) do |(span, replacement), out|
114
+ out[span] = replacement
115
+ end
116
+ rescue ArgumentError, EncodingError
117
+ text
118
+ end
119
+
120
+ # The token indices of each simple command, its operators left out.
121
+ def self.each_command(tokens)
122
+ current = []
123
+ tokens.each_with_index do |(kind, _value, *_rest), i|
124
+ if kind == :op
125
+ yield current unless current.empty?
126
+ current = []
127
+ else
128
+ current << i
129
+ end
130
+ end
131
+ yield current unless current.empty?
132
+ end
133
+ private_class_method :each_command
134
+
135
+ # [span, what it becomes] for a data heredoc's body at +span+ (its
136
+ # command's +words+), or nil when the body stays in the code text: a
137
+ # cat or tee writing a file may be run later (D1).
138
+ def self.body_cut(text, span, words)
139
+ return nil unless cut_body?(words)
140
+
141
+ [span, terminator_line(text[span])]
142
+ end
143
+ private_class_method :body_cut
144
+
145
+ # Whether a body a data reader reads leaves the code text: a cat or tee
146
+ # sending it to a file keeps it (D1); a body any other command reads as
147
+ # data is one the lexer's data_sink? approved.
148
+ def self.cut_body?(words)
149
+ verb = DataReaders.command_word(words)
150
+ return true unless DataReaders::SINKS.include?(File.basename(verb.to_s))
151
+
152
+ !DataReaders.writes_file?(words)
153
+ end
154
+ private_class_method :cut_body?
155
+
156
+ # [span, what it becomes] for the body of a "$(cat <<'EOF' … EOF)" given
157
+ # as a message (-m, --body, …) of a data reader (D2), or nil: eval,
158
+ # bash -c, a command piped on and a body that runs stay.
159
+ def self.substitution_cut(text, tokens, index, span, words, position)
160
+ return nil unless data_end?(tokens, index)
161
+ return nil if in_group?(tokens, index)
162
+ return nil unless DataReaders.message_arg?(words, position)
163
+
164
+ raw = text[span]
165
+ dollar = raw.start_with?("\"$(") ? 1 : 0
166
+ return nil unless raw[dollar, 2] == "$("
167
+
168
+ inner = raw[(dollar + 2)..]
169
+ inner_lexer = Lexer.new(inner)
170
+ first = inner_lexer.tokens.first(5)
171
+ return nil unless message_heredoc?(first)
172
+
173
+ inner_span = inner_lexer.spans[2]
174
+ return nil unless inner_span
175
+
176
+ start = span.begin + dollar + 2
177
+ cut = ((start + inner_span.begin)...(start + inner_span.end))
178
+ [cut, terminator_line(text[cut])]
179
+ end
180
+ private_class_method :substitution_cut
181
+
182
+ # Whether the command holding token +index+ ends at nil or a plain
183
+ # separator (not piped on: what it prints is not run).
184
+ def self.data_end?(tokens, index)
185
+ next_op = (tokens[(index + 1)..] || []).find { |token| token[0] == :op }
186
+ next_op.nil? || DATA_ENDS.include?(next_op[1])
187
+ end
188
+ private_class_method :data_end?
189
+
190
+ # Whether the tokens inside a $(…) are cat <<TAG and a data body, and
191
+ # the substitution closes right after.
192
+ def self.message_heredoc?(first)
193
+ first.size == 5 && first[0][0..1] == [:word, "cat"] &&
194
+ first[1][0] == :word && first[1][1].start_with?("<<") &&
195
+ first[2][0..1] == [:word, HEREDOC] && first[3] == [:op, "\n"] && first[4] == [:op, ")"]
196
+ end
197
+ private_class_method :message_heredoc?
198
+
199
+ # +raw+ (a body and its terminator line) as its terminator line alone.
200
+ def self.terminator_line(raw)
201
+ line = raw.rindex("\n")
202
+ line ? raw[(line + 1)..] : raw
203
+ end
204
+ private_class_method :terminator_line
205
+
60
206
  # A hand-written shell lexer: quotes, backslashes, comments, $(…) and
61
207
  # backticks (an opaque marker inside the word), redirections kept in
62
208
  # their word (2>&1 doesn't split on &), operators.
@@ -357,32 +503,24 @@ module Samagotchi
357
503
  body
358
504
  end
359
505
 
360
- # Whether the command the heredoc at token +at+ is in is cat or tee
361
- # and not piped into another command.
506
+ # Whether the command the heredoc at token +at+ is in reads it as data
507
+ # (DataReaders.data_reader?: cat, tee, git commit -F -, gh …), ends at
508
+ # a plain separator (DATA_ENDS: not piped on, backgrounded or handed to
509
+ # a process substitution) and sits outside any ( or { group (whose
510
+ # output a pipe after the group's ) or } would take).
362
511
  def data_sink?(at)
363
512
  start = at
364
513
  start -= 1 while start.positive? && @tokens[start - 1][0] == :word
365
- verb = command_word(@tokens[start...at])
366
- next_op = @tokens[at..].find { |t| t[0] == :op }
367
- DATA_SINKS.include?(File.basename(verb.to_s)) && !["|", "|&"].include?(next_op&.dig(1))
514
+ following = @tokens[at..] || []
515
+ next_op = following.index { |t| t[0] == :op }
516
+ ends = next_op.nil? || DATA_ENDS.include?(following[next_op][1])
517
+ words = @tokens[start...at].map { |t| t[1] }
518
+ words += @tokens[at...(next_op ? at + next_op : @tokens.size)].map { |t| t[1] }
519
+ DataReaders.data_reader?(words) && ends && !in_group?(at)
368
520
  end
369
521
 
370
- # The command word among the words before a heredoc's: the NAME=
371
- # words and the redirections dropped, and with a redirection written
372
- # with a space (> /tmp/x) its target word too.
373
- def command_word(words)
374
- i = 0
375
- while i < words.size
376
- word = words[i][1]
377
- if word.match?(REDIRECT)
378
- i += 1 if word.match?(/\A\d*[<>]{1,2}\z/)
379
- elsif !word.match?(/\A[A-Za-z_]\w*=/)
380
- return word
381
- end
382
- i += 1
383
- end
384
- nil
385
- end
522
+ # Whether an ( or { group is still open at token +at+.
523
+ def in_group?(at) = ShellLex.in_group?(@tokens, at)
386
524
  end
387
525
  end
388
526
  end
@@ -8,6 +8,7 @@ require_relative "../log"
8
8
  require_relative "model_size"
9
9
  require_relative "outside"
10
10
  require_relative "shell_git_dirs"
11
+ require_relative "shell_lex"
11
12
  require_relative "read_only_shell"
12
13
  require_relative "shell_paths"
13
14
  require_relative "rm_targets"
@@ -246,6 +247,19 @@ module Samagotchi
246
247
 
247
248
  def shell? = SHELL_TOOLS.include?(@tool)
248
249
 
250
+ # What a rule's command: reads: the shell command with the body of each
251
+ # heredoc its command only reads as data left out (ShellLex.code_text);
252
+ # a plugin tool's own command as given (its text may not be shell).
253
+ # Approvals, the approval question and hooks keep #command.
254
+ def rule_command
255
+ return @rule_command if defined?(@rule_command)
256
+
257
+ @rule_command = if @command.nil? then nil
258
+ elsif shell? then ShellLex.code_text(@command)
259
+ else @command
260
+ end
261
+ end
262
+
249
263
  # The repository's name: its main checkout's folder (the folder
250
264
  # holding the common .git dir; a bare repo's name less .git), else
251
265
  # the repo root's folder; nil outside a repo.
@@ -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
@@ -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
@@ -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+.
@@ -5,6 +5,7 @@ require_relative "../log"
5
5
  require_relative "../paths"
6
6
  require_relative "../guardrails/context"
7
7
  require_relative "../idle_client"
8
+ require_relative "service"
8
9
  require_relative "side_question"
9
10
 
10
11
  module Samagotchi
@@ -76,7 +77,7 @@ module Samagotchi
76
77
  # handler started) they act on the session now, through the Host.
77
78
  class Context
78
79
  # Thread.current key: {Context => the event its handler runs for}.
79
- CURRENT_EVENTS = :samagotchi_plugin_current_events
80
+ CURRENT_EVENTS = Service::CURRENT_EVENTS
80
81
  # Debug-log records tagged plugins, with bundle=<bundle> (tags are a
81
82
  # closed list, LogLine::TAGS).
82
83
  class Logger
@@ -294,7 +295,7 @@ module Samagotchi
294
295
  request = SideQuestion.request(messages: messages, prompt: prompt, system: system)
295
296
  limit = max_tokens.nil? ? ASK_MAX_TOKENS : Integer(max_tokens)
296
297
  @host.ask_model.call(request, timeout: Float(timeout), max_tokens: limit, cancel_controller: cancel,
297
- model: model&.strip).to_s
298
+ model: model&.strip, source: @bundle).to_s
298
299
  rescue IdleClient::SummarizeError => e
299
300
  raise ModelError, e.message
300
301
  rescue LLM::RequestCancelled
@@ -318,7 +319,7 @@ module Samagotchi
318
319
  # @return [Http]
319
320
  def http
320
321
  @http ||= Http.new(bundle: @bundle, transport: @http_transport,
321
- cancel_lookup: -> { @host.cancel_controller&.call(handler: in_handler?) })
322
+ cancel_lookup: -> { http_cancel_controller })
322
323
  end
323
324
 
324
325
  # This session's attached context: attach a URL or a command, list
@@ -387,6 +388,12 @@ module Samagotchi
387
388
  # Whether this plugin's handler runs on this thread (#with_event).
388
389
  def in_handler? = !!Thread.current[CURRENT_EVENTS]&.key?(self)
389
390
 
391
+ # The controller a ctx.http call without cancel: stops with: nil inside
392
+ # a service's start block, even on the turn's thread.
393
+ def http_cancel_controller
394
+ Service.starting? ? nil : @host.cancel_controller&.call(handler: in_handler?)
395
+ end
396
+
390
397
  # The event[:x] helper of the event this plugin's handler runs for on
391
398
  # this thread, or nil (not in a handler).
392
399
  def event_helper(name)
@@ -15,6 +15,16 @@ module Samagotchi
15
15
  # again.
16
16
  class Stopped < StandardError; end
17
17
 
18
+ # The thread-local Context#with_event keeps its handler events in;
19
+ # the start block runs with it cleared.
20
+ CURRENT_EVENTS = :samagotchi_plugin_current_events
21
+ # Thread.current key: true while a start block runs (the turn's own
22
+ # thread too: a ctx.http call there isn't the turn's).
23
+ STARTING = :samagotchi_plugin_service_starting
24
+
25
+ # Whether a service's start block runs on this thread.
26
+ def self.starting? = !!Thread.current[STARTING]
27
+
18
28
  # @return [String] "<bundle>:<name>"
19
29
  attr_reader :name
20
30
 
@@ -37,7 +47,7 @@ module Samagotchi
37
47
  return @value if @state == :running
38
48
 
39
49
  begin
40
- @value = @start.call(self)
50
+ @value = start_outside_handler
41
51
  @state = :running
42
52
  Log.info(:plugins, "service_started", service: @name)
43
53
  @value
@@ -81,6 +91,19 @@ module Samagotchi
81
91
 
82
92
  private
83
93
 
94
+ # The start block is the service's, not the handler's whose first
95
+ # #value started it: no event helper or ctx.http turn cancel inside.
96
+ def start_outside_handler
97
+ events = Thread.current[CURRENT_EVENTS]
98
+ starting = Thread.current[STARTING]
99
+ Thread.current[CURRENT_EVENTS] = nil
100
+ Thread.current[STARTING] = true
101
+ @start.call(self)
102
+ ensure
103
+ Thread.current[CURRENT_EVENTS] = events
104
+ Thread.current[STARTING] = starting
105
+ end
106
+
84
107
  def run_on_stop
85
108
  callbacks = @on_stop.reverse
86
109
  @on_stop = []
@@ -66,7 +66,8 @@ module Samagotchi
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
68
  store: RecapStore.new(session_id_lookup: -> { session_id.call }, state_dir_lookup: -> { state_dir.call }),
69
- session_id: -> { session_id.call }
69
+ session_id: -> { session_id.call },
70
+ on_usage: ->(usage) { engine.note_side_usage(usage) }
70
71
  )
71
72
  end
72
73