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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -1
- data/docs/architecture.md +9 -1
- data/docs/cli.md +8 -0
- data/docs/configuration.md +3 -0
- data/docs/guardrails.md +26 -3
- data/docs/plugins.md +8 -3
- data/docs/sessions.md +8 -0
- data/docs/testing.md +19 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
- data/lib/samagotchi/bootstrap_command.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 +1 -1
- data/lib/samagotchi/engine.rb +31 -5
- data/lib/samagotchi/guardrails/data_readers.rb +214 -0
- 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/host_config.rb +8 -8
- 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/usage.rb +9 -3
- data/lib/samagotchi/model_price.rb +16 -0
- data/lib/samagotchi/plugin/context.rb +10 -3
- data/lib/samagotchi/plugin/service.rb +24 -1
- data/lib/samagotchi/recap_setup.rb +2 -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/host.rb +2 -2
- data/lib/samagotchi/testing/recorder.rb +7 -4
- data/lib/samagotchi/testing/sandbox.rb +5 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +4 -1
- data/lib/samagotchi/web/public/ctx.js +32 -6
- data/lib/samagotchi/web/public/info_bar_model.js +1 -1
- data/lib/samagotchi/web/public/lists_view.js +1 -1
- data/lib/samagotchi/web/public/stream_handlers.js +7 -0
- data/lib/samagotchi/web/public/timing.js +6 -1
- data/lib/samagotchi/web/session_summary.rb +2 -0
- 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
|
|
12
|
-
#
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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 =
|
|
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
|
|
361
|
-
#
|
|
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
|
-
|
|
366
|
-
next_op =
|
|
367
|
-
|
|
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
|
-
#
|
|
371
|
-
|
|
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
|
-
|
|
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
|
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+.
|
|
@@ -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 =
|
|
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: -> {
|
|
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 =
|
|
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
|
|