samagotchi 0.75.0 → 0.76.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 +47 -1
- data/docs/architecture.md +11 -4
- data/docs/cli.md +13 -1
- data/docs/configuration.md +4 -3
- data/docs/guardrails.md +9 -0
- data/docs/internals/anthropic.md +3 -1
- data/docs/internals/bundles.md +20 -7
- data/docs/internals/context-telemetry.md +22 -0
- data/docs/internals/engine.md +14 -3
- data/docs/internals/prompt-caching.md +2 -1
- data/docs/memory.md +34 -0
- data/docs/plugins.md +1 -1
- data/docs/releasing.md +6 -2
- data/docs/sessions.md +3 -1
- data/lib/samagotchi/bundle_command.rb +52 -39
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +19 -2
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/desktop/macos.rb +1 -2
- data/lib/samagotchi/effective_model.rb +5 -0
- data/lib/samagotchi/engine.rb +84 -4
- data/lib/samagotchi/kernel_loop.rb +13 -10
- data/lib/samagotchi/llm_context_override.rb +8 -6
- data/lib/samagotchi/memory_bundle/asset_installer.rb +34 -5
- data/lib/samagotchi/memory_bundle/builder.rb +11 -14
- data/lib/samagotchi/memory_bundle/installer.rb +122 -5
- data/lib/samagotchi/memory_bundle/manifest.rb +20 -1
- data/lib/samagotchi/memory_bundle/memory_files.rb +24 -6
- data/lib/samagotchi/memory_bundle/profile.rb +1 -1
- data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
- data/lib/samagotchi/memory_bundle/source.rb +66 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -2
- data/lib/samagotchi/model_price.rb +4 -3
- data/lib/samagotchi/paths.rb +15 -1
- data/lib/samagotchi/session_commands.rb +2 -0
- data/lib/samagotchi/session_manager.rb +1 -2
- data/lib/samagotchi/session_metrics/model_segment.rb +109 -0
- data/lib/samagotchi/session_metrics/totals.rb +127 -0
- data/lib/samagotchi/session_metrics.rb +118 -83
- data/lib/samagotchi/terminal_ui/formatting.rb +127 -10
- data/lib/samagotchi/terminal_ui/status_row.rb +3 -1
- data/lib/samagotchi/toolkit.rb +9 -8
- data/lib/samagotchi/toolkit_command.rb +2 -2
- data/lib/samagotchi/tools/builtins.rb +1 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +3 -0
- data/lib/samagotchi/web/public/app.js +4 -3
- data/lib/samagotchi/web/public/chains.js +1 -1
- data/lib/samagotchi/web/public/composer.js +9 -5
- data/lib/samagotchi/web/public/ctx.js +73 -11
- data/lib/samagotchi/web/public/index.html +30 -35
- data/lib/samagotchi/web/public/info_bar_model.js +2 -1
- data/lib/samagotchi/web/public/question_cards.js +1 -1
- data/lib/samagotchi/web/public/session_state.js +3 -0
- data/lib/samagotchi/web/public/stage_slots.js +3 -3
- data/lib/samagotchi/web/public/stream_handlers.js +32 -1
- data/lib/samagotchi/web/public/timing.js +11 -2
- metadata +3 -1
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
class SessionMetrics
|
|
5
|
+
# The sums over a session's finished turn records, kept as running
|
|
6
|
+
# counters (#add folds one record in) and read with the running turn's
|
|
7
|
+
# finished generations folded in (#with_running), so /stats agrees
|
|
8
|
+
# mid-turn. The tool tallies (by tool, aliased, errors) are counted per
|
|
9
|
+
# tool record, not per turn: SessionMetrics keeps those apart.
|
|
10
|
+
class Totals < Data.define(
|
|
11
|
+
:cancellations, :iterations, :gen_ms, :retries, :cuts, :capped,
|
|
12
|
+
:prompt_sum, :completion_sum, :cached_sum, :cache_write_sum, :reprefill_sum, :reasoning_sum,
|
|
13
|
+
:cost_sum, :cost_estimate_sum, :decode_ms_sum, :decode_tokens_sum,
|
|
14
|
+
# The newest speeds a record has, and where the decode one came from.
|
|
15
|
+
:last_decode_tps, :last_prefill_tps, :tps_source,
|
|
16
|
+
# The kinds of counts (server / estimate / mixed) the records came from.
|
|
17
|
+
:token_sources
|
|
18
|
+
)
|
|
19
|
+
# The record keys each sum adds up (a turn record's own names).
|
|
20
|
+
RECORD_SUMS = {
|
|
21
|
+
prompt_sum: :prompt_tokens_sum, completion_sum: :completion_tokens, iterations: :iterations,
|
|
22
|
+
gen_ms: :gen_ms, retries: :retries, cuts: :cuts, capped: :capped, cached_sum: :cached_tokens_sum,
|
|
23
|
+
cache_write_sum: :cache_write_tokens_sum, reprefill_sum: :reprefill_tokens_sum,
|
|
24
|
+
reasoning_sum: :reasoning_tokens, cost_sum: :cost, cost_estimate_sum: :cost_estimate,
|
|
25
|
+
decode_ms_sum: :decode_ms, decode_tokens_sum: :decode_tokens
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
# The running turn's (TurnState) counters each sum adds while it runs.
|
|
29
|
+
TURN_SUMS = {
|
|
30
|
+
iterations: :iteration_count, gen_ms: :gen_latency_accum, retries: :retries, cuts: :cuts,
|
|
31
|
+
capped: :capped, prompt_sum: :prompt_sum, completion_sum: :completion_sum, cached_sum: :cached_sum,
|
|
32
|
+
cache_write_sum: :cache_write_sum, reprefill_sum: :reprefill_sum, reasoning_sum: :reasoning_sum,
|
|
33
|
+
cost_sum: :cost_sum, cost_estimate_sum: :cost_estimate_sum, decode_ms_sum: :decode_ms_sum,
|
|
34
|
+
decode_tokens_sum: :decode_tokens_sum
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
def self.zero
|
|
38
|
+
new(cancellations: 0, **RECORD_SUMS.keys.to_h { |key| [key, 0] }, last_decode_tps: nil,
|
|
39
|
+
last_prefill_tps: nil, tps_source: nil, token_sources: [].freeze)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# These totals with one finished turn record (symbol keys, as saved;
|
|
43
|
+
# an older record lacks some counts) counted in.
|
|
44
|
+
# @param record [Hash]
|
|
45
|
+
# @return [Totals]
|
|
46
|
+
def add(record)
|
|
47
|
+
sums = RECORD_SUMS.to_h { |sum, key| [sum, public_send(sum) + number(record[key])] }
|
|
48
|
+
sums[:cancellations] = cancellations + 1 if record[:status] == "canceled"
|
|
49
|
+
# The newest speeds a record has (an older record has none).
|
|
50
|
+
if record[:decode_tps].is_a?(Numeric)
|
|
51
|
+
sums[:last_decode_tps] = record[:decode_tps]
|
|
52
|
+
sums[:tps_source] = record[:tps_source]
|
|
53
|
+
end
|
|
54
|
+
sums[:last_prefill_tps] = record[:prefill_tps] if record[:prefill_tps].is_a?(Numeric)
|
|
55
|
+
source = record[:token_source]
|
|
56
|
+
sums[:token_sources] = (token_sources | [source.to_s]).freeze unless source.nil?
|
|
57
|
+
with(**sums)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# These totals with a later segment's counts and newest speeds.
|
|
61
|
+
# @param later [Totals]
|
|
62
|
+
# @return [Totals]
|
|
63
|
+
def merge(later)
|
|
64
|
+
sums = RECORD_SUMS.keys.to_h { |key| [key, public_send(key) + later.public_send(key)] }
|
|
65
|
+
with(**sums, cancellations: cancellations + later.cancellations,
|
|
66
|
+
token_sources: (token_sources | later.token_sources).freeze,
|
|
67
|
+
last_decode_tps: later.last_decode_tps || last_decode_tps,
|
|
68
|
+
tps_source: later.last_decode_tps ? later.tps_source : tps_source,
|
|
69
|
+
last_prefill_tps: later.last_prefill_tps || last_prefill_tps)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# These totals with the running turn's counters so far (its finished
|
|
73
|
+
# generations, calls and iterations) added; the last speeds are the
|
|
74
|
+
# newest a generation reported. nil: these totals.
|
|
75
|
+
# @param turn [TurnState, nil]
|
|
76
|
+
# @return [Totals]
|
|
77
|
+
def with_running(turn)
|
|
78
|
+
return self unless turn
|
|
79
|
+
|
|
80
|
+
sums = TURN_SUMS.to_h { |sum, key| [sum, public_send(sum) + (turn.public_send(key) || 0)] }
|
|
81
|
+
if turn.last_decode_tps
|
|
82
|
+
sums[:last_decode_tps] = turn.last_decode_tps
|
|
83
|
+
sums[:tps_source] = turn.tps_source
|
|
84
|
+
end
|
|
85
|
+
sums[:last_prefill_tps] = turn.last_prefill_tps if turn.last_prefill_tps
|
|
86
|
+
sums[:token_sources] = (token_sources + turn.token_sources).freeze
|
|
87
|
+
with(**sums)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The snapshot's tokens block.
|
|
91
|
+
# @return [Hash]
|
|
92
|
+
def tokens_block
|
|
93
|
+
{
|
|
94
|
+
prompt_sum: prompt_sum,
|
|
95
|
+
completion_sum: completion_sum,
|
|
96
|
+
cached_sum: cached_sum,
|
|
97
|
+
cache_write_sum: cache_write_sum,
|
|
98
|
+
reprefill_sum: reprefill_sum,
|
|
99
|
+
reasoning_sum: reasoning_sum,
|
|
100
|
+
cost_sum: cost_sum,
|
|
101
|
+
cost_estimate_sum: cost_estimate_sum,
|
|
102
|
+
decode_ms_sum: decode_ms_sum.round,
|
|
103
|
+
decode_tokens_sum: decode_tokens_sum,
|
|
104
|
+
avg_decode_tps: decode_ms_sum.positive? ? (decode_tokens_sum * 1000.0 / decode_ms_sum).round(1) : nil,
|
|
105
|
+
last_decode_tps: last_decode_tps,
|
|
106
|
+
last_prefill_tps: last_prefill_tps,
|
|
107
|
+
tps_source: tps_source,
|
|
108
|
+
source: combined_source
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def number(value)
|
|
115
|
+
value.is_a?(Numeric) ? value : 0
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# server, estimate, or mixed when both kinds of counts (or a turn that
|
|
119
|
+
# was already mixed) are in; nil before any.
|
|
120
|
+
def combined_source
|
|
121
|
+
kinds = token_sources.compact.map(&:to_s)
|
|
122
|
+
.flat_map { |source| source == "mixed" ? %w[server estimate] : [source] }.uniq
|
|
123
|
+
kinds.size > 1 ? "mixed" : kinds.first
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -10,6 +10,8 @@ require_relative "atomic_file"
|
|
|
10
10
|
require_relative "token_usage"
|
|
11
11
|
require_relative "model_price"
|
|
12
12
|
require_relative "session"
|
|
13
|
+
require_relative "session_metrics/totals"
|
|
14
|
+
require_relative "session_metrics/model_segment"
|
|
13
15
|
|
|
14
16
|
module Samagotchi
|
|
15
17
|
# SessionMetrics is a persistent, error-isolated collector of per-session
|
|
@@ -54,8 +56,9 @@ module Samagotchi
|
|
|
54
56
|
GenerationSpeed = Data.define(:decode_tps, :source)
|
|
55
57
|
|
|
56
58
|
# What #finish_generation reports: the generation's speed (nil when it
|
|
57
|
-
# had none)
|
|
58
|
-
|
|
59
|
+
# had none), the session's tokens block and its per-model rows
|
|
60
|
+
# (model_segments) as the snapshot has them.
|
|
61
|
+
GenerationReport = Data.define(:speed, :tokens, :model_segments)
|
|
59
62
|
|
|
60
63
|
# memory_index: the memory indexes the session's prompt held
|
|
61
64
|
# ({ system: { tokens:, lines: }, project: … }, string keys as saved),
|
|
@@ -142,6 +145,13 @@ module Samagotchi
|
|
|
142
145
|
:cuts,
|
|
143
146
|
:capped,
|
|
144
147
|
:model,
|
|
148
|
+
# The model the turn ran on (asked host:model, :generation_started's
|
|
149
|
+
# model_ref; the first generation's), and whether its open generation
|
|
150
|
+
# is the first after a /model switch (its prefill is recorded).
|
|
151
|
+
:model_ref,
|
|
152
|
+
:gen_switch,
|
|
153
|
+
:switch_prefill_tokens,
|
|
154
|
+
:switch_prefill_cost_estimate,
|
|
145
155
|
# A turn with no prompt of its own (:turn_started's +continue+): a
|
|
146
156
|
# step-limit Continue, a reminder's or a wake's turn; its record
|
|
147
157
|
# carries it.
|
|
@@ -280,6 +290,22 @@ module Samagotchi
|
|
|
280
290
|
end
|
|
281
291
|
end
|
|
282
292
|
|
|
293
|
+
# The model ref the last turn that named one ran on (a loaded session's
|
|
294
|
+
# too), nil before any: Engine#session= compares it with the effective
|
|
295
|
+
# model's.
|
|
296
|
+
# @return [String, nil]
|
|
297
|
+
def last_model_ref = @mutex.synchronize { @last_model_ref }
|
|
298
|
+
|
|
299
|
+
# /model's new window (Engine#announce_switched_context!), until the
|
|
300
|
+
# new model's first generation reports one: what the snapshot's context
|
|
301
|
+
# (a page's ctx % on load) counts against.
|
|
302
|
+
def note_context_window(tokens, source)
|
|
303
|
+
@mutex.synchronize do
|
|
304
|
+
@context_window_tokens = tokens
|
|
305
|
+
@context_window_source = source
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
283
309
|
# The memory indexes the session's prompt holds (SystemPrompt#memory_index:
|
|
284
310
|
# { system: { tokens:, lines: }, project: … }), set by the Engine when
|
|
285
311
|
# it builds the prompt it sends; the snapshot and analytics.json carry it.
|
|
@@ -344,6 +370,7 @@ module Samagotchi
|
|
|
344
370
|
reset_generation_tokens
|
|
345
371
|
@turn.gen_price = generation_price(event[:price])
|
|
346
372
|
@turn.gen_open = true
|
|
373
|
+
note_model_ref(event[:model_ref])
|
|
347
374
|
end
|
|
348
375
|
end
|
|
349
376
|
when :generation_completed, :generation_cancelled
|
|
@@ -401,7 +428,8 @@ module Samagotchi
|
|
|
401
428
|
def finish_generation
|
|
402
429
|
@mutex.synchronize do
|
|
403
430
|
speed = close_generation if @turn&.gen_open
|
|
404
|
-
GenerationReport.new(speed: speed, tokens:
|
|
431
|
+
GenerationReport.new(speed: speed, tokens: @totals.with_running(@turn).tokens_block,
|
|
432
|
+
model_segments: ModelSegment.rows(segments_with_running(@turn)))
|
|
405
433
|
end
|
|
406
434
|
end
|
|
407
435
|
|
|
@@ -449,14 +477,17 @@ module Samagotchi
|
|
|
449
477
|
# Caller holds the mutex. +records+: :recent (the live snapshot) or :all
|
|
450
478
|
# (analytics.json).
|
|
451
479
|
def build_snapshot(records_mode)
|
|
452
|
-
totals = @totals
|
|
453
480
|
turn = @turn
|
|
481
|
+
totals = @totals.with_running(turn)
|
|
454
482
|
in_flight = turn ? turn.tool_calls_by_id.values : []
|
|
455
483
|
{
|
|
456
484
|
session_id: @session_id,
|
|
457
485
|
turns: @turn_records.size + (turn ? 1 : 0),
|
|
458
|
-
cancellations: totals
|
|
459
|
-
tokens: tokens_block
|
|
486
|
+
cancellations: totals.cancellations,
|
|
487
|
+
tokens: totals.tokens_block,
|
|
488
|
+
# The tokens, cost and speeds per model, in order (ModelSegment.rows:
|
|
489
|
+
# none with one model); they add up to +tokens+.
|
|
490
|
+
model_segments: ModelSegment.rows(segments_with_running(turn)),
|
|
460
491
|
context: context_block(@turn_records),
|
|
461
492
|
memory_index: @memory_index,
|
|
462
493
|
profile: @profile,
|
|
@@ -464,16 +495,16 @@ module Samagotchi
|
|
|
464
495
|
served_model: @served_model,
|
|
465
496
|
served_model_for: @served_model_for,
|
|
466
497
|
tool_calls_total: @tool_records.size + in_flight.size,
|
|
467
|
-
tool_calls_by_tool: tally_into(
|
|
498
|
+
tool_calls_by_tool: tally_into(@tool_tallies[:by_tool].dup, in_flight),
|
|
468
499
|
# The calls the model made under an alias's name (bash run as
|
|
469
500
|
# execute), by its own spelling; only when there are any.
|
|
470
|
-
**(
|
|
471
|
-
tool_errors:
|
|
472
|
-
iterations_total: totals
|
|
473
|
-
gen_latency_ms:
|
|
474
|
-
retries: totals
|
|
475
|
-
cuts: totals
|
|
476
|
-
capped: totals
|
|
501
|
+
**(@tool_tallies[:aliased].empty? ? {} : { tool_calls_aliased: @tool_tallies[:aliased].dup }),
|
|
502
|
+
tool_errors: @tool_tallies[:errors],
|
|
503
|
+
iterations_total: totals.iterations,
|
|
504
|
+
gen_latency_ms: totals.gen_ms.round,
|
|
505
|
+
retries: totals.retries,
|
|
506
|
+
cuts: totals.cuts,
|
|
507
|
+
capped: totals.capped,
|
|
477
508
|
started_at: @started_at,
|
|
478
509
|
last_activity_at: @last_activity_at,
|
|
479
510
|
# From the session's first start (an earlier process's too), to now.
|
|
@@ -484,31 +515,19 @@ module Samagotchi
|
|
|
484
515
|
}
|
|
485
516
|
end
|
|
486
517
|
|
|
487
|
-
# The
|
|
488
|
-
#
|
|
489
|
-
#
|
|
490
|
-
def
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
reprefill_sum: add.call(:reprefill_sum),
|
|
501
|
-
reasoning_sum: add.call(:reasoning_sum),
|
|
502
|
-
cost_sum: add.call(:cost_sum),
|
|
503
|
-
cost_estimate_sum: add.call(:cost_estimate_sum),
|
|
504
|
-
decode_ms_sum: decode_ms.round,
|
|
505
|
-
decode_tokens_sum: decode_tokens,
|
|
506
|
-
avg_decode_tps: decode_ms.positive? ? (decode_tokens * 1000.0 / decode_ms).round(1) : nil,
|
|
507
|
-
last_decode_tps: speed_turn ? speed_turn.last_decode_tps : totals[:last_decode_tps],
|
|
508
|
-
last_prefill_tps: turn&.last_prefill_tps || totals[:last_prefill_tps],
|
|
509
|
-
tps_source: speed_turn ? speed_turn.tps_source : totals[:tps_source],
|
|
510
|
-
source: combined_source(totals[:token_sources] + (turn&.token_sources || []))
|
|
511
|
-
}
|
|
518
|
+
# The model segments with the running turn in: the last one's when it
|
|
519
|
+
# runs on that model or names none yet, else a new one. Caller holds
|
|
520
|
+
# the mutex.
|
|
521
|
+
def segments_with_running(turn)
|
|
522
|
+
return @segments unless turn
|
|
523
|
+
|
|
524
|
+
last = @segments.last
|
|
525
|
+
ref = turn.model_ref
|
|
526
|
+
if last.nil? || (ref && last.model_ref != ref)
|
|
527
|
+
return @segments + [ModelSegment.empty(ref, turn.started_at).with_running(turn)]
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
@segments[0...-1] + [last.with_running(turn)]
|
|
512
531
|
end
|
|
513
532
|
|
|
514
533
|
# The records a snapshot carries, copied. :all is every record; :recent
|
|
@@ -571,6 +590,8 @@ module Samagotchi
|
|
|
571
590
|
cuts: 0,
|
|
572
591
|
capped: 0,
|
|
573
592
|
model: nil,
|
|
593
|
+
model_ref: nil,
|
|
594
|
+
gen_switch: false,
|
|
574
595
|
continue: event[:continue] ? true : false,
|
|
575
596
|
resumed: event[:resumed] ? true : false,
|
|
576
597
|
id: event[:turn_id] || SecureRandom.uuid,
|
|
@@ -689,6 +710,7 @@ module Samagotchi
|
|
|
689
710
|
def close_generation
|
|
690
711
|
turn = @turn
|
|
691
712
|
turn.gen_open = false
|
|
713
|
+
note_switch_prefill(turn)
|
|
692
714
|
speed = nil
|
|
693
715
|
if turn.gen_had_server
|
|
694
716
|
completion = turn.gen_completion_max
|
|
@@ -712,6 +734,42 @@ module Samagotchi
|
|
|
712
734
|
speed
|
|
713
735
|
end
|
|
714
736
|
|
|
737
|
+
# The turn's model, from its first generation that names one; that
|
|
738
|
+
# generation is a switch's when the last turn that named a model ran on
|
|
739
|
+
# another (a turn with none, stopped before it asked, never counts; nor
|
|
740
|
+
# does a legacy session's first named one). Caller holds the mutex.
|
|
741
|
+
def note_model_ref(ref)
|
|
742
|
+
return if @turn.model_ref || ref.to_s.empty?
|
|
743
|
+
|
|
744
|
+
@turn.model_ref = ref.to_s
|
|
745
|
+
@turn.gen_switch = !@last_model_ref.nil? && @last_model_ref != @turn.model_ref
|
|
746
|
+
@last_model_ref = @turn.model_ref
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
# The first generation after a switch prefilled the history on the new
|
|
750
|
+
# model: its uncached prompt (the whole prompt less the cached reads;
|
|
751
|
+
# llama.cpp's native tokens_evaluated is the whole prompt too), with its
|
|
752
|
+
# cost estimated from the configured price, never a completion's. None
|
|
753
|
+
# for a generation with estimated tokens only or a prefill too small to
|
|
754
|
+
# be one (REPREFILL_MIN_TOKENS). Caller holds the mutex.
|
|
755
|
+
def note_switch_prefill(turn)
|
|
756
|
+
return unless turn.gen_switch
|
|
757
|
+
|
|
758
|
+
turn.gen_switch = false
|
|
759
|
+
return unless turn.gen_had_server
|
|
760
|
+
|
|
761
|
+
tokens = [turn.gen_prompt_max - turn.gen_cached_max, 0].max
|
|
762
|
+
return if tokens < TokenUsage::REPREFILL_MIN_TOKENS
|
|
763
|
+
|
|
764
|
+
turn.switch_prefill_tokens = tokens
|
|
765
|
+
return unless turn.gen_price
|
|
766
|
+
|
|
767
|
+
turn.switch_prefill_cost_estimate = turn.gen_price.cost(prompt_tokens: turn.gen_prompt_max,
|
|
768
|
+
cached_tokens: turn.gen_cached_max,
|
|
769
|
+
cache_write_tokens: turn.gen_cache_write_max,
|
|
770
|
+
completion_tokens: 0)
|
|
771
|
+
end
|
|
772
|
+
|
|
715
773
|
# The generation's cached/reasoning counts, cost and decode speed, into
|
|
716
774
|
# the turn. The speed is the server's (llama.cpp's predicted_per_second
|
|
717
775
|
# over its predicted_ms) or, without one, an estimate: the completion
|
|
@@ -781,6 +839,7 @@ module Samagotchi
|
|
|
781
839
|
sources = turn.token_sources
|
|
782
840
|
{
|
|
783
841
|
model: turn.model,
|
|
842
|
+
model_ref: turn.model_ref,
|
|
784
843
|
generations: turn.generations,
|
|
785
844
|
prompt_tokens: turn.prompt_last,
|
|
786
845
|
prompt_tokens_max: turn.prompt_max,
|
|
@@ -815,6 +874,8 @@ module Samagotchi
|
|
|
815
874
|
fields[:reprefill_tokens_sum] = turn.reprefill_sum if turn.reprefill_sum.positive?
|
|
816
875
|
fields.merge!(cost: turn.cost_sum, cost_source: "reported") if turn.cost_sum
|
|
817
876
|
fields[:cost_estimate] = turn.cost_estimate_sum if turn.cost_estimate_sum.positive?
|
|
877
|
+
fields[:switch_prefill_tokens] = turn.switch_prefill_tokens if turn.switch_prefill_tokens
|
|
878
|
+
fields[:switch_prefill_cost_estimate] = turn.switch_prefill_cost_estimate if turn.switch_prefill_cost_estimate
|
|
818
879
|
fields
|
|
819
880
|
end
|
|
820
881
|
|
|
@@ -1017,6 +1078,7 @@ module Samagotchi
|
|
|
1017
1078
|
reset_totals
|
|
1018
1079
|
@turn_records.each { |record| count_turn(record) }
|
|
1019
1080
|
@tool_records.each { |record| count_tool(record) }
|
|
1081
|
+
@last_model_ref = @turn.model_ref if @turn&.model_ref
|
|
1020
1082
|
@started_at = earliest_timestamp(prior["started_at"], @started_at)
|
|
1021
1083
|
@last_activity_at ||= prior["last_activity_at"]
|
|
1022
1084
|
# The served model last reported, with the name asked for then (the
|
|
@@ -1057,48 +1119,32 @@ module Samagotchi
|
|
|
1057
1119
|
end
|
|
1058
1120
|
|
|
1059
1121
|
# The session totals over the finished records, as running counters
|
|
1060
|
-
# (#snapshot adds the running turn)
|
|
1122
|
+
# (#snapshot adds the running turn), and the tool tallies over the tool
|
|
1123
|
+
# records. Caller holds the mutex.
|
|
1061
1124
|
def reset_totals
|
|
1062
|
-
@totals =
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
last_prefill_tps: nil, tps_source: nil }
|
|
1125
|
+
@totals = Totals.zero
|
|
1126
|
+
@segments = []
|
|
1127
|
+
@last_model_ref = nil
|
|
1128
|
+
@tool_tallies = { by_tool: {}, aliased: {}, errors: 0 }
|
|
1067
1129
|
end
|
|
1068
1130
|
|
|
1069
1131
|
def count_turn(record)
|
|
1070
|
-
totals = @totals
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
totals[:gen_ms] += number(record[:gen_ms])
|
|
1076
|
-
totals[:retries] += number(record[:retries])
|
|
1077
|
-
totals[:cuts] += number(record[:cuts])
|
|
1078
|
-
totals[:capped] += number(record[:capped])
|
|
1079
|
-
totals[:cached_sum] += number(record[:cached_tokens_sum])
|
|
1080
|
-
totals[:cache_write_sum] += number(record[:cache_write_tokens_sum])
|
|
1081
|
-
totals[:reprefill_sum] += number(record[:reprefill_tokens_sum])
|
|
1082
|
-
totals[:reasoning_sum] += number(record[:reasoning_tokens])
|
|
1083
|
-
totals[:cost_sum] += number(record[:cost])
|
|
1084
|
-
totals[:cost_estimate_sum] += number(record[:cost_estimate])
|
|
1085
|
-
totals[:decode_ms_sum] += number(record[:decode_ms])
|
|
1086
|
-
totals[:decode_tokens_sum] += number(record[:decode_tokens])
|
|
1087
|
-
# The newest speeds a record has (an older record has none).
|
|
1088
|
-
if record[:decode_tps].is_a?(Numeric)
|
|
1089
|
-
totals[:last_decode_tps] = record[:decode_tps]
|
|
1090
|
-
totals[:tps_source] = record[:tps_source]
|
|
1132
|
+
@totals = @totals.add(record)
|
|
1133
|
+
if ModelSegment.splits?(@segments.last, record)
|
|
1134
|
+
@segments << ModelSegment.open(record)
|
|
1135
|
+
else
|
|
1136
|
+
@segments[-1] = @segments.last.add(record)
|
|
1091
1137
|
end
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1138
|
+
# The model the last turn that named one ran on: what a switch is
|
|
1139
|
+
# told by (#note_model_ref).
|
|
1140
|
+
@last_model_ref = record[:model_ref].to_s unless record[:model_ref].to_s.empty?
|
|
1095
1141
|
end
|
|
1096
1142
|
|
|
1097
1143
|
def count_tool(record)
|
|
1098
|
-
@
|
|
1099
|
-
tally_into(@
|
|
1144
|
+
@tool_tallies[:errors] += 1 if record[:status] == "error"
|
|
1145
|
+
tally_into(@tool_tallies[:by_tool], [record])
|
|
1100
1146
|
called_as = record[:called_as].to_s
|
|
1101
|
-
@
|
|
1147
|
+
@tool_tallies[:aliased][called_as] = @tool_tallies[:aliased].fetch(called_as, 0) + 1 unless called_as.empty?
|
|
1102
1148
|
end
|
|
1103
1149
|
|
|
1104
1150
|
def tally_into(counts, tools)
|
|
@@ -1109,17 +1155,6 @@ module Samagotchi
|
|
|
1109
1155
|
counts
|
|
1110
1156
|
end
|
|
1111
1157
|
|
|
1112
|
-
def number(value)
|
|
1113
|
-
value.is_a?(Numeric) ? value : 0
|
|
1114
|
-
end
|
|
1115
|
-
|
|
1116
|
-
# server, estimate, or mixed when both kinds of counts (or a turn that
|
|
1117
|
-
# was already mixed) are in; nil before any.
|
|
1118
|
-
def combined_source(sources)
|
|
1119
|
-
kinds = sources.compact.map(&:to_s).flat_map { |source| source == "mixed" ? %w[server estimate] : [source] }.uniq
|
|
1120
|
-
kinds.size > 1 ? "mixed" : kinds.first
|
|
1121
|
-
end
|
|
1122
|
-
|
|
1123
1158
|
def earliest_timestamp(*timestamps)
|
|
1124
1159
|
timestamps.compact.min_by { |value| Time.iso8601(value.to_s) }
|
|
1125
1160
|
rescue ArgumentError
|
|
@@ -435,17 +435,61 @@ module Samagotchi
|
|
|
435
435
|
# /stats' cost: the reported part and the estimated one, each when present.
|
|
436
436
|
# +price_host+ names the host whose hosts.<name>.models prices the
|
|
437
437
|
# estimate came from (Engine#stats_snapshot; nil from an older worker).
|
|
438
|
-
|
|
438
|
+
# +per_model+: the session ran on several models (model_segments), each
|
|
439
|
+
# priced from its own host's prices.
|
|
440
|
+
def stats_cost_text(tokens, price_host: nil, per_model: false)
|
|
439
441
|
reported = cost_text(tokens[:cost_sum])
|
|
440
442
|
estimated = cost_text(tokens[:cost_estimate_sum], estimate: true)
|
|
441
443
|
return nil if reported.empty? && estimated.empty?
|
|
442
444
|
return "#{reported} (this session only)" if estimated.empty?
|
|
443
445
|
|
|
444
|
-
source =
|
|
446
|
+
source = if per_model then "each model's host prices"
|
|
447
|
+
elsif price_host.to_s.empty? then "configured prices"
|
|
448
|
+
else "hosts.#{price_host}.models prices"
|
|
449
|
+
end
|
|
445
450
|
estimate = "#{estimated} from #{source}"
|
|
446
451
|
"#{reported.empty? ? estimate : "#{reported} reported + #{estimate}"} (this session only)"
|
|
447
452
|
end
|
|
448
453
|
|
|
454
|
+
# ── A session's rows per model (the snapshot's model_segments) ──────
|
|
455
|
+
# The words the web's ctx tooltip shares (ctx.js; spec/shared/
|
|
456
|
+
# labels_matrix.json model_segments). Rows come symbol- or
|
|
457
|
+
# string-keyed (the attached TUI's as JSON).
|
|
458
|
+
|
|
459
|
+
# A row's model: its ref; the rows folded past the cap ("3 earlier
|
|
460
|
+
# segments", told by folded_segments); a session's turns from before
|
|
461
|
+
# turns named their model ("earlier turns").
|
|
462
|
+
def model_segment_label(row)
|
|
463
|
+
folded = segment_value(row, :folded_segments).to_i
|
|
464
|
+
return "#{folded} earlier segments" if folded.positive?
|
|
465
|
+
|
|
466
|
+
ref = segment_value(row, :model_ref).to_s
|
|
467
|
+
ref.empty? ? "earlier turns" : ref
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# "6.0k in / 40 out": the row's prompt and completion sums.
|
|
471
|
+
def model_segment_tokens_text(row)
|
|
472
|
+
count = ->(key) { MemoryBundle::IndexSize.count_text(segment_value(row, key).to_i) }
|
|
473
|
+
"#{count.call(:prompt_sum)} in / #{count.call(:completion_sum)} out"
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# "$0.42", "~$0.12", "$0.42 + ~$0.12" (reported + estimated); "" for none.
|
|
477
|
+
def model_segment_cost_text(row)
|
|
478
|
+
[cost_text(segment_value(row, :cost_sum)), cost_text(segment_value(row, :cost_estimate_sum), estimate: true)]
|
|
479
|
+
.reject(&:empty?).join(" + ")
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# "switch prefill 3.0k uncached prompt ≈$0.0060": what the switch to the
|
|
483
|
+
# row's model prefilled (its cost an estimate from the configured
|
|
484
|
+
# price, left out without one); "" for none.
|
|
485
|
+
def model_segment_prefill_text(row)
|
|
486
|
+
tokens = segment_value(row, :prefill_tokens).to_i
|
|
487
|
+
return "" unless tokens.positive?
|
|
488
|
+
|
|
489
|
+
cost = cost_text(segment_value(row, :prefill_cost_estimate))
|
|
490
|
+
"switch prefill #{MemoryBundle::IndexSize.count_text(tokens)} uncached prompt#{" ≈#{cost}" unless cost.empty?}"
|
|
491
|
+
end
|
|
492
|
+
|
|
449
493
|
private
|
|
450
494
|
|
|
451
495
|
# The recap shown on return: dim, one "recap>" block, noting how many
|
|
@@ -574,12 +618,17 @@ module Samagotchi
|
|
|
574
618
|
lines << " called as: #{aliased.map { |k, v| "#{k}=#{v}" }.join(", ")}"
|
|
575
619
|
end
|
|
576
620
|
lines << "iterations: #{snapshot[:iterations_total]}"
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
621
|
+
segments = Array(snapshot[:model_segments])
|
|
622
|
+
if segments.size >= 2
|
|
623
|
+
lines.concat(models_block_lines(segments, snapshot[:turns], tokens, token_src_label))
|
|
624
|
+
else
|
|
625
|
+
# Summed over every request: each prompt is sent in full again.
|
|
626
|
+
lines << "tokens in/out: #{tokens[:prompt_sum].to_i}/#{tokens[:completion_sum].to_i} (all requests, #{token_src_label})" \
|
|
627
|
+
"#{token_breakdown_text(tokens)}"
|
|
628
|
+
end
|
|
629
|
+
speed = stats_speed_text(tokens, last_model: segments.size >= 2 ? last_speed_label(segments) : nil)
|
|
581
630
|
lines << "speed: #{speed}" if speed
|
|
582
|
-
cost = stats_cost_text(tokens, price_host: snapshot[:price_host])
|
|
631
|
+
cost = stats_cost_text(tokens, price_host: snapshot[:price_host], per_model: segments.size >= 2)
|
|
583
632
|
lines << "cost: #{cost}" if cost
|
|
584
633
|
lines << "gen latency (ms): #{snapshot[:gen_latency_ms]}"
|
|
585
634
|
lines << "cancellations: #{snapshot[:cancellations]}"
|
|
@@ -682,19 +731,87 @@ module Samagotchi
|
|
|
682
731
|
|
|
683
732
|
# "87 tok/s out, 1.9k tok/s prompt (last, server), avg 81 tok/s", or nil
|
|
684
733
|
# before a generation had a speed. The prompt (prefill) speed only when
|
|
685
|
-
# the server reported it.
|
|
686
|
-
|
|
734
|
+
# the server reported it. +last_model+: the model the last speed came
|
|
735
|
+
# from, named when the session ran on several.
|
|
736
|
+
def stats_speed_text(tokens, last_model: nil)
|
|
687
737
|
last = tokens[:last_decode_tps]
|
|
688
738
|
return nil unless last
|
|
689
739
|
|
|
690
740
|
source = tokens[:tps_source].to_s
|
|
691
741
|
text = "#{speed_text(last, source)} out"
|
|
692
742
|
text << ", #{speed_text(tokens[:last_prefill_tps], "server")} prompt" if tokens[:last_prefill_tps]
|
|
693
|
-
text << " (last, #{source})"
|
|
743
|
+
text << " (last, #{source}#{", #{last_model}" if last_model})"
|
|
694
744
|
text << ", avg #{speed_text(tokens[:avg_decode_tps], source)}" if tokens[:avg_decode_tps]
|
|
695
745
|
text
|
|
696
746
|
end
|
|
697
747
|
|
|
748
|
+
def segment_value(row, key)
|
|
749
|
+
row[key] || row[key.to_s]
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
# /stats' models: block, in place of the tokens line: a row per
|
|
753
|
+
# segment (model, when, turns, tokens, its average speed and cost), a
|
|
754
|
+
# switch's prefill under the row of the model switched to, and the
|
|
755
|
+
# total (the tokens line's sums and breakdown).
|
|
756
|
+
def models_block_lines(segments, turns, tokens, token_src_label)
|
|
757
|
+
dated = segment_dates_differ?(segments)
|
|
758
|
+
cells = segments.map do |row|
|
|
759
|
+
speed = speed_text(segment_value(row, :avg_decode_tps), segment_value(row, :tps_source))
|
|
760
|
+
[model_segment_label(row), segment_span_text(row, dated: dated), turns_text(segment_value(row, :turns)),
|
|
761
|
+
[model_segment_tokens_text(row), speed, model_segment_cost_text(row)].reject(&:empty?).join(" ")]
|
|
762
|
+
end
|
|
763
|
+
total = ["total", "", turns_text(turns),
|
|
764
|
+
"#{MemoryBundle::IndexSize.count_text(tokens[:prompt_sum].to_i)} in / " \
|
|
765
|
+
"#{MemoryBundle::IndexSize.count_text(tokens[:completion_sum].to_i)} out " \
|
|
766
|
+
"(all requests, #{token_src_label}#{token_breakdown_text(tokens)})"]
|
|
767
|
+
widths = (0..2).map { |i| (cells + [total]).map { |cell| cell[i].length }.max }
|
|
768
|
+
row_text = ->(cell) { cell.each_with_index.map { |text, i| i < 3 ? text.ljust(widths[i]) : text }.join(" ") }
|
|
769
|
+
lines = []
|
|
770
|
+
segments.each_with_index do |row, i|
|
|
771
|
+
lines << "#{i.zero? ? "models:".ljust(18) : " " * 18}#{row_text.call(cells[i])}"
|
|
772
|
+
prefill = model_segment_prefill_text(row)
|
|
773
|
+
lines << "#{" " * 20}└ #{prefill}" unless prefill.empty?
|
|
774
|
+
end
|
|
775
|
+
lines << "#{" " * 18}#{row_text.call(total)}"
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
def turns_text(count)
|
|
779
|
+
"#{count.to_i} #{count.to_i == 1 ? "turn" : "turns"}"
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
# "10:02–10:41", "10:43–now" (a row with a turn running); with the
|
|
783
|
+
# day ("Oct 16 10:02") when the rows span more than one day.
|
|
784
|
+
def segment_span_text(row, dated:)
|
|
785
|
+
started = segment_time(segment_value(row, :started_at))
|
|
786
|
+
ended = segment_time(segment_value(row, :ended_at))
|
|
787
|
+
format_time = lambda { |time|
|
|
788
|
+
if time
|
|
789
|
+
time.strftime(dated ? "%b %-d %H:%M" : "%H:%M")
|
|
790
|
+
else
|
|
791
|
+
"?"
|
|
792
|
+
end
|
|
793
|
+
}
|
|
794
|
+
"#{format_time.call(started)}–#{ended ? format_time.call(ended) : "now"}"
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
def segment_dates_differ?(segments)
|
|
798
|
+
days = segments.flat_map { |row| [segment_value(row, :started_at), segment_value(row, :ended_at)] }
|
|
799
|
+
.filter_map { |stamp| segment_time(stamp)&.strftime("%F") }
|
|
800
|
+
days.uniq.size > 1
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
def segment_time(stamp)
|
|
804
|
+
stamp ? Time.iso8601(stamp.to_s).localtime : nil
|
|
805
|
+
rescue ArgumentError
|
|
806
|
+
nil
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
# The row the session's last speed came from: the newest with one.
|
|
810
|
+
def last_speed_label(segments)
|
|
811
|
+
row = segments.reverse_each.find { |segment| segment_value(segment, :last_decode_tps) }
|
|
812
|
+
row && model_segment_label(row)
|
|
813
|
+
end
|
|
814
|
+
|
|
698
815
|
def format_elapsed_duration(duration_ms)
|
|
699
816
|
duration_ms = duration_ms.to_f
|
|
700
817
|
return "" if duration_ms.negative?
|
|
@@ -89,7 +89,9 @@ module Samagotchi
|
|
|
89
89
|
# hint). Other events change nothing.
|
|
90
90
|
def take_event(event)
|
|
91
91
|
case event[:type]
|
|
92
|
-
|
|
92
|
+
# usage nil (a /model whose window didn't resolve) clears it.
|
|
93
|
+
when :context_status
|
|
94
|
+
update(context: event[:usage] ? { est_pct: event.dig(:usage, :estimated_pct), bucket: event[:bucket] } : nil)
|
|
93
95
|
when :used_memories_updated then update(used_memories: Array(event[:used_memory_names]))
|
|
94
96
|
when :generation_completed
|
|
95
97
|
update(served: [event[:served_model], event[:requested_model], event[:served_expected_by]]) if event[:served_model]
|