samagotchi 0.45.0 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +51 -1
- data/docs/architecture.md +2 -1
- data/docs/cli.md +14 -5
- data/docs/configuration.md +102 -5
- data/docs/internals/background-tasks.md +1 -0
- data/docs/internals/engine.md +1 -1
- data/docs/internals/llm-context-live.md +111 -0
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/bridge/turn_accumulator.rb +5 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/config.rb +57 -4
- data/lib/samagotchi/desktop/macos/Panel.swift +7 -3
- data/lib/samagotchi/engine.rb +39 -6
- data/lib/samagotchi/host_model.rb +116 -0
- data/lib/samagotchi/host_registry.rb +15 -9
- data/lib/samagotchi/kernel_loop.rb +2 -1
- data/lib/samagotchi/llm/chat_loop.rb +89 -7
- data/lib/samagotchi/llm/turn_settings.rb +5 -3
- data/lib/samagotchi/model_catalog.rb +17 -12
- data/lib/samagotchi/model_price.rb +54 -0
- data/lib/samagotchi/model_profile.rb +3 -1
- data/lib/samagotchi/process_group.rb +56 -9
- data/lib/samagotchi/recap_setup.rb +1 -1
- data/lib/samagotchi/self_report.rb +2 -1
- data/lib/samagotchi/session_commands.rb +23 -14
- data/lib/samagotchi/session_metrics.rb +40 -5
- data/lib/samagotchi/terminal_ui/attached_view.rb +2 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +26 -9
- data/lib/samagotchi/terminal_ui/status_row.rb +12 -6
- data/lib/samagotchi/terminal_ui.rb +13 -0
- data/lib/samagotchi/tools/delegate_wait.rb +5 -4
- data/lib/samagotchi/tools/task_runtime.rb +11 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +5 -2
- data/lib/samagotchi/web/public/app.js +7 -3
- data/lib/samagotchi/web/public/ctx.js +20 -7
- data/lib/samagotchi/web/public/format.js +7 -2
- data/lib/samagotchi/web/public/model_pick.js +23 -3
- data/lib/samagotchi/web/public/model_picker.js +5 -3
- data/lib/samagotchi/web/public/stage_view.js +1 -0
- data/lib/samagotchi/web/public/turn_events.js +3 -2
- data/lib/samagotchi/web/public/turn_model.js +16 -0
- data/lib/samagotchi/web/public/turn_view.js +19 -0
- data/lib/samagotchi/worker.rb +16 -1
- metadata +4 -1
data/lib/samagotchi/config.rb
CHANGED
|
@@ -10,6 +10,7 @@ require_relative "log"
|
|
|
10
10
|
require_relative "model_ref"
|
|
11
11
|
require_relative "paths"
|
|
12
12
|
require_relative "llm_context_strategy"
|
|
13
|
+
require_relative "host_model"
|
|
13
14
|
|
|
14
15
|
module Samagotchi
|
|
15
16
|
# Parses the thinking: levels of host and model entries (it needs Config).
|
|
@@ -263,10 +264,12 @@ module Samagotchi
|
|
|
263
264
|
# (ConfigFile.hosts_config, ConfigFile.model_settings).
|
|
264
265
|
MAP_ENTRY_KEYS = {
|
|
265
266
|
"hosts" => %w[host port url transport api api_key_env profile first_token_timeout vision sampling thinking enabled
|
|
266
|
-
remote window_tokens llm_context_strategy llm_context_apply llm_context_budget_tokens].freeze,
|
|
267
|
+
remote window_tokens llm_context_strategy llm_context_apply llm_context_budget_tokens models].freeze,
|
|
267
268
|
"models" => %w[profile vision sampling thinking window_tokens llm_context_strategy llm_context_apply
|
|
268
269
|
llm_context_budget_tokens].freeze
|
|
269
270
|
}.freeze
|
|
271
|
+
# The keys a hosts.<name>.models entry may hold (HostModel.parse_map).
|
|
272
|
+
HOST_MODEL_KEYS = %w[price served].freeze
|
|
270
273
|
# Section keys beyond the registry's: guardrails' YAML rules (Engine#guardrail_rules).
|
|
271
274
|
SECTION_EXTRA_KEYS = { "guardrails" => %w[rules disable].freeze }.freeze
|
|
272
275
|
|
|
@@ -615,7 +618,29 @@ module Samagotchi
|
|
|
615
618
|
|
|
616
619
|
(entry.keys.map(&:to_s) - allowed).map do |k|
|
|
617
620
|
unknown_key_message("#{map}.#{name}.#{k}", k, allowed, prefix: "#{map}.#{name}.")
|
|
618
|
-
end
|
|
621
|
+
end + (map == "hosts" ? host_model_problems(name, entry["models"]) : [])
|
|
622
|
+
end
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
# hosts.<name>.models' entries, one level further down.
|
|
626
|
+
def host_model_problems(host, models)
|
|
627
|
+
return [] unless models.is_a?(Hash)
|
|
628
|
+
|
|
629
|
+
models.flat_map do |id, model|
|
|
630
|
+
next [] unless model.is_a?(Hash)
|
|
631
|
+
|
|
632
|
+
prefix = "hosts.#{host}.models.#{id}."
|
|
633
|
+
(model.keys.map(&:to_s) - HOST_MODEL_KEYS).map do |k|
|
|
634
|
+
unknown_key_message("#{prefix}#{k}", k, HOST_MODEL_KEYS, prefix: prefix)
|
|
635
|
+
end + price_key_problems("#{prefix}price.", model["price"])
|
|
636
|
+
end
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
def price_key_problems(prefix, price)
|
|
640
|
+
return [] unless price.is_a?(Hash)
|
|
641
|
+
|
|
642
|
+
(price.keys.map(&:to_s) - ModelPrice::KEYS).map do |k|
|
|
643
|
+
unknown_key_message("#{prefix}#{k}", k, ModelPrice::KEYS, prefix: prefix)
|
|
619
644
|
end
|
|
620
645
|
end
|
|
621
646
|
|
|
@@ -853,6 +878,7 @@ module Samagotchi
|
|
|
853
878
|
"hosts entry '#{name}'")
|
|
854
879
|
llm_context_apply = LLMContextStrategy.parse_apply(raw_cfg.key?(LLMContextStrategy::APPLY_KEY) ? raw_cfg[LLMContextStrategy::APPLY_KEY] : raw_cfg[:llm_context_apply],
|
|
855
880
|
"hosts entry '#{name}'")
|
|
881
|
+
models = HostModel.parse_map(raw_cfg.key?("models") ? raw_cfg["models"] : raw_cfg[:models], name)
|
|
856
882
|
llm_context_budget = LLMContextStrategy.parse_budget(raw_cfg.key?(LLMContextStrategy::BUDGET_KEY) ? raw_cfg[LLMContextStrategy::BUDGET_KEY] : raw_cfg[:llm_context_budget_tokens],
|
|
857
883
|
"hosts entry '#{name}'")
|
|
858
884
|
unless first_token_timeout.nil? || (first_token_timeout.is_a?(Numeric) && !first_token_timeout.negative?)
|
|
@@ -920,8 +946,10 @@ module Samagotchi
|
|
|
920
946
|
profile: profile.empty? ? nil : profile, first_token_timeout: first_token_timeout,
|
|
921
947
|
vision: vision, sampling: sampling, thinking: thinking, remote: remote,
|
|
922
948
|
window_tokens: window, llm_context_strategy: llm_context,
|
|
923
|
-
llm_context_apply: llm_context_apply, llm_context_budget_tokens: llm_context_budget
|
|
949
|
+
llm_context_apply: llm_context_apply, llm_context_budget_tokens: llm_context_budget,
|
|
950
|
+
models: models }
|
|
924
951
|
end
|
|
952
|
+
warn_duplicate_host_models(normalized)
|
|
925
953
|
end
|
|
926
954
|
|
|
927
955
|
# If no hosts defined, synthesize "default" from server.host/port/transport.
|
|
@@ -941,6 +969,30 @@ module Samagotchi
|
|
|
941
969
|
{}
|
|
942
970
|
end
|
|
943
971
|
|
|
972
|
+
# hosts.<name>.models as written back (HostModel#to_config), nil when none.
|
|
973
|
+
def host_models_config(models)
|
|
974
|
+
models.values.to_h { |m| [m.id, m.to_config] } unless models.nil? || models.empty?
|
|
975
|
+
end
|
|
976
|
+
private_class_method :host_models_config
|
|
977
|
+
|
|
978
|
+
# Two hosts declaring one id under hosts.<name>.models: a bare id goes
|
|
979
|
+
# where HostRegistry#host_for_model sends it (the default host when it
|
|
980
|
+
# declares the id, else the first in hosts: order); say so once.
|
|
981
|
+
def warn_duplicate_host_models(hosts)
|
|
982
|
+
declared = Hash.new { |h, k| h[k] = [] }
|
|
983
|
+
hosts.each { |name, cfg| cfg[:models].each { |down, model| declared[down] << [name, model.id] } }
|
|
984
|
+
declared.each_value do |owners|
|
|
985
|
+
next if owners.size < 2
|
|
986
|
+
|
|
987
|
+
names = owners.map(&:first)
|
|
988
|
+
id = owners.first.last
|
|
989
|
+
goes = names.include?("default") ? "default" : "#{names.first} unless the default host lists it"
|
|
990
|
+
warn_once "Warning: #{names.map { |n| "hosts.#{n}.models" }.join(" and ")} #{names.size == 2 ? "both" : "all"} declare #{id}; " \
|
|
991
|
+
"a bare #{id} goes to #{goes}"
|
|
992
|
+
end
|
|
993
|
+
end
|
|
994
|
+
private_class_method :warn_duplicate_host_models
|
|
995
|
+
|
|
944
996
|
# The names (lowercased) of the hosts entries with enabled: false, which
|
|
945
997
|
# hosts_config leaves out. Read from the same source: config.yml's
|
|
946
998
|
# hosts:, or SAMAGOTCHI_HOSTS_JSON in a worker.
|
|
@@ -1070,7 +1122,8 @@ module Samagotchi
|
|
|
1070
1122
|
"remote" => v[:remote], "window_tokens" => v[:window_tokens],
|
|
1071
1123
|
"llm_context_strategy" => v[:llm_context_strategy]&.map(&:to_s),
|
|
1072
1124
|
"llm_context_apply" => v[:llm_context_apply]&.to_s,
|
|
1073
|
-
"llm_context_budget_tokens" => v[:llm_context_budget_tokens]
|
|
1125
|
+
"llm_context_budget_tokens" => v[:llm_context_budget_tokens],
|
|
1126
|
+
"models" => host_models_config(v[:models])).compact
|
|
1074
1127
|
end
|
|
1075
1128
|
# Disabled hosts travel as just that, so a worker refuses "box:x"
|
|
1076
1129
|
# the way its parent does instead of sending it to the default host.
|
|
@@ -980,6 +980,7 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
980
980
|
let kittyText = windows.isEmpty ? "" : Kitty.text(context: model.hasContext ? model.text : "", message: model.trimmedPrompt,
|
|
981
981
|
imagePaths: ImageIntake.keepForPaste(images), pasteOnly: kind == .paste)
|
|
982
982
|
|
|
983
|
+
let open = opens
|
|
983
984
|
let group = DispatchGroup()
|
|
984
985
|
var chiOutcome: SendOutcome?
|
|
985
986
|
var kittyOutcomes: [(KittyWindow, KittyError?)] = []
|
|
@@ -1004,6 +1005,9 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
1004
1005
|
// another try, unless the panel was closed or reopened meanwhile.
|
|
1005
1006
|
if ok { self.model.images.removeAll { images.contains($0) } }
|
|
1006
1007
|
ImageIntake.delete(images.filter { !self.model.images.contains($0) })
|
|
1008
|
+
// Reopened meanwhile (as for a broadcast): the new open keeps its
|
|
1009
|
+
// message and phase, and isn't closed by this send's success.
|
|
1010
|
+
guard self.opens == open else { return }
|
|
1007
1011
|
var parts: [String] = []
|
|
1008
1012
|
if let chiOutcome { parts.append(chiOutcome.message) }
|
|
1009
1013
|
if !pasted.isEmpty {
|
|
@@ -1014,7 +1018,7 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
1014
1018
|
if ok {
|
|
1015
1019
|
self.model.phase = .sent
|
|
1016
1020
|
DispatchQueue.main.asyncAfter(deadline: .now() + (chiOutcome?.linger ?? 1.0)) { [weak self] in
|
|
1017
|
-
if self?.model.phase == .sent { self?.panel.close() }
|
|
1021
|
+
if self?.opens == open, self?.model.phase == .sent { self?.panel.close() }
|
|
1018
1022
|
}
|
|
1019
1023
|
} else {
|
|
1020
1024
|
self.model.phase = .failed
|
|
@@ -1022,8 +1026,8 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
1022
1026
|
}
|
|
1023
1027
|
}
|
|
1024
1028
|
|
|
1025
|
-
/// Counts the panel's opens: a broadcast that ends after a
|
|
1026
|
-
/// the new open alone.
|
|
1029
|
+
/// Counts the panel's opens: a send or broadcast that ends after a
|
|
1030
|
+
/// reopen leaves the new open alone.
|
|
1027
1031
|
private var opens = 0
|
|
1028
1032
|
|
|
1029
1033
|
/// The Everyone row: `chi broadcast` with the note (the source field is
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -950,7 +950,8 @@ module Samagotchi
|
|
|
950
950
|
snapshot = @metrics.snapshot
|
|
951
951
|
target = @host_registry.resolve(@effective_model_name)
|
|
952
952
|
served, served_for = served_model_for(snapshot, target: target)
|
|
953
|
-
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,
|
|
954
955
|
prompt_notes: prompt_notes.map(&:to_h))
|
|
955
956
|
unless snapshot.dig(:context, :window_tokens)
|
|
956
957
|
window = current_context_window(target)
|
|
@@ -1045,6 +1046,19 @@ module Samagotchi
|
|
|
1045
1046
|
served_model_for(@metrics.served_report, target: probe ? @host_registry.resolve(@effective_model_name) : nil)
|
|
1046
1047
|
end
|
|
1047
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
|
+
|
|
1048
1062
|
# Read-only snapshot of the engine's view of the current session plus the
|
|
1049
1063
|
# live event sequence. Cheap primitive used by the bridge's reconnect-too-
|
|
1050
1064
|
# old reset marker and the GET /session/:id/state read surface. Orthogonal
|
|
@@ -1065,6 +1079,8 @@ module Samagotchi
|
|
|
1065
1079
|
# :served_model, :served_model_for [String, nil] what a generation of
|
|
1066
1080
|
# that model reported serving, and the name asked (#served_model
|
|
1067
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)
|
|
1068
1084
|
# :recap_enabled [Boolean] whether an idle recap is configured, with
|
|
1069
1085
|
# :recap_min_user_turns and :recap_inactivity_seconds (nil when not)
|
|
1070
1086
|
def session_state_snapshot
|
|
@@ -1085,6 +1101,7 @@ module Samagotchi
|
|
|
1085
1101
|
model_name: effective_model_ref,
|
|
1086
1102
|
served_model: served_pair[0],
|
|
1087
1103
|
served_model_for: served_pair[1],
|
|
1104
|
+
served_expected_by: served_expected_by(served_pair[0]),
|
|
1088
1105
|
context_status: @last_context_status&.dup || saved_context_status(metrics[:context]),
|
|
1089
1106
|
llm_context: llm_context_summary,
|
|
1090
1107
|
recap_enabled: !@recap.nil?,
|
|
@@ -1824,7 +1841,7 @@ module Samagotchi
|
|
|
1824
1841
|
@turn_thinking = [thinking_level(thinking_target), thinking_target]
|
|
1825
1842
|
announce_thinking_level(*@turn_thinking)
|
|
1826
1843
|
LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking: @turn_thinking.first, model_name: nil,
|
|
1827
|
-
window_setting: nil, llm_context: nil)
|
|
1844
|
+
window_setting: nil, llm_context: nil, price: nil)
|
|
1828
1845
|
end
|
|
1829
1846
|
private :turn_settings
|
|
1830
1847
|
|
|
@@ -1918,7 +1935,7 @@ module Samagotchi
|
|
|
1918
1935
|
# turn's model, not the last native one.
|
|
1919
1936
|
explained = llm_context_explained
|
|
1920
1937
|
@kernel.turn_settings = turn.settings.with(model_name: bare_for_backend, window_setting: turn_window_setting,
|
|
1921
|
-
llm_context: explained&.resolved)
|
|
1938
|
+
llm_context: explained&.resolved, price: turn_price)
|
|
1922
1939
|
log_llm_context(explained)
|
|
1923
1940
|
|
|
1924
1941
|
turn.limit = @no_interrupt ? IterationLimit.for(no_interrupt: true) : max_iterations || IterationLimit.for
|
|
@@ -2549,6 +2566,12 @@ module Samagotchi
|
|
|
2549
2566
|
when :generation_started
|
|
2550
2567
|
@generation_thought = nil
|
|
2551
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
|
|
2552
2575
|
when :generation_chunk then note_generation_thought(progress[:thinking])
|
|
2553
2576
|
end
|
|
2554
2577
|
# The chat loop asked again without the thinking fields: a notice,
|
|
@@ -2566,13 +2589,14 @@ module Samagotchi
|
|
|
2566
2589
|
end
|
|
2567
2590
|
|
|
2568
2591
|
# A :generation_completed with the generation's decode speed (`speed:`
|
|
2569
|
-
# {decode_tps:, source:}, nil without one)
|
|
2570
|
-
# 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
|
|
2571
2595
|
# reads them from the stream. SessionMetrics closes the generation here,
|
|
2572
2596
|
# so the event it gets next finds it closed.
|
|
2573
2597
|
def with_generation_report(event)
|
|
2574
2598
|
report = @metrics.finish_generation
|
|
2575
|
-
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]))
|
|
2576
2600
|
rescue StandardError
|
|
2577
2601
|
event
|
|
2578
2602
|
end
|
|
@@ -2638,6 +2662,15 @@ module Samagotchi
|
|
|
2638
2662
|
nil
|
|
2639
2663
|
end
|
|
2640
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
|
+
|
|
2641
2674
|
# The LLM context strategy the next turn runs under: the session's own
|
|
2642
2675
|
# (Session#llm_context), else the effective model's (models:/hosts:
|
|
2643
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
|
|
|
@@ -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
|