samagotchi 0.45.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -1
  3. data/docs/architecture.md +2 -1
  4. data/docs/cli.md +14 -5
  5. data/docs/configuration.md +102 -5
  6. data/docs/internals/background-tasks.md +1 -0
  7. data/docs/internals/engine.md +1 -1
  8. data/docs/internals/llm-context-live.md +111 -0
  9. data/docs/sessions.md +2 -2
  10. data/lib/samagotchi/bridge/turn_accumulator.rb +5 -0
  11. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  12. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  13. data/lib/samagotchi/config.rb +57 -4
  14. data/lib/samagotchi/desktop/macos/Panel.swift +7 -3
  15. data/lib/samagotchi/engine.rb +39 -6
  16. data/lib/samagotchi/host_model.rb +116 -0
  17. data/lib/samagotchi/host_registry.rb +15 -9
  18. data/lib/samagotchi/kernel_loop.rb +2 -1
  19. data/lib/samagotchi/llm/chat_loop.rb +89 -7
  20. data/lib/samagotchi/llm/turn_settings.rb +5 -3
  21. data/lib/samagotchi/model_catalog.rb +17 -12
  22. data/lib/samagotchi/model_price.rb +54 -0
  23. data/lib/samagotchi/model_profile.rb +3 -1
  24. data/lib/samagotchi/process_group.rb +56 -9
  25. data/lib/samagotchi/recap_setup.rb +1 -1
  26. data/lib/samagotchi/self_report.rb +2 -1
  27. data/lib/samagotchi/session_commands.rb +23 -14
  28. data/lib/samagotchi/session_metrics.rb +40 -5
  29. data/lib/samagotchi/terminal_ui/attached_view.rb +2 -0
  30. data/lib/samagotchi/terminal_ui/formatting.rb +26 -9
  31. data/lib/samagotchi/terminal_ui/status_row.rb +12 -6
  32. data/lib/samagotchi/terminal_ui.rb +13 -0
  33. data/lib/samagotchi/tools/delegate_wait.rb +5 -4
  34. data/lib/samagotchi/tools/task_runtime.rb +11 -2
  35. data/lib/samagotchi/version.rb +1 -1
  36. data/lib/samagotchi/web/app.rb +5 -2
  37. data/lib/samagotchi/web/public/app.js +7 -3
  38. data/lib/samagotchi/web/public/ctx.js +20 -7
  39. data/lib/samagotchi/web/public/format.js +7 -2
  40. data/lib/samagotchi/web/public/model_pick.js +23 -3
  41. data/lib/samagotchi/web/public/model_picker.js +5 -3
  42. data/lib/samagotchi/web/public/stage_view.js +1 -0
  43. data/lib/samagotchi/web/public/turn_events.js +3 -2
  44. data/lib/samagotchi/web/public/turn_model.js +16 -0
  45. data/lib/samagotchi/web/public/turn_view.js +19 -0
  46. data/lib/samagotchi/worker.rb +16 -1
  47. metadata +4 -1
@@ -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]).compact
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 reopen leaves
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
@@ -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, llm_context: llm_context_summary,
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) and the session's running
2570
- # token totals (`tokens:`, the snapshot's block): the web's info bar
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, keyword_init: true) do
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, client: Client.new(host: host, port: port, name: "default"))
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 default host when its cached list
199
- # has it, else to the first host in hosts: order whose list has it,
200
- # else to the default host (before /models nothing is listed: no
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
- listed = @mutex.synchronize { @model_index }&.fetch(ref.id.to_s.strip.downcase, nil) || []
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 = listed.include?(default.name) ? default.name : listed.first
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
- def initialize(kernel:, adapter: nil, stable_length: nil)
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
- response = with_generation do |generation_controller|
510
+ generation_controller = nil
511
+ response = with_generation do |controller|
512
+ generation_controller = controller
469
513
  begin
470
- request(iteration, retry_generation, streamed, thought, generation_controller)
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
- request(iteration, retry_generation, streamed, thought, generation_controller)
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
- event[:tool_call] = true if OpenAIChat.tool_call_delta?(payload)
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). The Engine sets them once
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