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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +120 -1
  3. data/docs/architecture.md +2 -1
  4. data/docs/cli.md +43 -12
  5. data/docs/configuration.md +102 -5
  6. data/docs/context.md +2 -1
  7. data/docs/internals/background-tasks.md +1 -0
  8. data/docs/internals/engine.md +1 -1
  9. data/docs/internals/llm-context-live.md +111 -0
  10. data/docs/plugins.md +4 -2
  11. data/docs/sessions.md +9 -5
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +19 -0
  13. data/lib/samagotchi/bridge.rb +22 -10
  14. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  15. data/lib/samagotchi/bundles/github-pr/plugin.rb +22 -4
  16. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  18. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  19. data/lib/samagotchi/commands/registry.rb +54 -9
  20. data/lib/samagotchi/config.rb +57 -4
  21. data/lib/samagotchi/desktop/macos/Panel.swift +7 -3
  22. data/lib/samagotchi/engine.rb +47 -9
  23. data/lib/samagotchi/host_model.rb +116 -0
  24. data/lib/samagotchi/host_registry.rb +43 -12
  25. data/lib/samagotchi/kernel_loop.rb +2 -1
  26. data/lib/samagotchi/llm/chat_loop.rb +89 -7
  27. data/lib/samagotchi/llm/turn_settings.rb +5 -3
  28. data/lib/samagotchi/llm_context_strategy.rb +37 -0
  29. data/lib/samagotchi/model_catalog.rb +17 -12
  30. data/lib/samagotchi/model_notes.rb +8 -6
  31. data/lib/samagotchi/model_overlay.rb +3 -2
  32. data/lib/samagotchi/model_price.rb +54 -0
  33. data/lib/samagotchi/model_profile.rb +74 -3
  34. data/lib/samagotchi/plugin/context.rb +19 -7
  35. data/lib/samagotchi/process_group.rb +56 -9
  36. data/lib/samagotchi/recap_setup.rb +1 -1
  37. data/lib/samagotchi/self_report.rb +33 -3
  38. data/lib/samagotchi/session_commands.rb +47 -21
  39. data/lib/samagotchi/session_manager.rb +19 -1
  40. data/lib/samagotchi/session_metrics.rb +79 -11
  41. data/lib/samagotchi/sessions_command.rb +15 -5
  42. data/lib/samagotchi/terminal_ui/attached_loop.rb +85 -3
  43. data/lib/samagotchi/terminal_ui/attached_view.rb +2 -0
  44. data/lib/samagotchi/terminal_ui/formatting.rb +45 -10
  45. data/lib/samagotchi/terminal_ui/status_row.rb +12 -6
  46. data/lib/samagotchi/terminal_ui.rb +97 -12
  47. data/lib/samagotchi/tools/delegate_wait.rb +5 -4
  48. data/lib/samagotchi/tools/task_runtime.rb +11 -2
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +12 -11
  51. data/lib/samagotchi/web/public/app.js +30 -18
  52. data/lib/samagotchi/web/public/command_complete.js +3 -1
  53. data/lib/samagotchi/web/public/ctx.js +62 -22
  54. data/lib/samagotchi/web/public/format.js +7 -2
  55. data/lib/samagotchi/web/public/index.html +3 -1
  56. data/lib/samagotchi/web/public/llm_context_chip.js +2 -4
  57. data/lib/samagotchi/web/public/model_pick.js +23 -3
  58. data/lib/samagotchi/web/public/model_picker.js +5 -3
  59. data/lib/samagotchi/web/public/stage_model.js +18 -4
  60. data/lib/samagotchi/web/public/stage_view.js +4 -3
  61. data/lib/samagotchi/web/public/turn_events.js +19 -6
  62. data/lib/samagotchi/web/public/turn_model.js +16 -0
  63. data/lib/samagotchi/web/public/turn_view.js +19 -0
  64. data/lib/samagotchi/web/session_hub.rb +9 -1
  65. data/lib/samagotchi/web/session_summary.rb +19 -2
  66. data/lib/samagotchi/worker.rb +172 -21
  67. data/lib/samagotchi/worker_inbound.rb +6 -5
  68. metadata +4 -1
@@ -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; without a session, the
485
- # notes the effective model's prompt would load.
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 @session.prompt_notes.dup if @session
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, 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,
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) and the session's running
2565
- # 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
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, 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
 
@@ -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
- # On disk too: a process that spawns a worker without listing
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
- 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
@@ -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 `--model` takes for
11
- # it (see .spell).
12
- Row = Data.define(:host, :id, :ref)
13
- # rows: the default host's first, then the others by name; warnings:
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
- data = results[host]
28
- if data[:error]
29
- warnings << "#{host}: #{data[:error]}"
32
+ if results[host][:error]
33
+ warnings << "#{host}: #{results[host][:error]}"
30
34
  next
31
35
  end
32
- Array(data[:models]).each do |info|
33
- id = info.id.to_s
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