samagotchi 0.45.0 → 0.46.1
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 +92 -1
- data/docs/architecture.md +2 -1
- data/docs/cli.md +22 -7
- data/docs/configuration.md +105 -6
- data/docs/desktop.md +2 -1
- 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/github-pr/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/plugin.rb +26 -5
- 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 +61 -4
- data/lib/samagotchi/desktop/macos/Panel.swift +7 -3
- data/lib/samagotchi/engine.rb +41 -9
- data/lib/samagotchi/host_model.rb +123 -0
- data/lib/samagotchi/host_registry.rb +21 -10
- data/lib/samagotchi/kernel_loop.rb +2 -1
- data/lib/samagotchi/llm/chat_loop.rb +109 -7
- data/lib/samagotchi/llm/errors.rb +8 -2
- data/lib/samagotchi/llm/turn_settings.rb +5 -3
- data/lib/samagotchi/llm_context_strategy.rb +1 -0
- data/lib/samagotchi/model_catalog.rb +22 -16
- data/lib/samagotchi/model_price.rb +54 -0
- data/lib/samagotchi/model_profile.rb +7 -4
- data/lib/samagotchi/models_command.rb +12 -5
- data/lib/samagotchi/process_group.rb +56 -9
- data/lib/samagotchi/question_desk.rb +29 -4
- data/lib/samagotchi/recap_setup.rb +1 -1
- data/lib/samagotchi/self_report.rb +2 -1
- data/lib/samagotchi/served_model.rb +9 -0
- data/lib/samagotchi/session_commands.rb +27 -17
- 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 +47 -15
- data/lib/samagotchi/terminal_ui/status_row.rb +12 -6
- data/lib/samagotchi/terminal_ui.rb +15 -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 +43 -6
- 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/index.html +3 -0
- data/lib/samagotchi/web/public/model_pick.js +36 -9
- data/lib/samagotchi/web/public/model_picker.js +11 -6
- data/lib/samagotchi/web/public/question_card.js +8 -0
- data/lib/samagotchi/web/public/stage_view.js +1 -0
- data/lib/samagotchi/web/public/turn_events.js +10 -5
- 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 +20 -1
- metadata +4 -1
|
@@ -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,37 +568,45 @@ 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]
|
|
573
575
|
host_label = "#{hname} (#{data[:host]}:#{data[:port]})"
|
|
576
|
+
# Declared ids (hosts.<name>.models) first, as "(config)", never cut
|
|
577
|
+
# by MODELS_PER_HOST; a host that failed keeps only those, as
|
|
578
|
+
# "(config, host down)" under its unreachable line.
|
|
579
|
+
rows = host_rows.fetch(hname, [])
|
|
574
580
|
if data[:error]
|
|
575
581
|
lines << "#{host_label} — unreachable: #{data[:error]}"
|
|
576
|
-
next
|
|
577
|
-
|
|
578
|
-
models = Array(data[:models])
|
|
579
|
-
if models.empty?
|
|
582
|
+
next if rows.empty?
|
|
583
|
+
elsif 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
|
-
lines << "#{host_label}:"
|
|
602
|
+
lines << "#{host_label}:" unless data[:error]
|
|
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
|
|
609
|
+
status = "#{status}, host down" if row.unavailable
|
|
601
610
|
base = status.to_s.empty? ? " #{identifier}" : " #{identifier} (#{status})"
|
|
602
611
|
alias_list = (by_model[identifier.to_s.downcase] || []) + (by_model["#{hname}:#{identifier}".downcase] || [])
|
|
603
612
|
alias_list.uniq!
|
|
@@ -631,9 +640,10 @@ module Samagotchi
|
|
|
631
640
|
def batch_variant_ids(models, needle)
|
|
632
641
|
return Set.new if needle.include?("batch")
|
|
633
642
|
|
|
643
|
+
# Any case: a declared id keeps its own spelling (HostModel.rows).
|
|
634
644
|
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
|
|
645
|
+
plain = ids.to_set(&:downcase)
|
|
646
|
+
ids.select { |id| id.downcase.end_with?(":batch") && plain.include?(id.downcase.delete_suffix(":batch")) }.to_set
|
|
637
647
|
end
|
|
638
648
|
end
|
|
639
649
|
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
|
|
@@ -96,14 +96,29 @@ module Samagotchi
|
|
|
96
96
|
paint(TurnNote.empty_answer_line(retries), 90)
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
-
# "retrying (
|
|
100
|
-
#
|
|
101
|
-
#
|
|
99
|
+
# "↻ retrying (503) in 4.0s, 2/5": the live status while the provider
|
|
100
|
+
# is asked again (generation_retrying), worded as the web words it
|
|
101
|
+
# (turn_events.js retryStatusLine; spec/shared/labels_matrix.json):
|
|
102
|
+
# why (the HTTP status, the error's short class name, or "stream
|
|
103
|
+
# dropped" for a dropped stream's step asked again), the wait before
|
|
104
|
+
# it, and the retry of all the retries there will be.
|
|
102
105
|
def format_generation_retry_line(event)
|
|
103
|
-
|
|
106
|
+
why = event[:restarted] ? "stream dropped" : retry_cause(event)
|
|
107
|
+
delay = Float(event[:next_delay], exception: false)
|
|
108
|
+
text = +"\u21bb retrying"
|
|
109
|
+
text << " (#{why})" unless why.empty?
|
|
110
|
+
text << " in #{format("%.1f", (delay * 10).round / 10.0)}s" if delay&.finite?
|
|
111
|
+
text << ", #{event[:attempt]}#{"/#{event[:max_retries]}" if event[:max_retries]}" if event[:attempt]
|
|
112
|
+
text
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# What failed, for a retry line: the HTTP status, else the error's
|
|
116
|
+
# class name without its namespace (Errno::ECONNREFUSED: ECONNREFUSED).
|
|
117
|
+
def retry_cause(event)
|
|
118
|
+
status = event[:status]
|
|
119
|
+
return status.to_s unless status.nil? || status.to_s.empty? || status.to_s == "0"
|
|
104
120
|
|
|
105
|
-
|
|
106
|
-
event[:error_class].to_s.empty? ? text : "#{text}: #{event[:error_class]}"
|
|
121
|
+
event[:error_class].to_s.split("::").last.to_s
|
|
107
122
|
end
|
|
108
123
|
|
|
109
124
|
# " → image 1280×800" after a tool line whose tool read an image.
|
|
@@ -342,13 +357,28 @@ module Samagotchi
|
|
|
342
357
|
"(#{scopes.map { |scope, tokens| "#{scope} #{count.call(tokens)}" }.join(", ")})"
|
|
343
358
|
end
|
|
344
359
|
|
|
345
|
-
# "$0.42"; a cost under a cent keeps four decimals ("$0.0012"); ""
|
|
346
|
-
# none. As ctx.js
|
|
347
|
-
|
|
360
|
+
# "$0.42"; a cost under a cent keeps four decimals ("$0.0012"); "~$0.12"
|
|
361
|
+
# for an estimate (hosts.<name>.models prices); "" for none. As ctx.js
|
|
362
|
+
# costText.
|
|
363
|
+
def cost_text(cost, estimate: false)
|
|
348
364
|
value = cost.to_f
|
|
349
365
|
return "" unless value.positive?
|
|
350
366
|
|
|
351
|
-
value >= 0.01 ? format("$%.2f", value) : format("$%.4f", value)
|
|
367
|
+
"#{"~" if estimate}#{value >= 0.01 ? format("$%.2f", value) : format("$%.4f", value)}"
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# /stats' cost: the reported part and the estimated one, each when present.
|
|
371
|
+
# +price_host+ names the host whose hosts.<name>.models prices the
|
|
372
|
+
# estimate came from (Engine#stats_snapshot; nil from an older worker).
|
|
373
|
+
def stats_cost_text(tokens, price_host: nil)
|
|
374
|
+
reported = cost_text(tokens[:cost_sum])
|
|
375
|
+
estimated = cost_text(tokens[:cost_estimate_sum], estimate: true)
|
|
376
|
+
return nil if reported.empty? && estimated.empty?
|
|
377
|
+
return "#{reported} (this session only)" if estimated.empty?
|
|
378
|
+
|
|
379
|
+
source = price_host.to_s.empty? ? "configured prices" : "hosts.#{price_host}.models prices"
|
|
380
|
+
estimate = "#{estimated} from #{source}"
|
|
381
|
+
"#{reported.empty? ? estimate : "#{reported} reported + #{estimate}"} (this session only)"
|
|
352
382
|
end
|
|
353
383
|
|
|
354
384
|
private
|
|
@@ -411,9 +441,9 @@ module Samagotchi
|
|
|
411
441
|
|
|
412
442
|
# +served+ / +served_for+: the model the server said it served for the
|
|
413
443
|
# 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)
|
|
444
|
+
# model, unless +expected+ (the host's served: names it).
|
|
445
|
+
def status_model_text(model, default_model, served: nil, served_for: nil, expected: false)
|
|
446
|
+
if !expected && ServedModel.differs?(served_for, served)
|
|
417
447
|
asked = model.to_s.length > STATUS_ASKED_MAX ? "#{model.to_s[0, STATUS_ASKED_MAX - 1]}…" : model
|
|
418
448
|
return "model=#{served} (served; asked #{asked})"
|
|
419
449
|
end
|
|
@@ -477,7 +507,8 @@ module Samagotchi
|
|
|
477
507
|
"#{token_breakdown_text(tokens)}"
|
|
478
508
|
speed = stats_speed_text(tokens)
|
|
479
509
|
lines << "speed: #{speed}" if speed
|
|
480
|
-
|
|
510
|
+
cost = stats_cost_text(tokens, price_host: snapshot[:price_host])
|
|
511
|
+
lines << "cost: #{cost}" if cost
|
|
481
512
|
lines << "gen latency (ms): #{snapshot[:gen_latency_ms]}"
|
|
482
513
|
lines << "cancellations: #{snapshot[:cancellations]}"
|
|
483
514
|
lines << "retries: #{snapshot[:retries]}"
|
|
@@ -500,7 +531,8 @@ module Samagotchi
|
|
|
500
531
|
lines << "llm context: #{llm_context}" if llm_context
|
|
501
532
|
if snapshot[:served_model]
|
|
502
533
|
asked = snapshot[:served_model_for]
|
|
503
|
-
|
|
534
|
+
differs = !snapshot[:served_expected_by] && ServedModel.differs?(asked, snapshot[:served_model])
|
|
535
|
+
note = differs ? " (asked for #{asked})" : ""
|
|
504
536
|
lines << "served model: #{snapshot[:served_model]}#{note}"
|
|
505
537
|
end
|
|
506
538
|
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,19 @@ 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. The model is told no
|
|
1355
|
+
# one could answer (QuestionDesk::UNANSWERABLE_NOTE), not that the user
|
|
1356
|
+
# dismissed it.
|
|
1357
|
+
def dismiss_unaskable_question(prompt)
|
|
1358
|
+
@engine.cancel_question(QuestionDesk::UNANSWERABLE_REASON) rescue nil
|
|
1359
|
+
banner(prompt.summary("(dismissed: no one to answer in a non-interactive run)", paint: method(:paint)))
|
|
1360
|
+
false
|
|
1361
|
+
end
|
|
1362
|
+
|
|
1348
1363
|
# Loop until a valid selection or a cancel. The block reads one answer:
|
|
1349
1364
|
# a line, nil (Ctrl-D, or Ctrl-C with no turn), or :canceled (the turn
|
|
1350
1365
|
# was cancelled).
|
|
@@ -84,10 +84,11 @@ module Samagotchi
|
|
|
84
84
|
outcomes << outcome.line
|
|
85
85
|
return with_outcomes(canceled_result(child_id, reports: reports), outcomes) if outcome.stopped
|
|
86
86
|
|
|
87
|
-
# Wait on
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
|
|
87
|
+
# Wait on from the child as it was before the relay (read when it
|
|
88
|
+
# asked), the relayed question no longer reported: a turn that
|
|
89
|
+
# ends with no reply during the relay's POST or after it still
|
|
90
|
+
# ends the wait.
|
|
91
|
+
baseline = ReplyWait.baseline_of(wait.session, question_id: wait.question[:id])
|
|
91
92
|
end
|
|
92
93
|
rescue ArgumentError => e
|
|
93
94
|
"Error: #{e.message}"
|
|
@@ -66,6 +66,8 @@ module Samagotchi
|
|
|
66
66
|
err: output_io
|
|
67
67
|
)
|
|
68
68
|
Process.detach(pid)
|
|
69
|
+
# Read now, compared before each signal: a pid reused later is not this task.
|
|
70
|
+
pid_started = ProcessGroup.start_time(pid)
|
|
69
71
|
|
|
70
72
|
now = timestamp
|
|
71
73
|
record = {
|
|
@@ -76,6 +78,7 @@ module Samagotchi
|
|
|
76
78
|
"workspace_root" => Dir.pwd,
|
|
77
79
|
"status" => "running",
|
|
78
80
|
"pid" => pid,
|
|
81
|
+
"pid_started" => pid_started,
|
|
79
82
|
"created_at" => now,
|
|
80
83
|
"started_at" => now,
|
|
81
84
|
"finished_at" => nil,
|
|
@@ -190,7 +193,7 @@ module Samagotchi
|
|
|
190
193
|
|
|
191
194
|
pid = refreshed["pid"]
|
|
192
195
|
# Checked again just before the signal: never a group chi didn't start.
|
|
193
|
-
return [mark_not_chis(refreshed), nil] unless
|
|
196
|
+
return [mark_not_chis(refreshed), nil] unless chis_group?(refreshed, started: refreshed["pid_started"])
|
|
194
197
|
|
|
195
198
|
refreshed["stop_requested_by"] = by.to_s
|
|
196
199
|
write_record(refreshed)
|
|
@@ -210,13 +213,19 @@ module Samagotchi
|
|
|
210
213
|
return record unless record["status"] == "running"
|
|
211
214
|
|
|
212
215
|
pid = record["pid"]
|
|
213
|
-
return record if
|
|
216
|
+
return record if chis_group?(record)
|
|
214
217
|
# Gone: its exit code says how it ended.
|
|
215
218
|
return mark_finished_without_exit_code(record) if ProcessGroup.signalable?(pid) && !ProcessGroup.alive?(pid)
|
|
216
219
|
|
|
217
220
|
mark_not_chis(record)
|
|
218
221
|
end
|
|
219
222
|
|
|
223
|
+
# Whether the record's pid still leads its own group. With +started+
|
|
224
|
+
# (the start time recorded at task_create), also the same process: a
|
|
225
|
+
# ps per call, so only task_stop asks it, just before the signal;
|
|
226
|
+
# a refresh (each task_wait poll, task_list) checks the group only.
|
|
227
|
+
def chis_group?(record, started: nil) = ProcessGroup.leader?(record["pid"], started: started)
|
|
228
|
+
|
|
220
229
|
# A running record whose pid isn't a group chi started (NOT_CHIS_PROCESS)
|
|
221
230
|
# ends as failed, unless its exit code or a stop on disk says more.
|
|
222
231
|
def mark_not_chis(record)
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -24,6 +24,7 @@ require_relative "../llm_context_override"
|
|
|
24
24
|
require_relative "../model_catalog"
|
|
25
25
|
require_relative "../model_profile"
|
|
26
26
|
require_relative "../sampling_settings"
|
|
27
|
+
require_relative "../served_model"
|
|
27
28
|
require_relative "../project_scope"
|
|
28
29
|
require_relative "../prompt_history"
|
|
29
30
|
require_relative "../version"
|
|
@@ -443,7 +444,10 @@ module Samagotchi
|
|
|
443
444
|
warnings.concat(listing.warnings)
|
|
444
445
|
# the web's own spelling, kept as it was: bare on the default host
|
|
445
446
|
listing.rows.each do |row|
|
|
446
|
-
|
|
447
|
+
model = { name: row.host == default_host ? row.id : "#{row.host}:#{row.id}", host: row.host, id: row.id }
|
|
448
|
+
model[:configured] = true if row.configured
|
|
449
|
+
model[:unavailable] = true if row.unavailable
|
|
450
|
+
models << model
|
|
447
451
|
end
|
|
448
452
|
end
|
|
449
453
|
rescue StandardError => e
|
|
@@ -485,6 +489,33 @@ module Samagotchi
|
|
|
485
489
|
nil
|
|
486
490
|
end
|
|
487
491
|
|
|
492
|
+
# The served model a session's last generation reported (analytics.json,
|
|
493
|
+
# SessionMetrics#persist), for a session no worker runs: shown only
|
|
494
|
+
# while the name it answered is still the session's model, as
|
|
495
|
+
# Engine#served_model does, with the host whose served: expects it.
|
|
496
|
+
# {} when none applies.
|
|
497
|
+
def saved_served_model(session, persisted)
|
|
498
|
+
served = persisted["served_model"]
|
|
499
|
+
model = session.model_name.to_s
|
|
500
|
+
return {} unless served.is_a?(String) && !served.empty? && !model.empty?
|
|
501
|
+
|
|
502
|
+
registry = host_registry
|
|
503
|
+
asked = registry.bare_name(model)
|
|
504
|
+
return {} unless persisted["served_model_for"] == asked
|
|
505
|
+
|
|
506
|
+
{ served_model: served, served_model_for: asked, served_expected_by: saved_served_expected_by(registry, model, served) }
|
|
507
|
+
rescue StandardError
|
|
508
|
+
{}
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# See ServedModel.expected_by; nil when the model doesn't resolve (the
|
|
512
|
+
# mismatch still shows).
|
|
513
|
+
def saved_served_expected_by(registry, model, served)
|
|
514
|
+
ServedModel.expected_by(registry.resolve(model), served)
|
|
515
|
+
rescue StandardError
|
|
516
|
+
nil
|
|
517
|
+
end
|
|
518
|
+
|
|
488
519
|
# A session's LLM context strategy, apply rule and budget, each with
|
|
489
520
|
# where it came from (LLMContextStrategy::Explained#summary), as its
|
|
490
521
|
# worker would resolve them; nil when they can't be.
|
|
@@ -655,17 +686,22 @@ module Samagotchi
|
|
|
655
686
|
# A /model in the worker changes it before the file catches up.
|
|
656
687
|
session.model_name = snapshot["model_name"] if snapshot && !snapshot["model_name"].to_s.empty?
|
|
657
688
|
session_json = session_to_json(session, status: displayed_status(session, snapshot, owner: owner), owner: owner)
|
|
658
|
-
# What the worker's server said it served for that model (after a turn)
|
|
689
|
+
# What the worker's server said it served for that model (after a turn);
|
|
690
|
+
# without a worker, what the session's analytics.json last saved.
|
|
691
|
+
persisted = snapshot ? nil : read_analytics(id)
|
|
659
692
|
if snapshot
|
|
660
|
-
session_json = session_json.merge(served_model: snapshot["served_model"], served_model_for: snapshot["served_model_for"]
|
|
693
|
+
session_json = session_json.merge(served_model: snapshot["served_model"], served_model_for: snapshot["served_model_for"],
|
|
694
|
+
served_expected_by: snapshot["served_expected_by"])
|
|
661
695
|
# The model notes its prompt carries now (a /model there changes them).
|
|
662
696
|
session_json = session_json.merge(prompt_notes: snapshot["prompt_notes"]) if snapshot["prompt_notes"].is_a?(Array)
|
|
697
|
+
else
|
|
698
|
+
session_json = session_json.merge(saved_served_model(session, persisted))
|
|
663
699
|
end
|
|
664
700
|
# The LLM context strategy the next turn runs under (the info bar's
|
|
665
701
|
# chip): the worker's, else worked out here from the session file.
|
|
666
702
|
session_json = session_json.merge(llm_context: (snapshot && snapshot["llm_context"]) || llm_context_for(session))
|
|
667
703
|
raw_messages = turn_snapshot ? turn_snapshot["messages"] : session.messages
|
|
668
|
-
timing = timing_payload(id, live_metrics: snapshot && snapshot["metrics"])
|
|
704
|
+
timing = timing_payload(id, live_metrics: snapshot && snapshot["metrics"], persisted: persisted)
|
|
669
705
|
json_response(200, {
|
|
670
706
|
session: session_json,
|
|
671
707
|
history: read_history(id),
|
|
@@ -1499,8 +1535,9 @@ module Samagotchi
|
|
|
1499
1535
|
|
|
1500
1536
|
# The whole timing: analytics.json's records merged with a live
|
|
1501
1537
|
# worker's (by id), and turn_count, the finished turns (#turn_count).
|
|
1502
|
-
|
|
1503
|
-
|
|
1538
|
+
# +persisted+: the session's analytics.json when the caller read it.
|
|
1539
|
+
def timing_payload(session_id, live_metrics: nil, persisted: nil)
|
|
1540
|
+
persisted ||= read_analytics(session_id)
|
|
1504
1541
|
live = live_metrics.is_a?(Hash)
|
|
1505
1542
|
source = if live
|
|
1506
1543
|
persisted.merge(live_metrics).merge(
|
|
@@ -1156,7 +1156,7 @@ function renderSessionView(data) {
|
|
|
1156
1156
|
setCapabilities(data.capabilities);
|
|
1157
1157
|
current.status = s.status;
|
|
1158
1158
|
current.model = s.model_name;
|
|
1159
|
-
current.served = s.served_model ? [s.served_model, s.served_model_for] : null;
|
|
1159
|
+
current.served = s.served_model ? [s.served_model, s.served_model_for, s.served_expected_by || null] : null;
|
|
1160
1160
|
current.dir = s.working_directory;
|
|
1161
1161
|
current.owner = s.owner || null;
|
|
1162
1162
|
current.timing = normalizeTiming(data.timing);
|
|
@@ -2007,7 +2007,7 @@ const streamHandlers = {
|
|
|
2007
2007
|
},
|
|
2008
2008
|
generation_completed: (data) => {
|
|
2009
2009
|
liveTurn.timing.setNote("");
|
|
2010
|
-
if (data?.served_model) current.served = [data.served_model, data.requested_model];
|
|
2010
|
+
if (data?.served_model) current.served = [data.served_model, data.requested_model, data.served_expected_by || null];
|
|
2011
2011
|
// The generation's speed and the session's token totals, from the server.
|
|
2012
2012
|
current.timing.tokens = generationTokens(current.timing.tokens, data);
|
|
2013
2013
|
if (data?.served_model || data?.tokens) updateInfoBar();
|
|
@@ -2212,7 +2212,11 @@ const streamHandlers = {
|
|
|
2212
2212
|
llm_context_edited: (data) => { turnDom.notice(llmContextNotice(data)); },
|
|
2213
2213
|
// The provider is asked again after an error, or the turn waits for
|
|
2214
2214
|
// plugins' setup: a note on the live timing line until it streams.
|
|
2215
|
-
generation_retrying: (data) =>
|
|
2215
|
+
generation_retrying: (data) => {
|
|
2216
|
+
// A dropped stream's step asked again: what it streamed is void.
|
|
2217
|
+
if (data?.restarted) turnDom.generationRestarted(data);
|
|
2218
|
+
liveTurn.timing.setNote(retryStatusLine(data));
|
|
2219
|
+
},
|
|
2216
2220
|
plugin_init_wait: (data) => liveTurn.timing.setNote(initWaitLine(data)),
|
|
2217
2221
|
plugin_init_started: (data) => onInitEvent("plugin_init_started", data),
|
|
2218
2222
|
plugin_init_finished: (data) => onInitEvent("plugin_init_finished", data),
|