samagotchi 0.44.0 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +120 -1
- data/docs/architecture.md +2 -1
- data/docs/cli.md +43 -12
- data/docs/configuration.md +102 -5
- data/docs/context.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/plugins.md +4 -2
- data/docs/sessions.md +9 -5
- data/lib/samagotchi/bridge/turn_accumulator.rb +19 -0
- data/lib/samagotchi/bridge.rb +22 -10
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +22 -4
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/commands/registry.rb +54 -9
- data/lib/samagotchi/config.rb +57 -4
- data/lib/samagotchi/desktop/macos/Panel.swift +7 -3
- data/lib/samagotchi/engine.rb +47 -9
- data/lib/samagotchi/host_model.rb +116 -0
- data/lib/samagotchi/host_registry.rb +43 -12
- data/lib/samagotchi/kernel_loop.rb +2 -1
- data/lib/samagotchi/llm/chat_loop.rb +89 -7
- data/lib/samagotchi/llm/turn_settings.rb +5 -3
- data/lib/samagotchi/llm_context_strategy.rb +37 -0
- data/lib/samagotchi/model_catalog.rb +17 -12
- data/lib/samagotchi/model_notes.rb +8 -6
- data/lib/samagotchi/model_overlay.rb +3 -2
- data/lib/samagotchi/model_price.rb +54 -0
- data/lib/samagotchi/model_profile.rb +74 -3
- data/lib/samagotchi/plugin/context.rb +19 -7
- data/lib/samagotchi/process_group.rb +56 -9
- data/lib/samagotchi/recap_setup.rb +1 -1
- data/lib/samagotchi/self_report.rb +33 -3
- data/lib/samagotchi/session_commands.rb +47 -21
- data/lib/samagotchi/session_manager.rb +19 -1
- data/lib/samagotchi/session_metrics.rb +79 -11
- data/lib/samagotchi/sessions_command.rb +15 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +85 -3
- data/lib/samagotchi/terminal_ui/attached_view.rb +2 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +45 -10
- data/lib/samagotchi/terminal_ui/status_row.rb +12 -6
- data/lib/samagotchi/terminal_ui.rb +97 -12
- 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 +12 -11
- data/lib/samagotchi/web/public/app.js +30 -18
- data/lib/samagotchi/web/public/command_complete.js +3 -1
- data/lib/samagotchi/web/public/ctx.js +62 -22
- data/lib/samagotchi/web/public/format.js +7 -2
- data/lib/samagotchi/web/public/index.html +3 -1
- data/lib/samagotchi/web/public/llm_context_chip.js +2 -4
- data/lib/samagotchi/web/public/model_pick.js +23 -3
- data/lib/samagotchi/web/public/model_picker.js +5 -3
- data/lib/samagotchi/web/public/stage_model.js +18 -4
- data/lib/samagotchi/web/public/stage_view.js +4 -3
- data/lib/samagotchi/web/public/turn_events.js +19 -6
- data/lib/samagotchi/web/public/turn_model.js +16 -0
- data/lib/samagotchi/web/public/turn_view.js +19 -0
- data/lib/samagotchi/web/session_hub.rb +9 -1
- data/lib/samagotchi/web/session_summary.rb +19 -2
- data/lib/samagotchi/worker.rb +172 -21
- data/lib/samagotchi/worker_inbound.rb +6 -5
- metadata +4 -1
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -481,11 +481,16 @@ module Samagotchi
|
|
|
481
481
|
|
|
482
482
|
# The model notes the session's prompt carried at its last build
|
|
483
483
|
# (Session#prompt_notes): a resumed session's or a woken worker's saved
|
|
484
|
-
# ones until this process builds its prompt;
|
|
485
|
-
#
|
|
484
|
+
# ones until this process builds its prompt; a fresh session (no build
|
|
485
|
+
# yet, no messages) has none saved, so the effective model's prompt
|
|
486
|
+
# would load them: the notes #model_notes names, as without a session.
|
|
487
|
+
# The three readers (/model, /stats, the web) share this.
|
|
486
488
|
# @return [Array<PromptNote>]
|
|
487
489
|
def prompt_notes
|
|
488
|
-
return
|
|
490
|
+
return model_notes.map { |note| PromptNote.from_note(note) } unless @session
|
|
491
|
+
|
|
492
|
+
saved = @session.prompt_notes
|
|
493
|
+
return saved.dup if !@prompt_builder.prompt_notes.nil? || !saved.empty? || @session.messages.to_a.any?
|
|
489
494
|
|
|
490
495
|
model_notes.map { |note| PromptNote.from_note(note) }
|
|
491
496
|
rescue StandardError
|
|
@@ -945,7 +950,8 @@ module Samagotchi
|
|
|
945
950
|
snapshot = @metrics.snapshot
|
|
946
951
|
target = @host_registry.resolve(@effective_model_name)
|
|
947
952
|
served, served_for = served_model_for(snapshot, target: target)
|
|
948
|
-
snapshot = snapshot.merge(served_model: served, served_model_for: served_for,
|
|
953
|
+
snapshot = snapshot.merge(served_model: served, served_model_for: served_for,
|
|
954
|
+
served_expected_by: served_expected_by(served, target: target), llm_context: llm_context_summary,
|
|
949
955
|
prompt_notes: prompt_notes.map(&:to_h))
|
|
950
956
|
unless snapshot.dig(:context, :window_tokens)
|
|
951
957
|
window = current_context_window(target)
|
|
@@ -1040,6 +1046,19 @@ module Samagotchi
|
|
|
1040
1046
|
served_model_for(@metrics.served_report, target: probe ? @host_registry.resolve(@effective_model_name) : nil)
|
|
1041
1047
|
end
|
|
1042
1048
|
|
|
1049
|
+
# The host whose hosts.<name>.models.<id>.served names +served+ for the
|
|
1050
|
+
# current model (a gateway's round-robin targets): not a mismatch to
|
|
1051
|
+
# warn about. nil when none does.
|
|
1052
|
+
# @return [String, nil]
|
|
1053
|
+
def served_expected_by(served, target: nil)
|
|
1054
|
+
target ||= @host_registry.resolve(@effective_model_name)
|
|
1055
|
+
target.entry.name if target.entry.models&.dig(target.bare_model.to_s.strip.downcase)&.serves?(served)
|
|
1056
|
+
rescue StandardError
|
|
1057
|
+
nil
|
|
1058
|
+
end
|
|
1059
|
+
|
|
1060
|
+
def served_expected?(served) = !served_expected_by(served).nil?
|
|
1061
|
+
|
|
1043
1062
|
# Read-only snapshot of the engine's view of the current session plus the
|
|
1044
1063
|
# live event sequence. Cheap primitive used by the bridge's reconnect-too-
|
|
1045
1064
|
# old reset marker and the GET /session/:id/state read surface. Orthogonal
|
|
@@ -1060,6 +1079,8 @@ module Samagotchi
|
|
|
1060
1079
|
# :served_model, :served_model_for [String, nil] what a generation of
|
|
1061
1080
|
# that model reported serving, and the name asked (#served_model
|
|
1062
1081
|
# without the probe)
|
|
1082
|
+
# :served_expected_by [String, nil] the host whose models: entry names
|
|
1083
|
+
# that served model under served: (#served_expected_by)
|
|
1063
1084
|
# :recap_enabled [Boolean] whether an idle recap is configured, with
|
|
1064
1085
|
# :recap_min_user_turns and :recap_inactivity_seconds (nil when not)
|
|
1065
1086
|
def session_state_snapshot
|
|
@@ -1080,6 +1101,7 @@ module Samagotchi
|
|
|
1080
1101
|
model_name: effective_model_ref,
|
|
1081
1102
|
served_model: served_pair[0],
|
|
1082
1103
|
served_model_for: served_pair[1],
|
|
1104
|
+
served_expected_by: served_expected_by(served_pair[0]),
|
|
1083
1105
|
context_status: @last_context_status&.dup || saved_context_status(metrics[:context]),
|
|
1084
1106
|
llm_context: llm_context_summary,
|
|
1085
1107
|
recap_enabled: !@recap.nil?,
|
|
@@ -1819,7 +1841,7 @@ module Samagotchi
|
|
|
1819
1841
|
@turn_thinking = [thinking_level(thinking_target), thinking_target]
|
|
1820
1842
|
announce_thinking_level(*@turn_thinking)
|
|
1821
1843
|
LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking: @turn_thinking.first, model_name: nil,
|
|
1822
|
-
window_setting: nil, llm_context: nil)
|
|
1844
|
+
window_setting: nil, llm_context: nil, price: nil)
|
|
1823
1845
|
end
|
|
1824
1846
|
private :turn_settings
|
|
1825
1847
|
|
|
@@ -1913,7 +1935,7 @@ module Samagotchi
|
|
|
1913
1935
|
# turn's model, not the last native one.
|
|
1914
1936
|
explained = llm_context_explained
|
|
1915
1937
|
@kernel.turn_settings = turn.settings.with(model_name: bare_for_backend, window_setting: turn_window_setting,
|
|
1916
|
-
llm_context: explained&.resolved)
|
|
1938
|
+
llm_context: explained&.resolved, price: turn_price)
|
|
1917
1939
|
log_llm_context(explained)
|
|
1918
1940
|
|
|
1919
1941
|
turn.limit = @no_interrupt ? IterationLimit.for(no_interrupt: true) : max_iterations || IterationLimit.for
|
|
@@ -2544,6 +2566,12 @@ module Samagotchi
|
|
|
2544
2566
|
when :generation_started
|
|
2545
2567
|
@generation_thought = nil
|
|
2546
2568
|
watch&.started(event[:iteration])
|
|
2569
|
+
when :generation_retrying
|
|
2570
|
+
# A dropped stream's step asked again: it streams from the start.
|
|
2571
|
+
if event[:restarted]
|
|
2572
|
+
@generation_thought = nil
|
|
2573
|
+
watch&.started(event[:iteration])
|
|
2574
|
+
end
|
|
2547
2575
|
when :generation_chunk then note_generation_thought(progress[:thinking])
|
|
2548
2576
|
end
|
|
2549
2577
|
# The chat loop asked again without the thinking fields: a notice,
|
|
@@ -2561,13 +2589,14 @@ module Samagotchi
|
|
|
2561
2589
|
end
|
|
2562
2590
|
|
|
2563
2591
|
# A :generation_completed with the generation's decode speed (`speed:`
|
|
2564
|
-
# {decode_tps:, source:}, nil without one)
|
|
2565
|
-
# token totals (`tokens:`, the snapshot's block)
|
|
2592
|
+
# {decode_tps:, source:}, nil without one), the session's running
|
|
2593
|
+
# token totals (`tokens:`, the snapshot's block) and whether its served
|
|
2594
|
+
# model is an expected one (`served_expected_by:`, its host): the web's info bar
|
|
2566
2595
|
# reads them from the stream. SessionMetrics closes the generation here,
|
|
2567
2596
|
# so the event it gets next finds it closed.
|
|
2568
2597
|
def with_generation_report(event)
|
|
2569
2598
|
report = @metrics.finish_generation
|
|
2570
|
-
event.merge(speed: report.speed&.to_h, tokens: report.tokens)
|
|
2599
|
+
event.merge(speed: report.speed&.to_h, tokens: report.tokens, served_expected_by: served_expected_by(event[:served_model]))
|
|
2571
2600
|
rescue StandardError
|
|
2572
2601
|
event
|
|
2573
2602
|
end
|
|
@@ -2633,6 +2662,15 @@ module Samagotchi
|
|
|
2633
2662
|
nil
|
|
2634
2663
|
end
|
|
2635
2664
|
|
|
2665
|
+
# The effective model's price on its host (hosts.<name>.models.<id>.price),
|
|
2666
|
+
# by the requested id, never the served one; read per turn as the window is.
|
|
2667
|
+
def turn_price
|
|
2668
|
+
target = @host_registry.resolve(@effective_model_name)
|
|
2669
|
+
target.entry.models&.dig(target.bare_model.to_s.strip.downcase)&.price
|
|
2670
|
+
rescue StandardError
|
|
2671
|
+
nil
|
|
2672
|
+
end
|
|
2673
|
+
|
|
2636
2674
|
# The LLM context strategy the next turn runs under: the session's own
|
|
2637
2675
|
# (Session#llm_context), else the effective model's (models:/hosts:
|
|
2638
2676
|
# llm_context_strategy), else llm_context.strategy; read per turn as
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "model_price"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# A model a host serves, declared under hosts.<name>.models: whatever the
|
|
7
|
+
# host's /v1/models says (a gateway's round-robin ids it never lists). A
|
|
8
|
+
# declared id is known on its host: no "doesn't list" warning, no re-list,
|
|
9
|
+
# and a bare id routes there as if the host listed it
|
|
10
|
+
# (HostRegistry#host_for_model).
|
|
11
|
+
#
|
|
12
|
+
# id: the id as written (keys are matched downcased); price: its
|
|
13
|
+
# ModelPrice on this host, nil when none; served: the models the host may
|
|
14
|
+
# answer it with (a gateway's round-robin targets), exact ids as written
|
|
15
|
+
# or "any", nil when none: no served-model warning for those (#serves?).
|
|
16
|
+
HostModel = Data.define(:id, :price, :served) do
|
|
17
|
+
def initialize(id:, price: nil, served: nil) = super
|
|
18
|
+
|
|
19
|
+
# hosts.<name>.models as written: a map of ids (each nil or a map), or
|
|
20
|
+
# a plain list of ids. Anything else warns once and is skipped.
|
|
21
|
+
# @param raw [Hash, Array, nil]
|
|
22
|
+
# @param host_name [String] for the warnings
|
|
23
|
+
# @return [Hash{String => HostModel}] by downcased id, in written order
|
|
24
|
+
def self.parse_map(raw, host_name)
|
|
25
|
+
return {} if raw.nil?
|
|
26
|
+
|
|
27
|
+
where = "hosts.#{host_name}.models"
|
|
28
|
+
pairs = case raw
|
|
29
|
+
when Hash then raw.to_a
|
|
30
|
+
when Array then raw.map { |id| [id, nil] }
|
|
31
|
+
else
|
|
32
|
+
warn_once "Warning: #{where} must be a map or a list of model ids; ignored"
|
|
33
|
+
return {}
|
|
34
|
+
end
|
|
35
|
+
pairs.each_with_object({}) do |(id, entry), models|
|
|
36
|
+
id = id.to_s.strip if id.is_a?(String) || id.is_a?(Symbol)
|
|
37
|
+
unless id.is_a?(String) && !id.empty?
|
|
38
|
+
warn_once "Warning: #{where}: #{id.inspect} is not a model id; ignored"
|
|
39
|
+
next
|
|
40
|
+
end
|
|
41
|
+
unless entry.nil? || entry.is_a?(Hash)
|
|
42
|
+
warn_once "Warning: #{where}.#{id} must be empty or a mapping; ignored"
|
|
43
|
+
next
|
|
44
|
+
end
|
|
45
|
+
value = ->(key) { entry && (entry.key?(key) ? entry[key] : entry[key.to_sym]) }
|
|
46
|
+
price = ModelPrice.parse(value.call("price"), "#{where}.#{id}")
|
|
47
|
+
models[id.downcase] ||= new(id: id, price: price, served: parse_served(value.call("served"), "#{where}.#{id}"))
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# served: a list of ids, or "any"; anything else warns once and is unset.
|
|
52
|
+
def self.parse_served(raw, where)
|
|
53
|
+
return nil if raw.nil?
|
|
54
|
+
return "any" if raw.is_a?(String) && raw.strip.casecmp?("any")
|
|
55
|
+
|
|
56
|
+
ids = raw.is_a?(Array) && raw.all? { |id| id.is_a?(String) && !id.strip.empty? } ? raw.map(&:strip) : nil
|
|
57
|
+
warn_once "Warning: #{where}.served must be a list of model ids or any; ignored" unless ids
|
|
58
|
+
ids
|
|
59
|
+
end
|
|
60
|
+
private_class_method :parse_served
|
|
61
|
+
|
|
62
|
+
# Whether the host answering this model with +name+ is expected
|
|
63
|
+
# (served: lists it, any case, or says any).
|
|
64
|
+
def serves?(name)
|
|
65
|
+
return false if served.nil? || name.to_s.strip.empty?
|
|
66
|
+
|
|
67
|
+
served == "any" || served.any? { |id| id.casecmp?(name.to_s.strip) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# config.rb requires this file, so config is required here only when a
|
|
71
|
+
# warning needs it (this file loaded alone).
|
|
72
|
+
def self.warn_once(message)
|
|
73
|
+
require_relative "config"
|
|
74
|
+
ConfigFile.warn_once(message)
|
|
75
|
+
end
|
|
76
|
+
private_class_method :warn_once
|
|
77
|
+
|
|
78
|
+
# One host's rows as the listings show them, declared ids first, then
|
|
79
|
+
# the listed ones in the host's order (see Row). An id both declared and
|
|
80
|
+
# listed is one row. Pure: +results+ (list_all_models') is only read.
|
|
81
|
+
# A host that errored has no rows: its declared ids aren't shown
|
|
82
|
+
# either (the listings say it's unreachable).
|
|
83
|
+
# @param results [Hash{String => Hash}] host name => {models: [ModelInfo], error:}
|
|
84
|
+
# @param entries [Hash{String => HostRegistry::HostEntry}]
|
|
85
|
+
# @return [Hash{String => Array<Row>}] for the hosts that answered
|
|
86
|
+
def self.rows(results, entries)
|
|
87
|
+
results.each_with_object({}) do |(name, data), out|
|
|
88
|
+
next if data[:error]
|
|
89
|
+
|
|
90
|
+
infos = Array(data[:models])
|
|
91
|
+
by_id = infos.group_by { |info| info.id.to_s.downcase }
|
|
92
|
+
declared = entries[name]&.models || {}
|
|
93
|
+
out[name] = declared.values.map do |m|
|
|
94
|
+
info = by_id[m.id.downcase]&.first
|
|
95
|
+
# A listed id keeps the host's spelling: a case-sensitive server takes only that.
|
|
96
|
+
HostModel::Row.new(id: info ? info.id.to_s : m.id, configured: true, info: info)
|
|
97
|
+
end +
|
|
98
|
+
infos.reject { |info| declared.key?(info.id.to_s.downcase) }
|
|
99
|
+
.map { |info| HostModel::Row.new(id: info.id.to_s, configured: false, info: info) }
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The entry as written back to config (SAMAGOTCHI_HOSTS_JSON for
|
|
104
|
+
# workers): nil for a bare id.
|
|
105
|
+
def to_config
|
|
106
|
+
config = { "price" => price&.to_config, "served" => served }.compact
|
|
107
|
+
config unless config.empty?
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# A listing row (HostModel.rows): id as the host lists it, else as
|
|
112
|
+
# declared;
|
|
113
|
+
# configured: declared under hosts.<name>.models; info: the host's
|
|
114
|
+
# ModelInfo for it, nil when only declared.
|
|
115
|
+
HostModel::Row = Data.define(:id, :configured, :info)
|
|
116
|
+
end
|
|
@@ -39,10 +39,12 @@ module Samagotchi
|
|
|
39
39
|
# window_tokens: the configured context window (ContextWindow.setting), nil when unset;
|
|
40
40
|
# llm_context_strategy: the configured layers (LLMContextStrategy), nil when unset;
|
|
41
41
|
# llm_context_apply: the configured apply rule (LLMContextStrategy), nil when unset;
|
|
42
|
-
# llm_context_budget_tokens: the configured context budget (LLMContextStrategy), nil when unset
|
|
42
|
+
# llm_context_budget_tokens: the configured context budget (LLMContextStrategy), nil when unset;
|
|
43
|
+
# models: the ids the host serves whatever it lists, {downcased id => HostModel} ({} when none).
|
|
43
44
|
HostEntry = Struct.new(:name, :host, :port, :transport, :client, :api, :scheme, :url, :api_key_env, :profile,
|
|
44
45
|
:first_token_timeout, :vision, :sampling, :thinking, :remote, :window_tokens,
|
|
45
|
-
:llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
|
|
46
|
+
:llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens, :models,
|
|
47
|
+
keyword_init: true) do
|
|
46
48
|
# Talks the OpenAI chat API (the chat loop); nil and raw apis use the
|
|
47
49
|
# raw-prompt loop.
|
|
48
50
|
def chat? = api == :openai
|
|
@@ -104,7 +106,7 @@ module Samagotchi
|
|
|
104
106
|
vision: cfg[:vision], sampling: cfg[:sampling], thinking: cfg[:thinking],
|
|
105
107
|
remote: cfg[:remote], window_tokens: cfg[:window_tokens],
|
|
106
108
|
llm_context_strategy: cfg[:llm_context_strategy], llm_context_apply: cfg[:llm_context_apply],
|
|
107
|
-
llm_context_budget_tokens: cfg[:llm_context_budget_tokens])
|
|
109
|
+
llm_context_budget_tokens: cfg[:llm_context_budget_tokens], models: cfg[:models] || {})
|
|
108
110
|
entry.client = Client.new(host: cfg[:host], port: cfg[:port], transport: transport, scheme: cfg[:scheme],
|
|
109
111
|
first_token_timeout: entry.first_token_limit, name: entry.name,
|
|
110
112
|
api_key_env: entry.api_key_env, env: env)
|
|
@@ -116,7 +118,8 @@ module Samagotchi
|
|
|
116
118
|
host = "localhost" if host.empty?
|
|
117
119
|
port = Config.get("server.port").to_i
|
|
118
120
|
port = 8080 if port <= 0
|
|
119
|
-
@entries["default"] = HostEntry.new(name: "default", host: host, port: port, transport: nil,
|
|
121
|
+
@entries["default"] = HostEntry.new(name: "default", host: host, port: port, transport: nil, models: {},
|
|
122
|
+
client: Client.new(host: host, port: port, name: "default"))
|
|
120
123
|
end
|
|
121
124
|
@mutex = Mutex.new
|
|
122
125
|
@cache = nil
|
|
@@ -195,18 +198,21 @@ module Samagotchi
|
|
|
195
198
|
|
|
196
199
|
# The host a model name, alias or host:model ref goes to and the model
|
|
197
200
|
# id it is sent as (ModelRef: one alias pass). A ref that names a host
|
|
198
|
-
# goes there. A bare id goes to the
|
|
199
|
-
#
|
|
200
|
-
#
|
|
201
|
+
# goes there. A bare id goes to the hosts whose cached list has it or
|
|
202
|
+
# that declare it (hosts.<name>.models, known before any /models): the
|
|
203
|
+
# default host when it is one of them, else the first in hosts: order,
|
|
204
|
+
# else the default host (before /models nothing is listed: no
|
|
201
205
|
# discovery here, the latency budget). Never by a substring.
|
|
202
206
|
def host_for_model(raw_model)
|
|
203
207
|
ref = model_ref(raw_model)
|
|
204
208
|
# ModelRef names a host only when it is one of ours
|
|
205
209
|
return [find_entry(ref.host_name), ref.id] if ref.host_name
|
|
206
210
|
|
|
207
|
-
|
|
211
|
+
down = ref.id.to_s.strip.downcase
|
|
212
|
+
listed = @mutex.synchronize { @model_index }&.fetch(down, nil) || []
|
|
213
|
+
candidates = @entries.values.select { |e| listed.include?(e.name) || e.models&.key?(down) }.map(&:name)
|
|
208
214
|
default = default_entry
|
|
209
|
-
name =
|
|
215
|
+
name = candidates.include?(default.name) ? default.name : candidates.first
|
|
210
216
|
[(name && find_entry(name)) || default, ref.id]
|
|
211
217
|
end
|
|
212
218
|
|
|
@@ -235,6 +241,26 @@ module Samagotchi
|
|
|
235
241
|
end
|
|
236
242
|
end
|
|
237
243
|
|
|
244
|
+
# List one host's models and save them to ModelListStore: what
|
|
245
|
+
# ModelProfile.model_warning re-lists on a miss (a fresh saved list that
|
|
246
|
+
# lacks the id: the server may have been reloaded with another model),
|
|
247
|
+
# so the saved list catches up at spawn time. Only that host is asked,
|
|
248
|
+
# and never through the cache (a fresh listing is the point).
|
|
249
|
+
# @param name [String, Symbol, nil] a host name; anything else lists
|
|
250
|
+
# nothing
|
|
251
|
+
# @return [Array<String>, nil] the ids the host lists now, nil for an
|
|
252
|
+
# unknown name or when the host fails to list (nothing is saved then:
|
|
253
|
+
# a host that is down keeps the ids we know)
|
|
254
|
+
def list_models(name)
|
|
255
|
+
entry = name.nil? ? nil : find_entry(name)
|
|
256
|
+
return nil unless entry
|
|
257
|
+
|
|
258
|
+
save_model_list(entry.name, list_models_for(entry))&.ids
|
|
259
|
+
rescue StandardError => e
|
|
260
|
+
Log.warn(:model, "list_failed", host: name.to_s, error: e.class.name, msg: e.message.to_s[0, 500])
|
|
261
|
+
nil
|
|
262
|
+
end
|
|
263
|
+
|
|
238
264
|
def client_for_model(raw_model)
|
|
239
265
|
host_entry, bare = host_for_model(raw_model)
|
|
240
266
|
[client_for(host_entry), bare, host_entry]
|
|
@@ -293,9 +319,7 @@ module Samagotchi
|
|
|
293
319
|
models = list_models_for(entry)
|
|
294
320
|
data = { host: entry.host, port: entry.port, transport: entry.transport, models: models, error: nil }
|
|
295
321
|
@mutex.synchronize { @host_lists[name] = { data: data, at: @clock.call } }
|
|
296
|
-
|
|
297
|
-
# (`chi send --new --model`, delegate) checks an id against it.
|
|
298
|
-
ModelListStore.save(name, models.map(&:id))
|
|
322
|
+
save_model_list(name, models)
|
|
299
323
|
rescue StandardError => e
|
|
300
324
|
Log.warn(:model, "list_failed", host: name, error: e.class.name, msg: e.message.to_s[0, 500])
|
|
301
325
|
data = { host: entry.host, port: entry.port, transport: entry.transport, models: [], error: e.message }
|
|
@@ -350,6 +374,13 @@ module Samagotchi
|
|
|
350
374
|
|
|
351
375
|
private
|
|
352
376
|
|
|
377
|
+
# Save a host's just-listed models on disk, for the processes that spawn
|
|
378
|
+
# a worker without listing (`chi send --new --model`, delegate):
|
|
379
|
+
# ModelListStore is what they check an id against.
|
|
380
|
+
def save_model_list(name, models)
|
|
381
|
+
ModelListStore.save(name, Array(models).map(&:id))
|
|
382
|
+
end
|
|
383
|
+
|
|
353
384
|
def fresh_list(name, entry)
|
|
354
385
|
@mutex.synchronize do
|
|
355
386
|
cached = @host_lists[name]
|
|
@@ -353,7 +353,8 @@ module Samagotchi
|
|
|
353
353
|
# thought or a tool call doesn't leave the next one "inside" it.
|
|
354
354
|
stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
|
|
355
355
|
emit(turn, type: :generation_started, iteration: turn.iteration, context_window_tokens: request.window.tokens,
|
|
356
|
-
context_window_source: request.window.source, profile: @profile.name, profile_source: @profile_source
|
|
356
|
+
context_window_source: request.window.source, profile: @profile.name, profile_source: @profile_source,
|
|
357
|
+
price: @turn_settings.price&.to_h)
|
|
357
358
|
# This generation's own server counts (the run-long ones in the
|
|
358
359
|
# context tracker can be an earlier one's), and the thinking it
|
|
359
360
|
# streamed, for the log (a stuck thinking generation shows as
|
|
@@ -54,10 +54,49 @@ module Samagotchi
|
|
|
54
54
|
# per turn for the effective model's host
|
|
55
55
|
# @param stable_length [#call, nil] text → the length of the system
|
|
56
56
|
# prompt's part every session shares (SystemPrompt#stable_length), or nil
|
|
57
|
-
|
|
57
|
+
# @param sleeper [#call, nil] waits the given seconds before a dropped
|
|
58
|
+
# stream's step is asked again (specs pass one that doesn't sleep)
|
|
59
|
+
def initialize(kernel:, adapter: nil, stable_length: nil, sleeper: nil)
|
|
58
60
|
@kernel = kernel
|
|
59
61
|
@adapter = adapter
|
|
60
62
|
@stable_length = stable_length
|
|
63
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# How many times a dropped stream's step may be asked again: +cap+, or
|
|
67
|
+
# retry.max when that is smaller (retry.max: 0 turns it off, as it
|
|
68
|
+
# does the transport's retries).
|
|
69
|
+
def stream_drop_limit(cap)
|
|
70
|
+
[cap, HTTP::RetryPolicy.from_config.max].min
|
|
71
|
+
rescue StandardError
|
|
72
|
+
cap
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The seconds before the +attempt+-th ask-again of a dropped stream's
|
|
76
|
+
# step: the transport's own backoff (retry.base_delay doubling, capped
|
|
77
|
+
# at retry.max_delay; LLM::HTTP::RetryPolicy), 0 when retry.max is
|
|
78
|
+
# spent.
|
|
79
|
+
def generation_retry_delay(attempt)
|
|
80
|
+
HTTP::RetryPolicy.from_config.delay_for(attempt) || 0.0
|
|
81
|
+
rescue StandardError
|
|
82
|
+
0.0
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Waits +seconds+ in short slices, raising RequestCancelled as soon as
|
|
86
|
+
# one of +controllers+ (the turn's, the generation's) is cancelled, as
|
|
87
|
+
# LLM::HTTP's own wait between retries does.
|
|
88
|
+
def wait_retry(seconds, *controllers)
|
|
89
|
+
remaining = seconds.to_f
|
|
90
|
+
while remaining.positive?
|
|
91
|
+
cancelled = controllers.compact.find(&:cancelled?)
|
|
92
|
+
raise RequestCancelled, cancelled.reason if cancelled
|
|
93
|
+
|
|
94
|
+
slice = [remaining, 0.05].min
|
|
95
|
+
@sleeper.call(slice)
|
|
96
|
+
remaining -= slice
|
|
97
|
+
end
|
|
98
|
+
cancelled = controllers.compact.find(&:cancelled?)
|
|
99
|
+
raise RequestCancelled, cancelled.reason if cancelled
|
|
61
100
|
end
|
|
62
101
|
|
|
63
102
|
def provider = :chat
|
|
@@ -180,6 +219,9 @@ module Samagotchi
|
|
|
180
219
|
# turn_settings), or nil.
|
|
181
220
|
def vision = @kernel.turn_settings.vision
|
|
182
221
|
|
|
222
|
+
# The turn's configured price (the kernel's turn_settings), or nil.
|
|
223
|
+
def price = (@kernel.turn_settings&.price if @kernel.respond_to?(:turn_settings))
|
|
224
|
+
|
|
183
225
|
# The turn's request parameters (the kernel's turn_settings).
|
|
184
226
|
def sampling = @kernel.turn_settings.sampling || {}
|
|
185
227
|
|
|
@@ -461,20 +503,22 @@ module Samagotchi
|
|
|
461
503
|
observe_context(iteration, window)
|
|
462
504
|
retry_generation = @empty_retry.take_sampling!
|
|
463
505
|
emit(type: :generation_started, iteration: iteration, context_window_tokens: window&.tokens,
|
|
464
|
-
context_window_source: window&.source)
|
|
506
|
+
context_window_source: window&.source, price: @loop.price&.to_h)
|
|
465
507
|
@loop.fire_hook(:before_generation, { type: :before_generation, iteration: iteration })
|
|
466
508
|
streamed = +""
|
|
467
509
|
thought = +""
|
|
468
|
-
|
|
510
|
+
generation_controller = nil
|
|
511
|
+
response = with_generation do |controller|
|
|
512
|
+
generation_controller = controller
|
|
469
513
|
begin
|
|
470
|
-
|
|
514
|
+
request_with_drops(iteration, retry_generation, streamed, thought, 0, controller)
|
|
471
515
|
rescue BadRequest => e
|
|
472
516
|
raise unless thinking_refused?(e)
|
|
473
517
|
|
|
474
518
|
# Once per model: asked again without the thinking fields.
|
|
475
519
|
@loop.thinking_refused!(@model_name)
|
|
476
520
|
emit(type: :thinking_refused, iteration: iteration, model: @model_name, level: @loop.thinking, detail: e.detail)
|
|
477
|
-
|
|
521
|
+
request_with_drops(iteration, retry_generation, streamed, thought, 0, controller)
|
|
478
522
|
end
|
|
479
523
|
rescue RequestCancelled
|
|
480
524
|
raise unless generation_controller&.cancelled? && !@cancel_controller.cancelled?
|
|
@@ -522,7 +566,7 @@ module Samagotchi
|
|
|
522
566
|
[response, nil]
|
|
523
567
|
end
|
|
524
568
|
|
|
525
|
-
def request(iteration, retry_generation, streamed, thought, generation_controller)
|
|
569
|
+
def request(iteration, retry_generation, streamed, thought, generation_controller, streamed_any = nil)
|
|
526
570
|
@loop.adapter.chat(
|
|
527
571
|
messages: @loop.wire_messages(@conversation), tools: @loop.tool_definitions, model: @model_name,
|
|
528
572
|
cancel_controller: generation_controller || @cancel_controller, session_id: @loop.session_id,
|
|
@@ -533,13 +577,51 @@ module Samagotchi
|
|
|
533
577
|
event = { type: :generation_chunk, iteration: iteration, content: reasoning + content, text: content,
|
|
534
578
|
thinking: reasoning, payload: payload }
|
|
535
579
|
# A tool call streaming: a steer doesn't cut it (Engine#cut_for_steer).
|
|
536
|
-
|
|
580
|
+
tool_call = OpenAIChat.tool_call_delta?(payload)
|
|
581
|
+
event[:tool_call] = true if tool_call
|
|
582
|
+
streamed_any&.call if !content.to_s.empty? || !reasoning.to_s.empty? || tool_call
|
|
537
583
|
emit(event)
|
|
538
584
|
},
|
|
539
585
|
on_retry: ->(**retry_event) { emit({ type: :generation_retrying, iteration: iteration }.merge(retry_event)) }
|
|
540
586
|
)
|
|
541
587
|
end
|
|
542
588
|
|
|
589
|
+
# A dropped stream (LLM::HTTP raises RetryExhausted once a stream
|
|
590
|
+
# that already showed something fails, where a retry would repeat
|
|
591
|
+
# output) is asked again here, from the same conversation, up to
|
|
592
|
+
# MAX_STREAM_DROPS times per step (fewer under a smaller retry.max;
|
|
593
|
+
# none with 0), after the transport's backoff: the partial reply is
|
|
594
|
+
# dropped and the step restarts. A refusal or a first-token timeout
|
|
595
|
+
# fails as before, and a cancel (before or during the wait) never
|
|
596
|
+
# retries.
|
|
597
|
+
MAX_STREAM_DROPS = 2
|
|
598
|
+
|
|
599
|
+
# Runs one request, asking again on a mid-stream drop. +attempt+
|
|
600
|
+
# counts the drops already retried this step.
|
|
601
|
+
def request_with_drops(iteration, retry_generation, streamed, thought, attempt, generation_controller)
|
|
602
|
+
streamed_any = false
|
|
603
|
+
request(iteration, retry_generation, streamed, thought, generation_controller, -> { streamed_any = true })
|
|
604
|
+
rescue RetryExhausted => e
|
|
605
|
+
# A cancel that landed as the stream dropped ends the turn canceled,
|
|
606
|
+
# not failed, whatever the drop's count.
|
|
607
|
+
cancelled = [@cancel_controller, generation_controller].compact.find(&:cancelled?)
|
|
608
|
+
raise RequestCancelled, cancelled.reason if cancelled
|
|
609
|
+
|
|
610
|
+
limit = @loop.stream_drop_limit(MAX_STREAM_DROPS)
|
|
611
|
+
raise if attempt >= limit || !streamed_any
|
|
612
|
+
|
|
613
|
+
attempt += 1
|
|
614
|
+
streamed.clear
|
|
615
|
+
thought.clear
|
|
616
|
+
delay = @loop.generation_retry_delay(attempt)
|
|
617
|
+
# restarted: the step's streamed chunks so far are void (the UIs
|
|
618
|
+
# and the Bridge's TurnAccumulator drop them).
|
|
619
|
+
emit(type: :generation_retrying, iteration: iteration, attempt: attempt, max_retries: limit,
|
|
620
|
+
next_delay: delay, error_class: e.class.name, error_message: e.message, status: nil, restarted: true)
|
|
621
|
+
@loop.wait_retry(delay, @cancel_controller, generation_controller)
|
|
622
|
+
retry
|
|
623
|
+
end
|
|
624
|
+
|
|
543
625
|
# A 400 about reasoning, for a request that carried thinking fields
|
|
544
626
|
# (not a missing-tools, image or context error).
|
|
545
627
|
def thinking_refused?(error)
|
|
@@ -11,12 +11,14 @@ module Samagotchi
|
|
|
11
11
|
# (Thinking.resolve), the bare model id the turn runs on (every debug
|
|
12
12
|
# dump is tagged with it) and the model's or host's configured context
|
|
13
13
|
# window (ContextWindow.setting, nil for none) and LLM context strategy
|
|
14
|
-
# (LLMContextStrategy.resolve, nil for none)
|
|
14
|
+
# (LLMContextStrategy.resolve, nil for none), and its price on its host
|
|
15
|
+
# (hosts.<name>.models.<id>.price, a ModelPrice; nil for none), what
|
|
16
|
+
# SessionMetrics estimates an unreported cost from. The Engine sets them once
|
|
15
17
|
# per turn, in Engine#generate, after the model's host is synced.
|
|
16
|
-
TurnSettings = Data.define(:vision, :sampling, :thinking, :model_name, :window_setting, :llm_context) do
|
|
18
|
+
TurnSettings = Data.define(:vision, :sampling, :thinking, :model_name, :window_setting, :llm_context, :price) do
|
|
17
19
|
# No vision, no sampling, the default thinking, no model name.
|
|
18
20
|
def self.none = new(vision: nil, sampling: {}.freeze, thinking: Thinking::DEFAULT, model_name: nil,
|
|
19
|
-
window_setting: nil, llm_context: nil)
|
|
21
|
+
window_setting: nil, llm_context: nil, price: nil)
|
|
20
22
|
end
|
|
21
23
|
end
|
|
22
24
|
end
|
|
@@ -167,6 +167,43 @@ module Samagotchi
|
|
|
167
167
|
budget_tokens: budget_origin)
|
|
168
168
|
end
|
|
169
169
|
|
|
170
|
+
# A session's LLM context strategy, apply rule and budget, each with
|
|
171
|
+
# where it came from (Explained#summary), as its worker would resolve
|
|
172
|
+
# them; nil when they can't be (an unreadable config, a model that
|
|
173
|
+
# resolves nowhere). Resolves from config and the registry only: no
|
|
174
|
+
# network, no model call. What a session page, a session list's ctx %
|
|
175
|
+
# and a spawn-time check all read.
|
|
176
|
+
# @param session [Session, nil]
|
|
177
|
+
# @param registry [HostRegistry, nil] the hosts to resolve against;
|
|
178
|
+
# HostRegistry.new by default (what the caller usually has)
|
|
179
|
+
# @return [Hash, nil] Explained#summary, symbol keys
|
|
180
|
+
def for_session(session, registry: nil)
|
|
181
|
+
return nil if session.nil?
|
|
182
|
+
|
|
183
|
+
require_relative "config"
|
|
184
|
+
registry ||= begin
|
|
185
|
+
require_relative "host_registry"
|
|
186
|
+
HostRegistry.new
|
|
187
|
+
end
|
|
188
|
+
name = session.model_name.to_s.strip.empty? ? ModelProfile.required_model_name(nil) : session.model_name
|
|
189
|
+
target = registry.resolve(name)
|
|
190
|
+
names = registry.lookup_names(session.model_typed || name, resolved: name, target: target)
|
|
191
|
+
own = session.llm_context
|
|
192
|
+
explain(target, names: names, **(own&.resolve_args || {})).summary(own)
|
|
193
|
+
rescue StandardError
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# The budget a session's turns run under (for_session's :budget_tokens),
|
|
198
|
+
# nil when it is off or can't be resolved. What a session list's ctx %
|
|
199
|
+
# counts against, so a saved session's fill reads as the live meter.
|
|
200
|
+
# @param session [Session, nil]
|
|
201
|
+
# @param registry [HostRegistry, nil] see .for_session
|
|
202
|
+
# @return [Integer, nil]
|
|
203
|
+
def session_budget(session, registry: nil)
|
|
204
|
+
for_session(session, registry: registry)&.fetch(:budget_tokens, nil)
|
|
205
|
+
end
|
|
206
|
+
|
|
170
207
|
# [the budget, its Origin]: the session's own first (0 there is off,
|
|
171
208
|
# whatever the model says).
|
|
172
209
|
def budget_for(target, names, models, session_budget)
|
|
@@ -7,33 +7,37 @@ module Samagotchi
|
|
|
7
7
|
# picks from: GET /api/models and `chi models`. Every name question goes
|
|
8
8
|
# to ModelRef / HostRegistry#resolve; nothing here parses a ref.
|
|
9
9
|
module ModelCatalog
|
|
10
|
-
# host: the host that listed the id; ref: the name
|
|
11
|
-
# it (see .spell)
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
# host: the host that listed (or declares) the id; ref: the name
|
|
11
|
+
# `--model` takes for it (see .spell); configured: declared under
|
|
12
|
+
# hosts.<name>.models (HostModel.rows).
|
|
13
|
+
Row = Data.define(:host, :id, :ref, :configured) do
|
|
14
|
+
def initialize(host:, id:, ref:, configured: false) = super
|
|
15
|
+
end
|
|
16
|
+
# rows: the default host's first, then the others by name, each host's
|
|
17
|
+
# declared ids first (HostModel.rows); warnings:
|
|
14
18
|
# "host: error" per host that failed.
|
|
15
19
|
Listing = Data.define(:rows, :warnings)
|
|
16
20
|
|
|
17
21
|
module_function
|
|
18
22
|
|
|
19
23
|
# @param results [Hash] list_all_models' answer
|
|
20
|
-
# @param registry [#default_entry]
|
|
24
|
+
# @param registry [#default_entry, #entries]
|
|
21
25
|
# @return [Listing]
|
|
22
26
|
def listing(results, registry:)
|
|
23
27
|
default_host = registry.default_entry&.name
|
|
24
28
|
rows = []
|
|
25
29
|
warnings = []
|
|
30
|
+
host_rows = HostModel.rows(results, registry.entries)
|
|
26
31
|
results.keys.sort_by { |name| [name == default_host ? 0 : 1, name] }.each do |host|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
warnings << "#{host}: #{data[:error]}"
|
|
32
|
+
if results[host][:error]
|
|
33
|
+
warnings << "#{host}: #{results[host][:error]}"
|
|
30
34
|
next
|
|
31
35
|
end
|
|
32
|
-
|
|
33
|
-
id =
|
|
34
|
-
next if id.strip.empty? || id.end_with?(":batch")
|
|
36
|
+
host_rows[host].each do |row|
|
|
37
|
+
id = row.id
|
|
38
|
+
next if id.strip.empty? || (!row.configured && id.end_with?(":batch"))
|
|
35
39
|
|
|
36
|
-
rows << Row.new(host: host, id: id, ref: spell(host, id, default_host))
|
|
40
|
+
rows << Row.new(host: host, id: id, ref: spell(host, id, default_host), configured: row.configured)
|
|
37
41
|
end
|
|
38
42
|
end
|
|
39
43
|
Listing.new(rows: rows, warnings: warnings)
|
|
@@ -58,6 +62,7 @@ module Samagotchi
|
|
|
58
62
|
listing = listing(results, registry: registry)
|
|
59
63
|
models = listing.rows.map do |row|
|
|
60
64
|
hash = { name: row.ref, host: row.host, id: row.id }
|
|
65
|
+
hash[:configured] = true if row.configured
|
|
61
66
|
shadow = shadowed_by(row, registry)
|
|
62
67
|
shadow ? hash.merge(shadowed_by: shadow) : hash
|
|
63
68
|
end
|