samagotchi 0.45.0 → 0.46.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 +51 -1
- data/docs/architecture.md +2 -1
- data/docs/cli.md +14 -5
- data/docs/configuration.md +102 -5
- data/docs/internals/background-tasks.md +1 -0
- data/docs/internals/engine.md +1 -1
- data/docs/internals/llm-context-live.md +111 -0
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/bridge/turn_accumulator.rb +5 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/config.rb +57 -4
- data/lib/samagotchi/desktop/macos/Panel.swift +7 -3
- data/lib/samagotchi/engine.rb +39 -6
- data/lib/samagotchi/host_model.rb +116 -0
- data/lib/samagotchi/host_registry.rb +15 -9
- data/lib/samagotchi/kernel_loop.rb +2 -1
- data/lib/samagotchi/llm/chat_loop.rb +89 -7
- data/lib/samagotchi/llm/turn_settings.rb +5 -3
- data/lib/samagotchi/model_catalog.rb +17 -12
- data/lib/samagotchi/model_price.rb +54 -0
- data/lib/samagotchi/model_profile.rb +3 -1
- data/lib/samagotchi/process_group.rb +56 -9
- data/lib/samagotchi/recap_setup.rb +1 -1
- data/lib/samagotchi/self_report.rb +2 -1
- data/lib/samagotchi/session_commands.rb +23 -14
- data/lib/samagotchi/session_metrics.rb +40 -5
- data/lib/samagotchi/terminal_ui/attached_view.rb +2 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +26 -9
- data/lib/samagotchi/terminal_ui/status_row.rb +12 -6
- data/lib/samagotchi/terminal_ui.rb +13 -0
- data/lib/samagotchi/tools/delegate_wait.rb +5 -4
- data/lib/samagotchi/tools/task_runtime.rb +11 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +5 -2
- data/lib/samagotchi/web/public/app.js +7 -3
- data/lib/samagotchi/web/public/ctx.js +20 -7
- data/lib/samagotchi/web/public/format.js +7 -2
- data/lib/samagotchi/web/public/model_pick.js +23 -3
- data/lib/samagotchi/web/public/model_picker.js +5 -3
- data/lib/samagotchi/web/public/stage_view.js +1 -0
- data/lib/samagotchi/web/public/turn_events.js +3 -2
- data/lib/samagotchi/web/public/turn_model.js +16 -0
- data/lib/samagotchi/web/public/turn_view.js +19 -0
- data/lib/samagotchi/worker.rb +16 -1
- metadata +4 -1
|
@@ -7,33 +7,37 @@ module Samagotchi
|
|
|
7
7
|
# picks from: GET /api/models and `chi models`. Every name question goes
|
|
8
8
|
# to ModelRef / HostRegistry#resolve; nothing here parses a ref.
|
|
9
9
|
module ModelCatalog
|
|
10
|
-
# host: the host that listed the id; ref: the name
|
|
11
|
-
# it (see .spell)
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
# host: the host that listed (or declares) the id; ref: the name
|
|
11
|
+
# `--model` takes for it (see .spell); configured: declared under
|
|
12
|
+
# hosts.<name>.models (HostModel.rows).
|
|
13
|
+
Row = Data.define(:host, :id, :ref, :configured) do
|
|
14
|
+
def initialize(host:, id:, ref:, configured: false) = super
|
|
15
|
+
end
|
|
16
|
+
# rows: the default host's first, then the others by name, each host's
|
|
17
|
+
# declared ids first (HostModel.rows); warnings:
|
|
14
18
|
# "host: error" per host that failed.
|
|
15
19
|
Listing = Data.define(:rows, :warnings)
|
|
16
20
|
|
|
17
21
|
module_function
|
|
18
22
|
|
|
19
23
|
# @param results [Hash] list_all_models' answer
|
|
20
|
-
# @param registry [#default_entry]
|
|
24
|
+
# @param registry [#default_entry, #entries]
|
|
21
25
|
# @return [Listing]
|
|
22
26
|
def listing(results, registry:)
|
|
23
27
|
default_host = registry.default_entry&.name
|
|
24
28
|
rows = []
|
|
25
29
|
warnings = []
|
|
30
|
+
host_rows = HostModel.rows(results, registry.entries)
|
|
26
31
|
results.keys.sort_by { |name| [name == default_host ? 0 : 1, name] }.each do |host|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
warnings << "#{host}: #{data[:error]}"
|
|
32
|
+
if results[host][:error]
|
|
33
|
+
warnings << "#{host}: #{results[host][:error]}"
|
|
30
34
|
next
|
|
31
35
|
end
|
|
32
|
-
|
|
33
|
-
id =
|
|
34
|
-
next if id.strip.empty? || id.end_with?(":batch")
|
|
36
|
+
host_rows[host].each do |row|
|
|
37
|
+
id = row.id
|
|
38
|
+
next if id.strip.empty? || (!row.configured && id.end_with?(":batch"))
|
|
35
39
|
|
|
36
|
-
rows << Row.new(host: host, id: id, ref: spell(host, id, default_host))
|
|
40
|
+
rows << Row.new(host: host, id: id, ref: spell(host, id, default_host), configured: row.configured)
|
|
37
41
|
end
|
|
38
42
|
end
|
|
39
43
|
Listing.new(rows: rows, warnings: warnings)
|
|
@@ -58,6 +62,7 @@ module Samagotchi
|
|
|
58
62
|
listing = listing(results, registry: registry)
|
|
59
63
|
models = listing.rows.map do |row|
|
|
60
64
|
hash = { name: row.ref, host: row.host, id: row.id }
|
|
65
|
+
hash[:configured] = true if row.configured
|
|
61
66
|
shadow = shadowed_by(row, registry)
|
|
62
67
|
shadow ? hash.merge(shadowed_by: shadow) : hash
|
|
63
68
|
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# A model's price on a host (hosts.<name>.models.<id>.price), in USD per
|
|
5
|
+
# 1M tokens: what a generation's cost is estimated from when the provider
|
|
6
|
+
# reports none (SessionMetrics, cost_estimate). An unset cache rate is the
|
|
7
|
+
# input rate: that overstates cached reads, and understates Anthropic
|
|
8
|
+
# cache writes (billed 1.25x input for the 5 min cache, 2x for 1 h).
|
|
9
|
+
ModelPrice = Data.define(:input, :cache_read, :cache_write, :output) do
|
|
10
|
+
# @param raw [Hash, nil] the price as written
|
|
11
|
+
# @param where [String] for the warning
|
|
12
|
+
# @return [ModelPrice, nil] nil when unset or invalid (warned once)
|
|
13
|
+
def self.parse(raw, where)
|
|
14
|
+
return nil if raw.nil?
|
|
15
|
+
|
|
16
|
+
values = raw.is_a?(Hash) ? raw.transform_keys(&:to_s).slice(*ModelPrice::KEYS) : nil
|
|
17
|
+
valid = values && values.key?("input") && values.key?("output") &&
|
|
18
|
+
# YAML's .inf and .nan are Floats too: JSON can't carry them
|
|
19
|
+
# (hosts_json_for_env would drop every host) and NaN poisons the sums.
|
|
20
|
+
values.values.all? { |v| v.is_a?(Numeric) && v.finite? && !v.negative? }
|
|
21
|
+
unless valid
|
|
22
|
+
warn_once "Warning: #{where}.price needs input and output, each a number >= 0 (USD per 1M tokens); ignored"
|
|
23
|
+
return nil
|
|
24
|
+
end
|
|
25
|
+
new(input: values["input"], cache_read: values.fetch("cache_read", values["input"]),
|
|
26
|
+
cache_write: values.fetch("cache_write", values["input"]), output: values["output"])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# config.rb requires model_price through host_model, so config is
|
|
30
|
+
# required here only when a warning needs it.
|
|
31
|
+
def self.warn_once(message)
|
|
32
|
+
require_relative "config"
|
|
33
|
+
ConfigFile.warn_once(message)
|
|
34
|
+
end
|
|
35
|
+
private_class_method :warn_once
|
|
36
|
+
|
|
37
|
+
# USD for one generation, OpenAI usage semantics: +prompt_tokens+ holds
|
|
38
|
+
# the cached reads and the cache writes (OpenRouter keeps that for
|
|
39
|
+
# Anthropic models), and reasoning is inside +completion_tokens+.
|
|
40
|
+
# llama.cpp's native timings.prompt_n leaves its cache_n out, so the
|
|
41
|
+
# uncached part is clamped at 0: an undercount, harmless because priced
|
|
42
|
+
# models are remote.
|
|
43
|
+
def cost(prompt_tokens:, cached_tokens:, cache_write_tokens:, completion_tokens:)
|
|
44
|
+
uncached = (prompt_tokens - cached_tokens - cache_write_tokens).clamp(0..)
|
|
45
|
+
((uncached * input) + (cached_tokens * cache_read) + (cache_write_tokens * cache_write) +
|
|
46
|
+
(completion_tokens * output)) / 1e6
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def to_config = to_h.transform_keys(&:to_s)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The keys a price may hold (config validation checks them too).
|
|
53
|
+
ModelPrice::KEYS = %w[input cache_read cache_write output].freeze
|
|
54
|
+
end
|
|
@@ -190,7 +190,7 @@ module Samagotchi
|
|
|
190
190
|
#
|
|
191
191
|
# Only a ref that names a host is checked (its alias resolved first, as
|
|
192
192
|
# ModelRef.parse does), and only against that host's own list: a bare id
|
|
193
|
-
# goes to whichever host lists it (HostRegistry#host_for_model), so no
|
|
193
|
+
# goes to whichever host lists or declares it (HostRegistry#host_for_model), so no
|
|
194
194
|
# single list can judge it. Nothing is checked either without a saved
|
|
195
195
|
# list for the host or with one older than ModelListStore::TTL_SECONDS
|
|
196
196
|
# (a week: the host may serve different models by now).
|
|
@@ -219,6 +219,8 @@ module Samagotchi
|
|
|
219
219
|
parsed = Samagotchi::ConfigFile.model_ref(model_name, env: env, hosts: hosts)
|
|
220
220
|
host = parsed.host_name
|
|
221
221
|
return nil unless host
|
|
222
|
+
# Declared under hosts.<name>.models: served whatever the host lists.
|
|
223
|
+
return nil if hosts[host.to_s.strip.downcase]&.dig(:models)&.key?(parsed.id.to_s.strip.downcase)
|
|
222
224
|
|
|
223
225
|
list = lists.read(env: env)[host.to_s.strip.downcase]
|
|
224
226
|
return nil if list.nil? || list.stale? || list.known?(parsed.id)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
3
5
|
module Samagotchi
|
|
4
6
|
# A command run in a process group of its own, so a stop reaches what it
|
|
5
7
|
# started too: TERM the group, a grace, then KILL it. Execute, TaskRuntime
|
|
@@ -51,12 +53,44 @@ module Samagotchi
|
|
|
51
53
|
|
|
52
54
|
# Whether +pid+ runs and leads its own group, as #spawn's process does
|
|
53
55
|
# (not, then, a reused pid in some other group).
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
# @param started [String, nil] the start time #start_time read when chi
|
|
57
|
+
# spawned it: a process that started at another time is one that
|
|
58
|
+
# reused the pid, even as a group leader. nil checks the group only (a
|
|
59
|
+
# record from an older chi), and so does a time that can't be read
|
|
60
|
+
# now (a ps that failed once must not disown a live task).
|
|
61
|
+
# @param start_time [#call] (pid) the reader, #start_time by default
|
|
62
|
+
def leader?(pid, started: nil, start_time: method(:start_time))
|
|
63
|
+
return false unless signalable?(pid) && Process.getpgid(pid) == pid
|
|
64
|
+
return true if started.nil?
|
|
65
|
+
|
|
66
|
+
now = start_time.call(pid)
|
|
67
|
+
now.nil? || now == started
|
|
56
68
|
rescue Errno::ESRCH, Errno::EPERM
|
|
57
69
|
false
|
|
58
70
|
end
|
|
59
71
|
|
|
72
|
+
# When +pid+'s process started, as an opaque string to compare: the
|
|
73
|
+
# start in clock ticks from /proc/PID/stat on Linux, ps's lstart (to the
|
|
74
|
+
# second, in UTC) elsewhere. nil when it isn't running or can't be read.
|
|
75
|
+
# @return [String, nil]
|
|
76
|
+
def start_time(pid)
|
|
77
|
+
return nil unless signalable?(pid)
|
|
78
|
+
|
|
79
|
+
stat = "/proc/#{pid}/stat"
|
|
80
|
+
return proc_start_time(File.read(stat)) if File.exist?(stat)
|
|
81
|
+
|
|
82
|
+
out, status = Open3.capture2({ "LC_ALL" => "C", "TZ" => "UTC" }, "ps", "-o", "lstart=", "-p", pid.to_s,
|
|
83
|
+
err: File::NULL)
|
|
84
|
+
time = out.strip
|
|
85
|
+
status.success? && !time.empty? ? time : nil
|
|
86
|
+
rescue SystemCallError, IOError
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Field 22 (starttime); the command name (field 2) may hold spaces and
|
|
91
|
+
# parentheses, so the fields are counted after its last ")".
|
|
92
|
+
def proc_start_time(stat) = stat[/\A.*\)\s(.*)\z/m, 1]&.split&.[](19)
|
|
93
|
+
|
|
60
94
|
# Whether +pid+ is still running (or a zombie not reaped yet).
|
|
61
95
|
# @raise [ArgumentError] unless #signalable?
|
|
62
96
|
def alive?(pid)
|
|
@@ -69,26 +103,39 @@ module Samagotchi
|
|
|
69
103
|
true
|
|
70
104
|
end
|
|
71
105
|
|
|
72
|
-
#
|
|
106
|
+
# How long #stop waits for +stopped+ after the KILL by default.
|
|
107
|
+
KILL_WAIT_SEC = 1.0
|
|
108
|
+
|
|
109
|
+
# TERMs the group, waits up to +grace+ for +stopped+, then KILLs it and
|
|
110
|
+
# waits up to +kill_wait+ for +stopped+ again (a KILL is delivered, not
|
|
111
|
+
# done, when kill returns).
|
|
73
112
|
# @param stopped [#call] true once it has stopped (default: the leader
|
|
74
113
|
# is gone)
|
|
75
114
|
# @param wait [#call] (seconds) between two checks (default: a sleep)
|
|
76
115
|
# @param signal_options [Hash] for #signal (leader:, quiet:)
|
|
77
116
|
# @return [Boolean] true when it stopped within the grace (no KILL sent)
|
|
78
117
|
# @raise [ArgumentError] unless #signalable?
|
|
79
|
-
def stop(pgid, grace:, poll:, stopped: -> { !alive?(pgid) }, wait: ->(seconds) { sleep(seconds) },
|
|
118
|
+
def stop(pgid, grace:, poll:, stopped: -> { !alive?(pgid) }, wait: ->(seconds) { sleep(seconds) },
|
|
119
|
+
kill_wait: KILL_WAIT_SEC, **signal_options)
|
|
80
120
|
check!(pgid)
|
|
81
121
|
signal(pgid, "TERM", **signal_options)
|
|
82
|
-
|
|
83
|
-
until (done = stopped.call) || monotonic >= deadline
|
|
84
|
-
wait.call(poll)
|
|
85
|
-
end
|
|
86
|
-
return true if done
|
|
122
|
+
return true if wait_for(stopped, grace, poll, wait)
|
|
87
123
|
|
|
88
124
|
signal(pgid, "KILL", **signal_options)
|
|
125
|
+
wait_for(stopped, kill_wait, poll, wait)
|
|
89
126
|
false
|
|
90
127
|
end
|
|
91
128
|
|
|
129
|
+
# Checks +stopped+ every +poll+ until it is true or +seconds+ are over.
|
|
130
|
+
# @return [Boolean] whether it stopped
|
|
131
|
+
def wait_for(stopped, seconds, poll, wait)
|
|
132
|
+
deadline = monotonic + seconds
|
|
133
|
+
until (done = stopped.call) || monotonic >= deadline
|
|
134
|
+
wait.call(poll)
|
|
135
|
+
end
|
|
136
|
+
done ? true : false
|
|
137
|
+
end
|
|
138
|
+
|
|
92
139
|
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
93
140
|
|
|
94
141
|
# Reads a pipe in chunks on a thread of its own, so the output read so
|
|
@@ -49,7 +49,7 @@ module Samagotchi
|
|
|
49
49
|
elsif !IdleTarget.host_named?(**settings)
|
|
50
50
|
# A model naming no host goes where a bare --model goes
|
|
51
51
|
# (HostRegistry#host_for_model: the default host unless
|
|
52
|
-
# another
|
|
52
|
+
# another lists or declares it), resolved at each attempt.
|
|
53
53
|
-> { IdleTarget.resolve(**settings) }
|
|
54
54
|
else
|
|
55
55
|
fixed = fixed_target(settings)
|
|
@@ -294,7 +294,8 @@ module Samagotchi
|
|
|
294
294
|
served = ServedModel.from_props(props)
|
|
295
295
|
return "unknown (no answer from the server's /props)" unless served
|
|
296
296
|
|
|
297
|
-
|
|
297
|
+
expected = entry.models&.dig(bare.to_s.downcase)&.serves?(served)
|
|
298
|
+
ServedModel.differs?(bare, served) && !expected ? "#{served} (not #{bare}: the server serves its own model)" : served
|
|
298
299
|
rescue StandardError => e
|
|
299
300
|
"unknown (#{e.class})"
|
|
300
301
|
end
|
|
@@ -492,10 +492,11 @@ module Samagotchi
|
|
|
492
492
|
end
|
|
493
493
|
end
|
|
494
494
|
|
|
495
|
-
# "; served: <name>" when the server serves another model than asked
|
|
495
|
+
# "; served: <name>" when the server serves another model than asked
|
|
496
|
+
# and its host's served: doesn't expect it.
|
|
496
497
|
def served_note
|
|
497
498
|
served, asked = @engine.served_model
|
|
498
|
-
ServedModel.differs?(asked, served) ? "; served: #{served}" : ""
|
|
499
|
+
ServedModel.differs?(asked, served) && !@engine.served_expected?(served) ? "; served: #{served}" : ""
|
|
499
500
|
rescue StandardError
|
|
500
501
|
""
|
|
501
502
|
end
|
|
@@ -567,6 +568,7 @@ module Samagotchi
|
|
|
567
568
|
|
|
568
569
|
seen = Set.new
|
|
569
570
|
lines = []
|
|
571
|
+
host_rows = HostModel.rows(results, registry.entries)
|
|
570
572
|
# Sort hosts for deterministic output
|
|
571
573
|
results.keys.sort.each do |hname|
|
|
572
574
|
data = results[hname]
|
|
@@ -575,29 +577,35 @@ module Samagotchi
|
|
|
575
577
|
lines << "#{host_label} — unreachable: #{data[:error]}"
|
|
576
578
|
next
|
|
577
579
|
end
|
|
578
|
-
|
|
579
|
-
|
|
580
|
+
# Declared ids (hosts.<name>.models) first, as "(config)", never cut
|
|
581
|
+
# by MODELS_PER_HOST.
|
|
582
|
+
rows = host_rows[hname]
|
|
583
|
+
if rows.empty?
|
|
580
584
|
lines << "#{host_label} — no models discovered"
|
|
581
585
|
next
|
|
582
586
|
end
|
|
587
|
+
declared = []
|
|
583
588
|
shown = []
|
|
584
|
-
batch_variants = batch_variant_ids(
|
|
585
|
-
|
|
586
|
-
identifier =
|
|
589
|
+
batch_variants = batch_variant_ids(rows, needle)
|
|
590
|
+
rows.each do |row|
|
|
591
|
+
identifier = row.id.to_s.empty? ? "unknown" : row.id
|
|
587
592
|
seen << identifier.to_s.downcase
|
|
588
593
|
# also track host-qualified seen for orphan logic
|
|
589
594
|
seen << "#{hname}:#{identifier}".downcase
|
|
590
|
-
next if batch_variants.include?(identifier)
|
|
595
|
+
next if !row.configured && batch_variants.include?(identifier)
|
|
596
|
+
next unless needle.empty? || identifier.to_s.downcase.include?(needle)
|
|
591
597
|
|
|
592
|
-
shown << [
|
|
598
|
+
(row.configured ? declared : shown) << [row, identifier]
|
|
593
599
|
end
|
|
594
|
-
next if shown.empty?
|
|
600
|
+
next if declared.empty? && shown.empty?
|
|
595
601
|
|
|
596
602
|
lines << "#{host_label}:"
|
|
597
603
|
hidden = needle.empty? ? [shown.size - MODELS_PER_HOST, 0].max : 0
|
|
598
|
-
shown.first(shown.size - hidden).each do |
|
|
599
|
-
|
|
604
|
+
(declared + shown.first(shown.size - hidden)).each do |row, identifier|
|
|
605
|
+
raw = row.info&.raw || {}
|
|
606
|
+
raw_status = raw["status"] || raw[:status]
|
|
600
607
|
status = raw_status.is_a?(Hash) ? (raw_status["value"] || raw_status[:value] || raw_status["status"] || raw_status[:status]) : raw_status
|
|
608
|
+
status = ["config", status].reject { |s| s.to_s.empty? }.join(", ") if row.configured
|
|
601
609
|
base = status.to_s.empty? ? " #{identifier}" : " #{identifier} (#{status})"
|
|
602
610
|
alias_list = (by_model[identifier.to_s.downcase] || []) + (by_model["#{hname}:#{identifier}".downcase] || [])
|
|
603
611
|
alias_list.uniq!
|
|
@@ -631,9 +639,10 @@ module Samagotchi
|
|
|
631
639
|
def batch_variant_ids(models, needle)
|
|
632
640
|
return Set.new if needle.include?("batch")
|
|
633
641
|
|
|
642
|
+
# Any case: a declared id keeps its own spelling (HostModel.rows).
|
|
634
643
|
ids = models.map { |entry| entry.id.to_s }
|
|
635
|
-
plain = ids.to_set
|
|
636
|
-
ids.select { |id| id.end_with?(":batch") && plain.include?(id.delete_suffix(":batch")) }.to_set
|
|
644
|
+
plain = ids.to_set(&:downcase)
|
|
645
|
+
ids.select { |id| id.downcase.end_with?(":batch") && plain.include?(id.downcase.delete_suffix(":batch")) }.to_set
|
|
637
646
|
end
|
|
638
647
|
end
|
|
639
648
|
end
|
|
@@ -7,6 +7,7 @@ require "fileutils"
|
|
|
7
7
|
require "securerandom"
|
|
8
8
|
require_relative "atomic_file"
|
|
9
9
|
require_relative "token_usage"
|
|
10
|
+
require_relative "model_price"
|
|
10
11
|
require_relative "session"
|
|
11
12
|
|
|
12
13
|
module Samagotchi
|
|
@@ -58,9 +59,11 @@ module Samagotchi
|
|
|
58
59
|
# memory_index: the memory indexes the session's prompt held
|
|
59
60
|
# ({ system: { tokens:, lines: }, project: … }, string keys as saved),
|
|
60
61
|
# nil when an older file has none.
|
|
62
|
+
# cost_estimate_sum: the estimated cost (hosts.<name>.models prices), 0
|
|
63
|
+
# in an older file.
|
|
61
64
|
SavedSummary = Data.define(:ctx_pct, :prompt_sum, :completion_sum, :cached_sum, :reasoning_sum, :cost_sum,
|
|
62
|
-
:memory_index) do
|
|
63
|
-
def initialize(memory_index: nil, **) = super
|
|
65
|
+
:cost_estimate_sum, :memory_index) do
|
|
66
|
+
def initialize(memory_index: nil, cost_estimate_sum: 0, **) = super
|
|
64
67
|
|
|
65
68
|
def tokens = to_h.except(:ctx_pct, :memory_index)
|
|
66
69
|
end
|
|
@@ -94,15 +97,21 @@ module Samagotchi
|
|
|
94
97
|
:gen_cache_write_max,
|
|
95
98
|
:gen_reasoning_max,
|
|
96
99
|
:gen_cost,
|
|
100
|
+
# The generation's configured price (ModelPrice, from
|
|
101
|
+
# :generation_started), what a generation without a reported cost
|
|
102
|
+
# (or a reported 0) is estimated from.
|
|
103
|
+
:gen_price,
|
|
97
104
|
:gen_decode_tps,
|
|
98
105
|
:gen_prefill_tps,
|
|
99
106
|
:gen_decode_ms,
|
|
100
107
|
:gen_first_chunk_at,
|
|
101
108
|
# The turn's generations, folded as each one ends: the count, the last
|
|
102
109
|
# one's prompt and completion, the sums, and which kinds of counts
|
|
103
|
-
# (server / estimate) they came from
|
|
110
|
+
# (server / estimate) they came from; prompt_max is the largest
|
|
111
|
+
# prompt (an LLM context edit can make a later one smaller).
|
|
104
112
|
:generations,
|
|
105
113
|
:prompt_last,
|
|
114
|
+
:prompt_max,
|
|
106
115
|
:prompt_sum,
|
|
107
116
|
:completion_last,
|
|
108
117
|
:completion_sum,
|
|
@@ -118,6 +127,8 @@ module Samagotchi
|
|
|
118
127
|
:reprefill_sum,
|
|
119
128
|
:reasoning_sum,
|
|
120
129
|
:cost_sum,
|
|
130
|
+
# The estimated cost of the generations priced from config (0 when none).
|
|
131
|
+
:cost_estimate_sum,
|
|
121
132
|
:decode_ms_sum,
|
|
122
133
|
:decode_tokens_sum,
|
|
123
134
|
:last_decode_tps,
|
|
@@ -166,6 +177,7 @@ module Samagotchi
|
|
|
166
177
|
SavedSummary.new(ctx_pct: saved_pct(data["context"], budget_tokens), prompt_sum: count.call("prompt_sum"),
|
|
167
178
|
completion_sum: count.call("completion_sum"), cached_sum: count.call("cached_sum"),
|
|
168
179
|
reasoning_sum: count.call("reasoning_sum"), cost_sum: count.call("cost_sum"),
|
|
180
|
+
cost_estimate_sum: count.call("cost_estimate_sum"),
|
|
169
181
|
memory_index: data["memory_index"].is_a?(Hash) ? data["memory_index"] : nil)
|
|
170
182
|
rescue JSON::ParserError, SystemCallError, TypeError
|
|
171
183
|
nil
|
|
@@ -322,6 +334,7 @@ module Samagotchi
|
|
|
322
334
|
@turn.gen_started_at = monotonic_time
|
|
323
335
|
@turn.generations += 1
|
|
324
336
|
reset_generation_tokens
|
|
337
|
+
@turn.gen_price = generation_price(event[:price])
|
|
325
338
|
@turn.gen_open = true
|
|
326
339
|
end
|
|
327
340
|
end
|
|
@@ -457,6 +470,7 @@ module Samagotchi
|
|
|
457
470
|
reprefill_sum: add.call(:reprefill_sum),
|
|
458
471
|
reasoning_sum: add.call(:reasoning_sum),
|
|
459
472
|
cost_sum: add.call(:cost_sum),
|
|
473
|
+
cost_estimate_sum: add.call(:cost_estimate_sum),
|
|
460
474
|
decode_ms_sum: decode_ms.round,
|
|
461
475
|
decode_tokens_sum: decode_tokens,
|
|
462
476
|
avg_decode_tps: decode_ms.positive? ? (decode_tokens * 1000.0 / decode_ms).round(1) : nil,
|
|
@@ -508,6 +522,7 @@ module Samagotchi
|
|
|
508
522
|
gen_reasoning_max: 0,
|
|
509
523
|
generations: 0,
|
|
510
524
|
prompt_last: nil,
|
|
525
|
+
prompt_max: nil,
|
|
511
526
|
prompt_sum: 0,
|
|
512
527
|
completion_last: 0,
|
|
513
528
|
completion_sum: 0,
|
|
@@ -517,6 +532,7 @@ module Samagotchi
|
|
|
517
532
|
reprefill_sum: 0,
|
|
518
533
|
reasoning_sum: 0,
|
|
519
534
|
cost_sum: nil,
|
|
535
|
+
cost_estimate_sum: 0,
|
|
520
536
|
decode_ms_sum: 0,
|
|
521
537
|
decode_tokens_sum: 0,
|
|
522
538
|
retries: 0,
|
|
@@ -627,6 +643,7 @@ module Samagotchi
|
|
|
627
643
|
if turn.gen_had_server
|
|
628
644
|
completion = turn.gen_completion_max
|
|
629
645
|
turn.prompt_last = turn.gen_prompt_max
|
|
646
|
+
turn.prompt_max = [turn.prompt_max.to_i, turn.gen_prompt_max].max
|
|
630
647
|
turn.prompt_sum += turn.gen_prompt_max
|
|
631
648
|
turn.token_sources |= ["server"]
|
|
632
649
|
speed = close_server_report(turn, completion)
|
|
@@ -656,6 +673,12 @@ module Samagotchi
|
|
|
656
673
|
turn.cache_write_sum += turn.gen_cache_write_max
|
|
657
674
|
turn.reasoning_sum += turn.gen_reasoning_max
|
|
658
675
|
turn.cost_sum = turn.cost_sum.to_f + turn.gen_cost if turn.gen_cost
|
|
676
|
+
# A reported cost wins; a reported 0 with a price is taken as no report
|
|
677
|
+
# (a gateway that doesn't bill per call); the 0 stays in cost.
|
|
678
|
+
if turn.gen_price && (turn.gen_cost.nil? || turn.gen_cost.zero?)
|
|
679
|
+
turn.cost_estimate_sum += turn.gen_price.cost(prompt_tokens: turn.gen_prompt_max, cached_tokens: turn.gen_cached_max,
|
|
680
|
+
cache_write_tokens: turn.gen_cache_write_max, completion_tokens: completion)
|
|
681
|
+
end
|
|
659
682
|
turn.last_prefill_tps = turn.gen_prefill_tps.round(1) if turn.gen_prefill_tps
|
|
660
683
|
speed = generation_speed(turn, completion)
|
|
661
684
|
return unless speed
|
|
@@ -685,7 +708,9 @@ module Samagotchi
|
|
|
685
708
|
[tps, decode_ms, source]
|
|
686
709
|
end
|
|
687
710
|
|
|
688
|
-
#
|
|
711
|
+
# The open generation's counts start over (a new generation, or a retry
|
|
712
|
+
# that streams from the start). Its price stays: generation_started sets
|
|
713
|
+
# it, and a retry sends none. Caller holds the mutex.
|
|
689
714
|
def reset_generation_tokens
|
|
690
715
|
@turn.gen_completion_max = 0
|
|
691
716
|
@turn.gen_prompt_max = 0
|
|
@@ -708,6 +733,7 @@ module Samagotchi
|
|
|
708
733
|
model: turn.model,
|
|
709
734
|
generations: turn.generations,
|
|
710
735
|
prompt_tokens: turn.prompt_last,
|
|
736
|
+
prompt_tokens_max: turn.prompt_max,
|
|
711
737
|
prompt_tokens_sum: turn.prompt_sum,
|
|
712
738
|
completion_tokens: turn.completion_sum,
|
|
713
739
|
context_used_tokens: turn.prompt_last && (turn.prompt_last + turn.completion_last),
|
|
@@ -738,6 +764,7 @@ module Samagotchi
|
|
|
738
764
|
fields[:cache_write_tokens_sum] = turn.cache_write_sum if turn.cache_write_sum.positive?
|
|
739
765
|
fields[:reprefill_tokens_sum] = turn.reprefill_sum if turn.reprefill_sum.positive?
|
|
740
766
|
fields.merge!(cost: turn.cost_sum, cost_source: "reported") if turn.cost_sum
|
|
767
|
+
fields[:cost_estimate] = turn.cost_estimate_sum if turn.cost_estimate_sum.positive?
|
|
741
768
|
fields
|
|
742
769
|
end
|
|
743
770
|
|
|
@@ -862,6 +889,13 @@ module Samagotchi
|
|
|
862
889
|
event_content.is_a?(String) ? event_content : ""
|
|
863
890
|
end
|
|
864
891
|
|
|
892
|
+
# The :generation_started event's price (ModelPrice#to_h), nil for none.
|
|
893
|
+
def generation_price(hash)
|
|
894
|
+
hash.is_a?(Hash) ? ModelPrice.new(**hash.transform_keys(&:to_sym)) : nil
|
|
895
|
+
rescue ArgumentError
|
|
896
|
+
nil
|
|
897
|
+
end
|
|
898
|
+
|
|
865
899
|
def monotonic_time
|
|
866
900
|
@clock.call
|
|
867
901
|
end
|
|
@@ -981,7 +1015,7 @@ module Samagotchi
|
|
|
981
1015
|
@totals = { cancellations: 0, prompt_sum: 0, completion_sum: 0, token_sources: [], iterations: 0,
|
|
982
1016
|
gen_ms: 0, retries: 0, cuts: 0, capped: 0, by_tool: {}, tool_errors: 0, cached_sum: 0, cache_write_sum: 0,
|
|
983
1017
|
reprefill_sum: 0, reasoning_sum: 0,
|
|
984
|
-
cost_sum: 0, decode_ms_sum: 0, decode_tokens_sum: 0, last_decode_tps: nil,
|
|
1018
|
+
cost_sum: 0, cost_estimate_sum: 0, decode_ms_sum: 0, decode_tokens_sum: 0, last_decode_tps: nil,
|
|
985
1019
|
last_prefill_tps: nil, tps_source: nil }
|
|
986
1020
|
end
|
|
987
1021
|
|
|
@@ -1000,6 +1034,7 @@ module Samagotchi
|
|
|
1000
1034
|
totals[:reprefill_sum] += number(record[:reprefill_tokens_sum])
|
|
1001
1035
|
totals[:reasoning_sum] += number(record[:reasoning_tokens])
|
|
1002
1036
|
totals[:cost_sum] += number(record[:cost])
|
|
1037
|
+
totals[:cost_estimate_sum] += number(record[:cost_estimate])
|
|
1003
1038
|
totals[:decode_ms_sum] += number(record[:decode_ms])
|
|
1004
1039
|
totals[:decode_tokens_sum] += number(record[:decode_tokens])
|
|
1005
1040
|
# The newest speeds a record has (an older record has none).
|
|
@@ -93,6 +93,8 @@ module Samagotchi
|
|
|
93
93
|
def generation_feedback_retrying(event)
|
|
94
94
|
@lock.synchronize do
|
|
95
95
|
@retry = format_generation_retry_line(event)
|
|
96
|
+
# A dropped stream's step asked again: its sentence so far is void.
|
|
97
|
+
@line.reset if event[:restarted]
|
|
96
98
|
# The retry streams from the start: wait for its first token again.
|
|
97
99
|
@waiting_since = @clock.call if @thinking
|
|
98
100
|
redraw_status
|
|
@@ -103,6 +103,9 @@ module Samagotchi
|
|
|
103
103
|
return "retrying (attempt #{event[:attempt]})" unless event[:max_retries]
|
|
104
104
|
|
|
105
105
|
text = "retrying (#{event[:attempt]}/#{event[:max_retries]} in #{format("%.1f", event[:next_delay].to_f)}s)"
|
|
106
|
+
# restarted: a dropped stream's step asked again (the chat loop's).
|
|
107
|
+
return "#{text}: stream dropped" if event[:restarted]
|
|
108
|
+
|
|
106
109
|
event[:error_class].to_s.empty? ? text : "#{text}: #{event[:error_class]}"
|
|
107
110
|
end
|
|
108
111
|
|
|
@@ -342,13 +345,25 @@ module Samagotchi
|
|
|
342
345
|
"(#{scopes.map { |scope, tokens| "#{scope} #{count.call(tokens)}" }.join(", ")})"
|
|
343
346
|
end
|
|
344
347
|
|
|
345
|
-
# "$0.42"; a cost under a cent keeps four decimals ("$0.0012"); ""
|
|
346
|
-
# none. As ctx.js
|
|
347
|
-
|
|
348
|
+
# "$0.42"; a cost under a cent keeps four decimals ("$0.0012"); "~$0.12"
|
|
349
|
+
# for an estimate (hosts.<name>.models prices); "" for none. As ctx.js
|
|
350
|
+
# costText.
|
|
351
|
+
def cost_text(cost, estimate: false)
|
|
348
352
|
value = cost.to_f
|
|
349
353
|
return "" unless value.positive?
|
|
350
354
|
|
|
351
|
-
value >= 0.01 ? format("$%.2f", value) : format("$%.4f", value)
|
|
355
|
+
"#{"~" if estimate}#{value >= 0.01 ? format("$%.2f", value) : format("$%.4f", value)}"
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# /stats' cost: the reported part and the estimated one, each when present.
|
|
359
|
+
def stats_cost_text(tokens)
|
|
360
|
+
reported = cost_text(tokens[:cost_sum])
|
|
361
|
+
estimated = cost_text(tokens[:cost_estimate_sum], estimate: true)
|
|
362
|
+
return nil if reported.empty? && estimated.empty?
|
|
363
|
+
return "#{reported} (this session only)" if estimated.empty?
|
|
364
|
+
|
|
365
|
+
estimate = "#{estimated} from hosts.<name>.models prices"
|
|
366
|
+
"#{reported.empty? ? estimate : "#{reported} reported + #{estimate}"} (this session only)"
|
|
352
367
|
end
|
|
353
368
|
|
|
354
369
|
private
|
|
@@ -411,9 +426,9 @@ module Samagotchi
|
|
|
411
426
|
|
|
412
427
|
# +served+ / +served_for+: the model the server said it served for the
|
|
413
428
|
# name asked (see Engine#served_model); shown first when it's another
|
|
414
|
-
# model.
|
|
415
|
-
def status_model_text(model, default_model, served: nil, served_for: nil)
|
|
416
|
-
if ServedModel.differs?(served_for, served)
|
|
429
|
+
# model, unless +expected+ (the host's served: names it).
|
|
430
|
+
def status_model_text(model, default_model, served: nil, served_for: nil, expected: false)
|
|
431
|
+
if !expected && ServedModel.differs?(served_for, served)
|
|
417
432
|
asked = model.to_s.length > STATUS_ASKED_MAX ? "#{model.to_s[0, STATUS_ASKED_MAX - 1]}…" : model
|
|
418
433
|
return "model=#{served} (served; asked #{asked})"
|
|
419
434
|
end
|
|
@@ -477,7 +492,8 @@ module Samagotchi
|
|
|
477
492
|
"#{token_breakdown_text(tokens)}"
|
|
478
493
|
speed = stats_speed_text(tokens)
|
|
479
494
|
lines << "speed: #{speed}" if speed
|
|
480
|
-
|
|
495
|
+
cost = stats_cost_text(tokens)
|
|
496
|
+
lines << "cost: #{cost}" if cost
|
|
481
497
|
lines << "gen latency (ms): #{snapshot[:gen_latency_ms]}"
|
|
482
498
|
lines << "cancellations: #{snapshot[:cancellations]}"
|
|
483
499
|
lines << "retries: #{snapshot[:retries]}"
|
|
@@ -500,7 +516,8 @@ module Samagotchi
|
|
|
500
516
|
lines << "llm context: #{llm_context}" if llm_context
|
|
501
517
|
if snapshot[:served_model]
|
|
502
518
|
asked = snapshot[:served_model_for]
|
|
503
|
-
|
|
519
|
+
differs = !snapshot[:served_expected_by] && ServedModel.differs?(asked, snapshot[:served_model])
|
|
520
|
+
note = differs ? " (asked for #{asked})" : ""
|
|
504
521
|
lines << "served model: #{snapshot[:served_model]}#{note}"
|
|
505
522
|
end
|
|
506
523
|
lines.join("\n")
|
|
@@ -17,8 +17,9 @@ module Samagotchi
|
|
|
17
17
|
# What the row shows:
|
|
18
18
|
# model, default_model the model in use, and the config's default
|
|
19
19
|
# (named beside another model)
|
|
20
|
-
# served [served, asked]: what the server said it
|
|
21
|
-
# served for the name asked (ServedModel)
|
|
20
|
+
# served [served, asked, expected]: what the server said it
|
|
21
|
+
# served for the name asked (ServedModel), and
|
|
22
|
+
# the host whose served: expects it (or nil)
|
|
22
23
|
# parent_id the session that delegated this one
|
|
23
24
|
# children its delegates' ChildrenStatus::Counts, or
|
|
24
25
|
# nil (the segment shows running and waiting)
|
|
@@ -61,7 +62,7 @@ module Samagotchi
|
|
|
61
62
|
# @param default_model [String, nil] the config's default, named beside
|
|
62
63
|
# another model
|
|
63
64
|
def take_state(state, default_model: nil)
|
|
64
|
-
fields = { served: state[:served_model] ? [state[:served_model], state[:served_model_for]] : nil }
|
|
65
|
+
fields = { served: state[:served_model] ? [state[:served_model], state[:served_model_for], state[:served_expected_by]] : nil }
|
|
65
66
|
fields.merge!(model: state[:model_name], default_model: default_model) if state[:model_name]
|
|
66
67
|
STATE_LISTS.each { |field, key| fields[field] = Array(state[key]) if state.key?(key) }
|
|
67
68
|
fields[:parent_id] = state[:parent_id] if state.key?(:parent_id)
|
|
@@ -82,7 +83,7 @@ module Samagotchi
|
|
|
82
83
|
when :context_status then update(context: { est_pct: event.dig(:usage, :estimated_pct), bucket: event[:bucket] })
|
|
83
84
|
when :used_memories_updated then update(used_memories: Array(event[:used_memory_names]))
|
|
84
85
|
when :generation_completed
|
|
85
|
-
update(served: [event[:served_model], event[:requested_model]]) if event[:served_model]
|
|
86
|
+
update(served: [event[:served_model], event[:requested_model], event[:served_expected_by]]) if event[:served_model]
|
|
86
87
|
end
|
|
87
88
|
end
|
|
88
89
|
|
|
@@ -104,10 +105,15 @@ module Samagotchi
|
|
|
104
105
|
|
|
105
106
|
# @return [Array<String>] the row cut to +width+ (none with nothing to say)
|
|
106
107
|
def rows(width)
|
|
107
|
-
served, served_for = @values[:served]
|
|
108
|
+
served, served_for, expected = @values[:served]
|
|
108
109
|
model = @values[:model]
|
|
109
110
|
parent = @values[:parent_id]
|
|
110
|
-
|
|
111
|
+
model_text = ""
|
|
112
|
+
if model
|
|
113
|
+
model_text = status_model_text(model, @values[:default_model], served: served, served_for: served_for,
|
|
114
|
+
expected: expected.is_a?(String))
|
|
115
|
+
end
|
|
116
|
+
segments = [model_text,
|
|
111
117
|
parent ? "↳ #{parent.to_s[0, 8]}" : "",
|
|
112
118
|
children_text(@values[:children]),
|
|
113
119
|
status_context_text(estimate: @values[:context]),
|
|
@@ -1322,6 +1322,8 @@ module Samagotchi
|
|
|
1322
1322
|
|
|
1323
1323
|
# The activity row goes while the question waits.
|
|
1324
1324
|
@view.finish_thinking_spinner
|
|
1325
|
+
return dismiss_unaskable_question(prompt) if @non_interactive
|
|
1326
|
+
|
|
1325
1327
|
# An edit's diff goes above the slot, into the scrollback.
|
|
1326
1328
|
preview = prompt.preview_lines(paint: method(:paint))
|
|
1327
1329
|
@surface.commit(preview.join("\n")) unless preview.empty?
|
|
@@ -1345,6 +1347,17 @@ module Samagotchi
|
|
|
1345
1347
|
@surface.clear_slot(:notes)
|
|
1346
1348
|
end
|
|
1347
1349
|
|
|
1350
|
+
# --non-interactive has no one to ask (as for an approval or a hook's
|
|
1351
|
+
# question there): the question is dismissed at once, as an empty answer
|
|
1352
|
+
# would be, and the model finishes its reply. Never a read of stdin: a
|
|
1353
|
+
# pipe its writer keeps open would block it forever. Its one line goes to
|
|
1354
|
+
# stderr (#banner): stdout is the answer's alone.
|
|
1355
|
+
def dismiss_unaskable_question(prompt)
|
|
1356
|
+
@engine.cancel_question("user") rescue nil
|
|
1357
|
+
banner(prompt.summary("(dismissed: no one to answer in a non-interactive run)", paint: method(:paint)))
|
|
1358
|
+
false
|
|
1359
|
+
end
|
|
1360
|
+
|
|
1348
1361
|
# Loop until a valid selection or a cancel. The block reads one answer:
|
|
1349
1362
|
# a line, nil (Ctrl-D, or Ctrl-C with no turn), or :canceled (the turn
|
|
1350
1363
|
# was cancelled).
|