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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -1
  3. data/docs/architecture.md +2 -1
  4. data/docs/cli.md +14 -5
  5. data/docs/configuration.md +102 -5
  6. data/docs/internals/background-tasks.md +1 -0
  7. data/docs/internals/engine.md +1 -1
  8. data/docs/internals/llm-context-live.md +111 -0
  9. data/docs/sessions.md +2 -2
  10. data/lib/samagotchi/bridge/turn_accumulator.rb +5 -0
  11. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  12. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  13. data/lib/samagotchi/config.rb +57 -4
  14. data/lib/samagotchi/desktop/macos/Panel.swift +7 -3
  15. data/lib/samagotchi/engine.rb +39 -6
  16. data/lib/samagotchi/host_model.rb +116 -0
  17. data/lib/samagotchi/host_registry.rb +15 -9
  18. data/lib/samagotchi/kernel_loop.rb +2 -1
  19. data/lib/samagotchi/llm/chat_loop.rb +89 -7
  20. data/lib/samagotchi/llm/turn_settings.rb +5 -3
  21. data/lib/samagotchi/model_catalog.rb +17 -12
  22. data/lib/samagotchi/model_price.rb +54 -0
  23. data/lib/samagotchi/model_profile.rb +3 -1
  24. data/lib/samagotchi/process_group.rb +56 -9
  25. data/lib/samagotchi/recap_setup.rb +1 -1
  26. data/lib/samagotchi/self_report.rb +2 -1
  27. data/lib/samagotchi/session_commands.rb +23 -14
  28. data/lib/samagotchi/session_metrics.rb +40 -5
  29. data/lib/samagotchi/terminal_ui/attached_view.rb +2 -0
  30. data/lib/samagotchi/terminal_ui/formatting.rb +26 -9
  31. data/lib/samagotchi/terminal_ui/status_row.rb +12 -6
  32. data/lib/samagotchi/terminal_ui.rb +13 -0
  33. data/lib/samagotchi/tools/delegate_wait.rb +5 -4
  34. data/lib/samagotchi/tools/task_runtime.rb +11 -2
  35. data/lib/samagotchi/version.rb +1 -1
  36. data/lib/samagotchi/web/app.rb +5 -2
  37. data/lib/samagotchi/web/public/app.js +7 -3
  38. data/lib/samagotchi/web/public/ctx.js +20 -7
  39. data/lib/samagotchi/web/public/format.js +7 -2
  40. data/lib/samagotchi/web/public/model_pick.js +23 -3
  41. data/lib/samagotchi/web/public/model_picker.js +5 -3
  42. data/lib/samagotchi/web/public/stage_view.js +1 -0
  43. data/lib/samagotchi/web/public/turn_events.js +3 -2
  44. data/lib/samagotchi/web/public/turn_model.js +16 -0
  45. data/lib/samagotchi/web/public/turn_view.js +19 -0
  46. data/lib/samagotchi/worker.rb +16 -1
  47. 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 `--model` takes for
11
- # it (see .spell).
12
- Row = Data.define(:host, :id, :ref)
13
- # rows: the default host's first, then the others by name; warnings:
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
- data = results[host]
28
- if data[:error]
29
- warnings << "#{host}: #{data[:error]}"
32
+ if results[host][:error]
33
+ warnings << "#{host}: #{results[host][:error]}"
30
34
  next
31
35
  end
32
- Array(data[:models]).each do |info|
33
- id = info.id.to_s
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
- def leader?(pid)
55
- signalable?(pid) && Process.getpgid(pid) == pid
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
- # TERMs the group, waits up to +grace+ for +stopped+, then KILLs it.
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) }, **signal_options)
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
- deadline = monotonic + grace
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's list has it), resolved at each attempt.
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
- ServedModel.differs?(bare, served) ? "#{served} (not #{bare}: the server serves its own model)" : served
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
- models = Array(data[:models])
579
- if models.empty?
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(models, needle)
585
- models.each do |entry|
586
- identifier = entry.id.to_s.empty? ? "unknown" : entry.id
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 << [entry, identifier] if needle.empty? || identifier.to_s.downcase.include?(needle)
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 |entry, identifier|
599
- raw_status = entry.raw["status"] || entry.raw[:status]
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
- # Caller holds the mutex.
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"); "" for
346
- # none. As ctx.js costText.
347
- def cost_text(cost)
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
- lines << "cost: #{cost_text(tokens[:cost_sum])} (this session only)" if tokens[:cost_sum].to_f.positive?
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
- note = ServedModel.differs?(asked, snapshot[:served_model]) ? " (asked for #{asked})" : ""
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
- segments = [model ? status_model_text(model, @values[:default_model], served: served, served_for: served_for) : "",
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).