samagotchi 0.45.0 → 0.46.1
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 +92 -1
- data/docs/architecture.md +2 -1
- data/docs/cli.md +22 -7
- data/docs/configuration.md +105 -6
- data/docs/desktop.md +2 -1
- data/docs/internals/background-tasks.md +1 -0
- data/docs/internals/engine.md +1 -1
- data/docs/internals/llm-context-live.md +111 -0
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/bridge/turn_accumulator.rb +5 -0
- data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/plugin.rb +26 -5
- 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 +61 -4
- data/lib/samagotchi/desktop/macos/Panel.swift +7 -3
- data/lib/samagotchi/engine.rb +41 -9
- data/lib/samagotchi/host_model.rb +123 -0
- data/lib/samagotchi/host_registry.rb +21 -10
- data/lib/samagotchi/kernel_loop.rb +2 -1
- data/lib/samagotchi/llm/chat_loop.rb +109 -7
- data/lib/samagotchi/llm/errors.rb +8 -2
- data/lib/samagotchi/llm/turn_settings.rb +5 -3
- data/lib/samagotchi/llm_context_strategy.rb +1 -0
- data/lib/samagotchi/model_catalog.rb +22 -16
- data/lib/samagotchi/model_price.rb +54 -0
- data/lib/samagotchi/model_profile.rb +7 -4
- data/lib/samagotchi/models_command.rb +12 -5
- data/lib/samagotchi/process_group.rb +56 -9
- data/lib/samagotchi/question_desk.rb +29 -4
- data/lib/samagotchi/recap_setup.rb +1 -1
- data/lib/samagotchi/self_report.rb +2 -1
- data/lib/samagotchi/served_model.rb +9 -0
- data/lib/samagotchi/session_commands.rb +27 -17
- 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 +47 -15
- data/lib/samagotchi/terminal_ui/status_row.rb +12 -6
- data/lib/samagotchi/terminal_ui.rb +15 -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 +43 -6
- 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/index.html +3 -0
- data/lib/samagotchi/web/public/model_pick.js +36 -9
- data/lib/samagotchi/web/public/model_picker.js +11 -6
- data/lib/samagotchi/web/public/question_card.js +8 -0
- data/lib/samagotchi/web/public/stage_view.js +1 -0
- data/lib/samagotchi/web/public/turn_events.js +10 -5
- 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 +20 -1
- metadata +4 -1
|
@@ -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,25 @@ 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
|
|
513
|
+
# One count for the step: the ask-again without thinking fields
|
|
514
|
+
# doesn't get drops of its own.
|
|
515
|
+
drops = DropCount.new(0, 0)
|
|
469
516
|
begin
|
|
470
|
-
|
|
517
|
+
request_with_drops(iteration, retry_generation, streamed, thought, drops, controller)
|
|
471
518
|
rescue BadRequest => e
|
|
472
519
|
raise unless thinking_refused?(e)
|
|
473
520
|
|
|
474
521
|
# Once per model: asked again without the thinking fields.
|
|
475
522
|
@loop.thinking_refused!(@model_name)
|
|
476
523
|
emit(type: :thinking_refused, iteration: iteration, model: @model_name, level: @loop.thinking, detail: e.detail)
|
|
477
|
-
|
|
524
|
+
request_with_drops(iteration, retry_generation, streamed, thought, drops, controller)
|
|
478
525
|
end
|
|
479
526
|
rescue RequestCancelled
|
|
480
527
|
raise unless generation_controller&.cancelled? && !@cancel_controller.cancelled?
|
|
@@ -522,7 +569,7 @@ module Samagotchi
|
|
|
522
569
|
[response, nil]
|
|
523
570
|
end
|
|
524
571
|
|
|
525
|
-
def request(iteration, retry_generation, streamed, thought, generation_controller)
|
|
572
|
+
def request(iteration, retry_generation, streamed, thought, generation_controller, streamed_any = nil)
|
|
526
573
|
@loop.adapter.chat(
|
|
527
574
|
messages: @loop.wire_messages(@conversation), tools: @loop.tool_definitions, model: @model_name,
|
|
528
575
|
cancel_controller: generation_controller || @cancel_controller, session_id: @loop.session_id,
|
|
@@ -533,13 +580,68 @@ module Samagotchi
|
|
|
533
580
|
event = { type: :generation_chunk, iteration: iteration, content: reasoning + content, text: content,
|
|
534
581
|
thinking: reasoning, payload: payload }
|
|
535
582
|
# A tool call streaming: a steer doesn't cut it (Engine#cut_for_steer).
|
|
536
|
-
|
|
583
|
+
tool_call = OpenAIChat.tool_call_delta?(payload)
|
|
584
|
+
event[:tool_call] = true if tool_call
|
|
585
|
+
streamed_any&.call if !content.to_s.empty? || !reasoning.to_s.empty? || tool_call
|
|
537
586
|
emit(event)
|
|
538
587
|
},
|
|
539
588
|
on_retry: ->(**retry_event) { emit({ type: :generation_retrying, iteration: iteration }.merge(retry_event)) }
|
|
540
589
|
)
|
|
541
590
|
end
|
|
542
591
|
|
|
592
|
+
# A dropped stream (LLM::HTTP raises RetryExhausted once a stream
|
|
593
|
+
# that already showed something fails, where a retry would repeat
|
|
594
|
+
# output) is asked again here, from the same conversation, up to
|
|
595
|
+
# MAX_STREAM_DROPS times per step (fewer under a smaller retry.max;
|
|
596
|
+
# none with 0), after the transport's backoff: the partial reply is
|
|
597
|
+
# dropped and the step restarts. A refusal or a first-token timeout
|
|
598
|
+
# fails as before, and a cancel (before or during the wait) never
|
|
599
|
+
# retries.
|
|
600
|
+
MAX_STREAM_DROPS = 2
|
|
601
|
+
|
|
602
|
+
# A step's drops asked again (+drops+) and the HTTP requests made so
|
|
603
|
+
# far (+requests+: each failed request's transport attempts), so the
|
|
604
|
+
# error that ends the step counts every request, not the last one's.
|
|
605
|
+
DropCount = Struct.new(:drops, :requests)
|
|
606
|
+
|
|
607
|
+
# Runs one request, asking again on a mid-stream drop; +count+ (a
|
|
608
|
+
# DropCount) carries on across calls for one step.
|
|
609
|
+
def request_with_drops(iteration, retry_generation, streamed, thought, count, generation_controller)
|
|
610
|
+
streamed_any = false
|
|
611
|
+
request(iteration, retry_generation, streamed, thought, generation_controller, -> { streamed_any = true })
|
|
612
|
+
rescue RetryExhausted => e
|
|
613
|
+
# A cancel that landed as the stream dropped ends the turn canceled,
|
|
614
|
+
# not failed, whatever the drop's count.
|
|
615
|
+
cancelled = [@cancel_controller, generation_controller].compact.find(&:cancelled?)
|
|
616
|
+
raise RequestCancelled, cancelled.reason if cancelled
|
|
617
|
+
|
|
618
|
+
count.requests += e.attempts.to_i
|
|
619
|
+
limit = @loop.stream_drop_limit(MAX_STREAM_DROPS)
|
|
620
|
+
if count.drops >= limit || !streamed_any
|
|
621
|
+
raise if count.requests == e.attempts.to_i
|
|
622
|
+
|
|
623
|
+
raise RetryExhausted.new(attempts: count.requests, last_error: e.last_error, label: e.host)
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
count.drops += 1
|
|
627
|
+
attempt = count.drops
|
|
628
|
+
streamed.clear
|
|
629
|
+
thought.clear
|
|
630
|
+
delay = @loop.generation_retry_delay(attempt)
|
|
631
|
+
# restarted: the step's streamed chunks so far are void (the UIs
|
|
632
|
+
# and the Bridge's TurnAccumulator drop them).
|
|
633
|
+
emit(type: :generation_retrying, iteration: iteration, attempt: attempt, max_retries: limit,
|
|
634
|
+
next_delay: delay, error_class: e.class.name, error_message: e.message, status: nil, restarted: true)
|
|
635
|
+
@loop.wait_retry(delay, @cancel_controller, generation_controller)
|
|
636
|
+
retry
|
|
637
|
+
rescue ProviderError => e
|
|
638
|
+
# Another failure (a 503, a refusal) after earlier requests this
|
|
639
|
+
# step: its count takes theirs in.
|
|
640
|
+
count.requests += e.attempts.to_i
|
|
641
|
+
e.attempts = count.requests
|
|
642
|
+
raise
|
|
643
|
+
end
|
|
644
|
+
|
|
543
645
|
# A 400 about reasoning, for a request that carried thinking fields
|
|
544
646
|
# (not a missing-tools, image or context error).
|
|
545
647
|
def thinking_refused?(error)
|
|
@@ -223,11 +223,17 @@ module Samagotchi
|
|
|
223
223
|
|
|
224
224
|
def initialize(attempts:, last_error:, label: "llama.cpp")
|
|
225
225
|
@last_error = last_error
|
|
226
|
-
super("#{label} request failed after #{attempts}
|
|
226
|
+
super("#{label} request failed after #{self.class.count(attempts)}: #{last_error.class}: #{last_error.message}",
|
|
227
227
|
host: label, retryable: false, attempts: attempts)
|
|
228
228
|
end
|
|
229
229
|
|
|
230
|
-
def summary = "network error after #{attempts}
|
|
230
|
+
def summary = "network error after #{self.class.count(attempts)} (host #{host}: #{last_error.class})"
|
|
231
|
+
|
|
232
|
+
# The message without its leading "host ": "request failed after …".
|
|
233
|
+
def detail = host ? message.delete_prefix("#{host} ") : message
|
|
234
|
+
|
|
235
|
+
# "1 attempt", "3 attempts".
|
|
236
|
+
def self.count(attempts) = "#{attempts} attempt#{"s" unless attempts == 1}"
|
|
231
237
|
end
|
|
232
238
|
|
|
233
239
|
# A refused connection: nothing listens at the host's address. Not
|
|
@@ -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
|
|
@@ -7,33 +7,36 @@ module Samagotchi
|
|
|
7
7
|
# picks from: GET /api/models and `chi models`. Every name question goes
|
|
8
8
|
# to ModelRef / HostRegistry#resolve; nothing here parses a ref.
|
|
9
9
|
module ModelCatalog
|
|
10
|
-
# host: the host that listed the id; ref: the name
|
|
11
|
-
# it (see .spell)
|
|
12
|
-
|
|
13
|
-
#
|
|
14
|
-
|
|
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); unavailable: a declared id of a
|
|
13
|
+
# host that failed to list its models (still pickable).
|
|
14
|
+
Row = Data.define(:host, :id, :ref, :configured, :unavailable) do
|
|
15
|
+
def initialize(host:, id:, ref:, configured: false, unavailable: false) = super
|
|
16
|
+
end
|
|
17
|
+
# rows: the default host's first, then the others by name, each host's
|
|
18
|
+
# declared ids first (HostModel.rows; a host that failed keeps only
|
|
19
|
+
# those); warnings: "host: error" per host that failed.
|
|
15
20
|
Listing = Data.define(:rows, :warnings)
|
|
16
21
|
|
|
17
22
|
module_function
|
|
18
23
|
|
|
19
24
|
# @param results [Hash] list_all_models' answer
|
|
20
|
-
# @param registry [#default_entry]
|
|
25
|
+
# @param registry [#default_entry, #entries]
|
|
21
26
|
# @return [Listing]
|
|
22
27
|
def listing(results, registry:)
|
|
23
28
|
default_host = registry.default_entry&.name
|
|
24
29
|
rows = []
|
|
25
30
|
warnings = []
|
|
31
|
+
host_rows = HostModel.rows(results, registry.entries)
|
|
26
32
|
results.keys.sort_by { |name| [name == default_host ? 0 : 1, name] }.each do |host|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
next
|
|
31
|
-
end
|
|
32
|
-
Array(data[:models]).each do |info|
|
|
33
|
-
id = info.id.to_s
|
|
34
|
-
next if id.strip.empty? || id.end_with?(":batch")
|
|
33
|
+
warnings << "#{host}: #{results[host][:error]}" if results[host][:error]
|
|
34
|
+
host_rows.fetch(host, []).each do |row|
|
|
35
|
+
id = row.id
|
|
36
|
+
next if id.strip.empty? || (!row.configured && id.end_with?(":batch"))
|
|
35
37
|
|
|
36
|
-
rows << Row.new(host: host, id: id, ref: spell(host, id, default_host)
|
|
38
|
+
rows << Row.new(host: host, id: id, ref: spell(host, id, default_host), configured: row.configured,
|
|
39
|
+
unavailable: row.unavailable)
|
|
37
40
|
end
|
|
38
41
|
end
|
|
39
42
|
Listing.new(rows: rows, warnings: warnings)
|
|
@@ -51,13 +54,16 @@ module Samagotchi
|
|
|
51
54
|
# go first (its rows are the "default host" group). default: the default's listed
|
|
52
55
|
# row name when it resolves to one, else its resolved ref; default_typed:
|
|
53
56
|
# the alias it was typed as. A row whose name resolves elsewhere (an
|
|
54
|
-
# alias named like the id shadows it) carries shadowed_by
|
|
57
|
+
# alias named like the id shadows it) carries shadowed_by; a declared id
|
|
58
|
+
# of a host that failed to list carries unavailable.
|
|
55
59
|
# @param default_name [String, nil] default.model as configured
|
|
56
60
|
# @return [Hash]
|
|
57
61
|
def payload(results, registry:, default_name:)
|
|
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
|
|
66
|
+
hash[:unavailable] = true if row.unavailable
|
|
61
67
|
shadow = shadowed_by(row, registry)
|
|
62
68
|
shadow ? hash.merge(shadowed_by: shadow) : hash
|
|
63
69
|
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# A model's price on a host (hosts.<name>.models.<id>.price), in USD per
|
|
5
|
+
# 1M tokens: what a generation's cost is estimated from when the provider
|
|
6
|
+
# reports none (SessionMetrics, cost_estimate). An unset cache rate is the
|
|
7
|
+
# input rate: that overstates cached reads, and understates Anthropic
|
|
8
|
+
# cache writes (billed 1.25x input for the 5 min cache, 2x for 1 h).
|
|
9
|
+
ModelPrice = Data.define(:input, :cache_read, :cache_write, :output) do
|
|
10
|
+
# @param raw [Hash, nil] the price as written
|
|
11
|
+
# @param where [String] for the warning
|
|
12
|
+
# @return [ModelPrice, nil] nil when unset or invalid (warned once)
|
|
13
|
+
def self.parse(raw, where)
|
|
14
|
+
return nil if raw.nil?
|
|
15
|
+
|
|
16
|
+
values = raw.is_a?(Hash) ? raw.transform_keys(&:to_s).slice(*ModelPrice::KEYS) : nil
|
|
17
|
+
valid = values && values.key?("input") && values.key?("output") &&
|
|
18
|
+
# YAML's .inf and .nan are Floats too: JSON can't carry them
|
|
19
|
+
# (hosts_json_for_env would drop every host) and NaN poisons the sums.
|
|
20
|
+
values.values.all? { |v| v.is_a?(Numeric) && v.finite? && !v.negative? }
|
|
21
|
+
unless valid
|
|
22
|
+
warn_once "Warning: #{where}.price needs input and output, each a number >= 0 (USD per 1M tokens); ignored"
|
|
23
|
+
return nil
|
|
24
|
+
end
|
|
25
|
+
new(input: values["input"], cache_read: values.fetch("cache_read", values["input"]),
|
|
26
|
+
cache_write: values.fetch("cache_write", values["input"]), output: values["output"])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# config.rb requires model_price through host_model, so config is
|
|
30
|
+
# required here only when a warning needs it.
|
|
31
|
+
def self.warn_once(message)
|
|
32
|
+
require_relative "config"
|
|
33
|
+
ConfigFile.warn_once(message)
|
|
34
|
+
end
|
|
35
|
+
private_class_method :warn_once
|
|
36
|
+
|
|
37
|
+
# USD for one generation, OpenAI usage semantics: +prompt_tokens+ holds
|
|
38
|
+
# the cached reads and the cache writes (OpenRouter keeps that for
|
|
39
|
+
# Anthropic models), and reasoning is inside +completion_tokens+.
|
|
40
|
+
# llama.cpp's native timings.prompt_n leaves its cache_n out, so the
|
|
41
|
+
# uncached part is clamped at 0: an undercount, harmless because priced
|
|
42
|
+
# models are remote.
|
|
43
|
+
def cost(prompt_tokens:, cached_tokens:, cache_write_tokens:, completion_tokens:)
|
|
44
|
+
uncached = (prompt_tokens - cached_tokens - cache_write_tokens).clamp(0..)
|
|
45
|
+
((uncached * input) + (cached_tokens * cache_read) + (cache_write_tokens * cache_write) +
|
|
46
|
+
(completion_tokens * output)) / 1e6
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def to_config = to_h.transform_keys(&:to_s)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The keys a price may hold (config validation checks them too).
|
|
53
|
+
ModelPrice::KEYS = %w[input cache_read cache_write output].freeze
|
|
54
|
+
end
|
|
@@ -190,7 +190,7 @@ module Samagotchi
|
|
|
190
190
|
#
|
|
191
191
|
# Only a ref that names a host is checked (its alias resolved first, as
|
|
192
192
|
# ModelRef.parse does), and only against that host's own list: a bare id
|
|
193
|
-
# goes to whichever host lists it (HostRegistry#host_for_model), so no
|
|
193
|
+
# goes to whichever host lists or declares it (HostRegistry#host_for_model), so no
|
|
194
194
|
# single list can judge it. Nothing is checked either without a saved
|
|
195
195
|
# list for the host or with one older than ModelListStore::TTL_SECONDS
|
|
196
196
|
# (a week: the host may serve different models by now).
|
|
@@ -219,6 +219,8 @@ module Samagotchi
|
|
|
219
219
|
parsed = Samagotchi::ConfigFile.model_ref(model_name, env: env, hosts: hosts)
|
|
220
220
|
host = parsed.host_name
|
|
221
221
|
return nil unless host
|
|
222
|
+
# Declared under hosts.<name>.models: served whatever the host lists.
|
|
223
|
+
return nil if hosts[host.to_s.strip.downcase]&.dig(:models)&.key?(parsed.id.to_s.strip.downcase)
|
|
222
224
|
|
|
223
225
|
list = lists.read(env: env)[host.to_s.strip.downcase]
|
|
224
226
|
return nil if list.nil? || list.stale? || list.known?(parsed.id)
|
|
@@ -230,10 +232,11 @@ module Samagotchi
|
|
|
230
232
|
return unknown_model_message(parsed.id, host, list.ids) if list.age < RELIST_AFTER_SECONDS
|
|
231
233
|
|
|
232
234
|
fresh_ids = relist_or_nil(relist || default_relist(hosts), host, env)
|
|
233
|
-
# A re-list that answered: the saved list catches up
|
|
234
|
-
# re-list, HostRegistry#list_models, saved it already
|
|
235
|
+
# A re-list that answered: the saved list catches up. The default
|
|
236
|
+
# re-list, HostRegistry#list_models, saved it already; a given one
|
|
237
|
+
# is saved here.
|
|
235
238
|
if fresh_ids
|
|
236
|
-
Samagotchi::ModelListStore.save(host, fresh_ids, env: env)
|
|
239
|
+
Samagotchi::ModelListStore.save(host, fresh_ids, env: env) if relist
|
|
237
240
|
return nil if fresh_ids.any? { |id| id.to_s.casecmp?(parsed.id.to_s.strip) }
|
|
238
241
|
|
|
239
242
|
return unknown_model_message(parsed.id, host, fresh_ids)
|
|
@@ -23,13 +23,14 @@ module Samagotchi
|
|
|
23
23
|
Lists the models every configured host offers, as the names
|
|
24
24
|
--model takes: the default first, a default-host id bare, another
|
|
25
25
|
host's as host:id, then the aliases as "name -> ref". A host that
|
|
26
|
-
fails or doesn't answer in time is noted on stderr
|
|
26
|
+
fails or doesn't answer in time is noted on stderr; the ids
|
|
27
|
+
declared under its hosts.<name>.models are still listed.
|
|
27
28
|
TEXT only the names containing it (any case)
|
|
28
29
|
--format json the default, the models (host, id, name), the aliases
|
|
29
30
|
and the warnings as one JSON object
|
|
30
31
|
--timeout S wait at most S seconds for the hosts; default 4
|
|
31
32
|
Exit 0 when any host listed its models, 1 when none did (the
|
|
32
|
-
default
|
|
33
|
+
default and declared names still print), 2 on a usage error.
|
|
33
34
|
TEXT
|
|
34
35
|
|
|
35
36
|
FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS) do |f|
|
|
@@ -59,8 +60,10 @@ module Samagotchi
|
|
|
59
60
|
return usage_error("--timeout takes a number of seconds above 0") unless timeout&.positive?
|
|
60
61
|
|
|
61
62
|
payload, listed = catalog(timeout)
|
|
63
|
+
# Before the filter: a down host's declared ids are listed even when TEXT hides them.
|
|
64
|
+
down_hosts = payload[:models].select { |m| m[:unavailable] }.map { |m| m[:host] }.uniq
|
|
62
65
|
payload = filtered(payload, parsed.args.join(" ").strip)
|
|
63
|
-
options[:format] == "json" ? print_json(payload) : print_text(payload)
|
|
66
|
+
options[:format] == "json" ? print_json(payload) : print_text(payload, down_hosts)
|
|
64
67
|
listed ? 0 : 1
|
|
65
68
|
end
|
|
66
69
|
|
|
@@ -99,14 +102,18 @@ module Samagotchi
|
|
|
99
102
|
@stdout.puts(JSON.generate(payload.except(:filter)))
|
|
100
103
|
end
|
|
101
104
|
|
|
102
|
-
|
|
105
|
+
# @param down_hosts [Array<String>] hosts that failed but declare ids
|
|
106
|
+
def print_text(payload, down_hosts)
|
|
103
107
|
default = payload[:default]
|
|
104
108
|
names = payload[:models].reject { |m| m[:shadowed_by] }.map { |m| m[:name] }
|
|
105
109
|
shown_default = default && (payload[:filter].nil? || default.downcase.include?(payload[:filter].downcase))
|
|
106
110
|
@stdout.puts(default) if shown_default
|
|
107
111
|
(names - [default]).each { |name| @stdout.puts(name) }
|
|
108
112
|
payload[:aliases].each { |a| @stdout.puts("#{a[:name]} -> #{a[:ref]}") }
|
|
109
|
-
payload[:warnings].each
|
|
113
|
+
payload[:warnings].each do |w|
|
|
114
|
+
kept = down_hosts.any? { |host| w.start_with?("#{host}: ") }
|
|
115
|
+
@stderr.puts("chi models: #{w}#{" (its declared ids are still listed)" if kept}")
|
|
116
|
+
end
|
|
110
117
|
end
|
|
111
118
|
end
|
|
112
119
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
3
5
|
module Samagotchi
|
|
4
6
|
# A command run in a process group of its own, so a stop reaches what it
|
|
5
7
|
# started too: TERM the group, a grace, then KILL it. Execute, TaskRuntime
|
|
@@ -51,12 +53,44 @@ module Samagotchi
|
|
|
51
53
|
|
|
52
54
|
# Whether +pid+ runs and leads its own group, as #spawn's process does
|
|
53
55
|
# (not, then, a reused pid in some other group).
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
# @param started [String, nil] the start time #start_time read when chi
|
|
57
|
+
# spawned it: a process that started at another time is one that
|
|
58
|
+
# reused the pid, even as a group leader. nil checks the group only (a
|
|
59
|
+
# record from an older chi), and so does a time that can't be read
|
|
60
|
+
# now (a ps that failed once must not disown a live task).
|
|
61
|
+
# @param start_time [#call] (pid) the reader, #start_time by default
|
|
62
|
+
def leader?(pid, started: nil, start_time: method(:start_time))
|
|
63
|
+
return false unless signalable?(pid) && Process.getpgid(pid) == pid
|
|
64
|
+
return true if started.nil?
|
|
65
|
+
|
|
66
|
+
now = start_time.call(pid)
|
|
67
|
+
now.nil? || now == started
|
|
56
68
|
rescue Errno::ESRCH, Errno::EPERM
|
|
57
69
|
false
|
|
58
70
|
end
|
|
59
71
|
|
|
72
|
+
# When +pid+'s process started, as an opaque string to compare: the
|
|
73
|
+
# start in clock ticks from /proc/PID/stat on Linux, ps's lstart (to the
|
|
74
|
+
# second, in UTC) elsewhere. nil when it isn't running or can't be read.
|
|
75
|
+
# @return [String, nil]
|
|
76
|
+
def start_time(pid)
|
|
77
|
+
return nil unless signalable?(pid)
|
|
78
|
+
|
|
79
|
+
stat = "/proc/#{pid}/stat"
|
|
80
|
+
return proc_start_time(File.read(stat)) if File.exist?(stat)
|
|
81
|
+
|
|
82
|
+
out, status = Open3.capture2({ "LC_ALL" => "C", "TZ" => "UTC" }, "ps", "-o", "lstart=", "-p", pid.to_s,
|
|
83
|
+
err: File::NULL)
|
|
84
|
+
time = out.strip
|
|
85
|
+
status.success? && !time.empty? ? time : nil
|
|
86
|
+
rescue SystemCallError, IOError
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Field 22 (starttime); the command name (field 2) may hold spaces and
|
|
91
|
+
# parentheses, so the fields are counted after its last ")".
|
|
92
|
+
def proc_start_time(stat) = stat[/\A.*\)\s(.*)\z/m, 1]&.split&.[](19)
|
|
93
|
+
|
|
60
94
|
# Whether +pid+ is still running (or a zombie not reaped yet).
|
|
61
95
|
# @raise [ArgumentError] unless #signalable?
|
|
62
96
|
def alive?(pid)
|
|
@@ -69,26 +103,39 @@ module Samagotchi
|
|
|
69
103
|
true
|
|
70
104
|
end
|
|
71
105
|
|
|
72
|
-
#
|
|
106
|
+
# How long #stop waits for +stopped+ after the KILL by default.
|
|
107
|
+
KILL_WAIT_SEC = 1.0
|
|
108
|
+
|
|
109
|
+
# TERMs the group, waits up to +grace+ for +stopped+, then KILLs it and
|
|
110
|
+
# waits up to +kill_wait+ for +stopped+ again (a KILL is delivered, not
|
|
111
|
+
# done, when kill returns).
|
|
73
112
|
# @param stopped [#call] true once it has stopped (default: the leader
|
|
74
113
|
# is gone)
|
|
75
114
|
# @param wait [#call] (seconds) between two checks (default: a sleep)
|
|
76
115
|
# @param signal_options [Hash] for #signal (leader:, quiet:)
|
|
77
116
|
# @return [Boolean] true when it stopped within the grace (no KILL sent)
|
|
78
117
|
# @raise [ArgumentError] unless #signalable?
|
|
79
|
-
def stop(pgid, grace:, poll:, stopped: -> { !alive?(pgid) }, wait: ->(seconds) { sleep(seconds) },
|
|
118
|
+
def stop(pgid, grace:, poll:, stopped: -> { !alive?(pgid) }, wait: ->(seconds) { sleep(seconds) },
|
|
119
|
+
kill_wait: KILL_WAIT_SEC, **signal_options)
|
|
80
120
|
check!(pgid)
|
|
81
121
|
signal(pgid, "TERM", **signal_options)
|
|
82
|
-
|
|
83
|
-
until (done = stopped.call) || monotonic >= deadline
|
|
84
|
-
wait.call(poll)
|
|
85
|
-
end
|
|
86
|
-
return true if done
|
|
122
|
+
return true if wait_for(stopped, grace, poll, wait)
|
|
87
123
|
|
|
88
124
|
signal(pgid, "KILL", **signal_options)
|
|
125
|
+
wait_for(stopped, kill_wait, poll, wait)
|
|
89
126
|
false
|
|
90
127
|
end
|
|
91
128
|
|
|
129
|
+
# Checks +stopped+ every +poll+ until it is true or +seconds+ are over.
|
|
130
|
+
# @return [Boolean] whether it stopped
|
|
131
|
+
def wait_for(stopped, seconds, poll, wait)
|
|
132
|
+
deadline = monotonic + seconds
|
|
133
|
+
until (done = stopped.call) || monotonic >= deadline
|
|
134
|
+
wait.call(poll)
|
|
135
|
+
end
|
|
136
|
+
done ? true : false
|
|
137
|
+
end
|
|
138
|
+
|
|
92
139
|
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
93
140
|
|
|
94
141
|
# Reads a pipe in chunks on a thread of its own, so the output read so
|
|
@@ -64,6 +64,13 @@ module Samagotchi
|
|
|
64
64
|
"or anything else that changes files or state. Finish your reply with what you found " \
|
|
65
65
|
"and what you would do, and wait."
|
|
66
66
|
|
|
67
|
+
# The cancel reason of a question no one could answer: a
|
|
68
|
+
# --non-interactive run has no one to ask (TerminalUI).
|
|
69
|
+
UNANSWERABLE_REASON = "non_interactive"
|
|
70
|
+
UNANSWERABLE_NOTE = "No one could answer the question: this is a non-interactive run. Don't do what you " \
|
|
71
|
+
"asked about, or anything else that changes files or state. Finish your reply with " \
|
|
72
|
+
"what you found and what you would do."
|
|
73
|
+
|
|
67
74
|
# @param session [#call] → Session, nil
|
|
68
75
|
# @param state_dir [#call] → String, the session's state dir
|
|
69
76
|
# @param emit [#call] (event) → emits an event to the turn sink + observers
|
|
@@ -82,6 +89,8 @@ module Samagotchi
|
|
|
82
89
|
@cv = @lock.new_cond
|
|
83
90
|
@pending = nil
|
|
84
91
|
@answer = nil
|
|
92
|
+
# Why the open question was cancelled (#cancel), for the sync path.
|
|
93
|
+
@cancel_reason = nil
|
|
85
94
|
@sync_handler = nil
|
|
86
95
|
# The standing question while it is @pending: {id:, fields:,
|
|
87
96
|
# on_answer:, on_superseded_close:}; @shelved, one superseded by (or
|
|
@@ -106,7 +115,10 @@ module Samagotchi
|
|
|
106
115
|
.merge(multi_select: !!payload[:multi_select], allow_freeform: !!payload[:allow_freeform]))
|
|
107
116
|
# Dismissed (the card's dismiss, Esc): an answer of its own, not a
|
|
108
117
|
# tool failure the model learns to avoid the tool from.
|
|
109
|
-
|
|
118
|
+
if result.is_a?(Hash) && result[:error] == "no answer"
|
|
119
|
+
note = result[:reason] == UNANSWERABLE_REASON ? UNANSWERABLE_NOTE : DISMISSED_NOTE
|
|
120
|
+
result = { dismissed: true, id: result[:id], note: note }
|
|
121
|
+
end
|
|
110
122
|
# Who answered is for the relay, not the model's tool result.
|
|
111
123
|
result = result.except(:by) if result.is_a?(Hash)
|
|
112
124
|
result.is_a?(String) ? result : JSON.generate(result)
|
|
@@ -213,7 +225,15 @@ module Samagotchi
|
|
|
213
225
|
emit({ type: :question_answered, id: id, answer: ans })
|
|
214
226
|
return ans
|
|
215
227
|
end
|
|
216
|
-
|
|
228
|
+
if sync_res.is_a?(String) && !sync_res.strip.empty?
|
|
229
|
+
# The handler's text is the answer: nothing stays pending, and
|
|
230
|
+
# every UI hears it was answered (the text as its freeform).
|
|
231
|
+
@lock.synchronize { @pending = nil }
|
|
232
|
+
clear_saved_question
|
|
233
|
+
emit({ type: :question_answered, id: id,
|
|
234
|
+
answer: { id: id.to_s, selected: [], freeform: sync_res.strip, selected_indices: [] } })
|
|
235
|
+
return sync_res
|
|
236
|
+
end
|
|
217
237
|
rescue StandardError => e
|
|
218
238
|
Log.warn(:turn, "question_handler_failed", echo: "[ask_user_question] sync handler failed: #{e.message}", error: e.class.name)
|
|
219
239
|
end
|
|
@@ -221,9 +241,12 @@ module Samagotchi
|
|
|
221
241
|
# CV (no cross-thread answerer exists for synchronous UIs). Clear pending
|
|
222
242
|
# and return an error so the model can fallback to plain text. Generic
|
|
223
243
|
# observers will discard the stale question_requested via staleness check.
|
|
224
|
-
@lock.synchronize
|
|
244
|
+
reason = @lock.synchronize do
|
|
245
|
+
@pending = nil
|
|
246
|
+
@cancel_reason
|
|
247
|
+
end
|
|
225
248
|
clear_saved_question
|
|
226
|
-
return { error: "no answer", detail: "handler failed to capture selection", id: id }
|
|
249
|
+
return { error: "no answer", detail: "handler failed to capture selection", id: id, reason: reason }.compact
|
|
227
250
|
end
|
|
228
251
|
|
|
229
252
|
# Block until answered/cancelled (cross-thread path: WEB/Bridge/background worker)
|
|
@@ -356,6 +379,7 @@ module Samagotchi
|
|
|
356
379
|
next if @standing
|
|
357
380
|
|
|
358
381
|
pending[:status] = "cancelled"
|
|
382
|
+
@cancel_reason = reason.to_s
|
|
359
383
|
@cv.broadcast
|
|
360
384
|
pending[:id]
|
|
361
385
|
end
|
|
@@ -403,6 +427,7 @@ module Samagotchi
|
|
|
403
427
|
@lock.synchronize do
|
|
404
428
|
@pending = pending
|
|
405
429
|
@answer = nil
|
|
430
|
+
@cancel_reason = nil
|
|
406
431
|
@standing = standing&.merge(id: pending[:id])
|
|
407
432
|
end
|
|
408
433
|
if session
|
|
@@ -49,7 +49,7 @@ module Samagotchi
|
|
|
49
49
|
elsif !IdleTarget.host_named?(**settings)
|
|
50
50
|
# A model naming no host goes where a bare --model goes
|
|
51
51
|
# (HostRegistry#host_for_model: the default host unless
|
|
52
|
-
# another
|
|
52
|
+
# another lists or declares it), resolved at each attempt.
|
|
53
53
|
-> { IdleTarget.resolve(**settings) }
|
|
54
54
|
else
|
|
55
55
|
fixed = fixed_target(settings)
|
|
@@ -294,7 +294,8 @@ module Samagotchi
|
|
|
294
294
|
served = ServedModel.from_props(props)
|
|
295
295
|
return "unknown (no answer from the server's /props)" unless served
|
|
296
296
|
|
|
297
|
-
|
|
297
|
+
expected = entry.models&.dig(bare.to_s.downcase)&.serves?(served)
|
|
298
|
+
ServedModel.differs?(bare, served) && !expected ? "#{served} (not #{bare}: the server serves its own model)" : served
|
|
298
299
|
rescue StandardError => e
|
|
299
300
|
"unknown (#{e.class})"
|
|
300
301
|
end
|
|
@@ -17,6 +17,15 @@ module Samagotchi
|
|
|
17
17
|
name.is_a?(String) && !name.strip.empty? ? name : nil
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
# The host whose hosts.<name>.models.<id>.served names +served+ for
|
|
21
|
+
# +target+'s model (a gateway's round-robin targets): not a mismatch to
|
|
22
|
+
# warn about. nil when none does.
|
|
23
|
+
# @param target [HostRegistry::ModelTarget]
|
|
24
|
+
# @return [String, nil]
|
|
25
|
+
def expected_by(target, served)
|
|
26
|
+
target.entry.name if target.entry.models&.dig(target.bare_model.to_s.strip.downcase)&.serves?(served)
|
|
27
|
+
end
|
|
28
|
+
|
|
20
29
|
# The separators a provider uses when it decorates a model name: a tag
|
|
21
30
|
# (`qwen3:latest`), a date or quant suffix (`gpt-4o-2024-08-06`), an
|
|
22
31
|
# owner (`@org/name`) or a path (`meta-llama/Llama-3`). Anything else
|