samagotchi 0.64.0 → 0.65.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +41 -1
- data/docs/architecture.md +20 -3
- data/docs/cli.md +25 -1
- data/docs/configuration.md +128 -3
- data/docs/internals/engine.md +15 -3
- data/docs/sessions.md +3 -2
- data/lib/samagotchi/bridge.rb +11 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/client.rb +4 -2
- data/lib/samagotchi/config.rb +5 -4
- data/lib/samagotchi/engine.rb +39 -7
- data/lib/samagotchi/generation_phase.rb +17 -0
- data/lib/samagotchi/host_config.rb +27 -6
- data/lib/samagotchi/host_registry.rb +34 -11
- data/lib/samagotchi/idle_client.rb +62 -25
- data/lib/samagotchi/idle_recap.rb +5 -2
- data/lib/samagotchi/idle_target.rb +30 -7
- data/lib/samagotchi/llm/adapters.rb +41 -0
- data/lib/samagotchi/llm/anthropic_assembly.rb +210 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
- data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
- data/lib/samagotchi/llm/api_key.rb +26 -9
- data/lib/samagotchi/llm/chat_loop.rb +113 -14
- data/lib/samagotchi/llm/dialect.rb +57 -0
- data/lib/samagotchi/llm/errors.rb +81 -12
- data/lib/samagotchi/llm/model_listing.rb +111 -0
- data/lib/samagotchi/llm/model_result.rb +9 -2
- data/lib/samagotchi/llm/openai_chat.rb +46 -93
- data/lib/samagotchi/model_profile.rb +1 -1
- data/lib/samagotchi/reasoning_history.rb +14 -4
- data/lib/samagotchi/recap_setup.rb +2 -1
- data/lib/samagotchi/self_report.rb +18 -28
- data/lib/samagotchi/session.rb +3 -2
- data/lib/samagotchi/session_metrics.rb +6 -11
- data/lib/samagotchi/steer_cut.rb +4 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +8 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +4 -3
- data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
- data/lib/samagotchi/turn_note.rb +22 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -1
- data/lib/samagotchi/web/public/activity.js +3 -0
- data/lib/samagotchi/web/public/app.js +90 -13
- data/lib/samagotchi/web/public/delivery_line.js +228 -0
- data/lib/samagotchi/web/public/index.html +29 -0
- data/lib/samagotchi/web/public/live_turn.js +8 -0
- data/lib/samagotchi/web/public/stage_model.js +27 -7
- data/lib/samagotchi/web/public/stage_view.js +32 -10
- data/lib/samagotchi/web/public/stream_handlers.js +8 -2
- data/lib/samagotchi/web/public/turn_events.js +22 -8
- data/lib/samagotchi/web/public/turn_html.js +20 -4
- data/lib/samagotchi/web/public/turn_model.js +16 -9
- data/lib/samagotchi/web/public/turn_view.js +6 -4
- metadata +8 -1
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -224,6 +224,7 @@ module Samagotchi
|
|
|
224
224
|
# and after a reminder turn), so the idle layer's clock is identical
|
|
225
225
|
# across UIs.
|
|
226
226
|
@turn_state.restart_clock!
|
|
227
|
+
@side_client_mutex = Mutex.new
|
|
227
228
|
@recap = RecapSetup.build(recap, engine: self, host_registry: @host_registry,
|
|
228
229
|
session_target: -> { session_model_recap_target },
|
|
229
230
|
session_id: -> { @session&.id }, state_dir: -> { session_state_dir })
|
|
@@ -299,7 +300,7 @@ module Samagotchi
|
|
|
299
300
|
# event[:ask_user], event[:stop_turn]): the Engine's routes to the UIs.
|
|
300
301
|
@hooks.runtime = hook_runtime
|
|
301
302
|
# The loop follows the effective model's host (its api:): the raw-prompt
|
|
302
|
-
# NativeBackend, or the chat backend for openai
|
|
303
|
+
# NativeBackend, or the chat backend for chat hosts (openai, anthropic).
|
|
303
304
|
@native_backend = LLM::NativeBackend.new(kernel: @kernel)
|
|
304
305
|
end
|
|
305
306
|
private :wire_kernel
|
|
@@ -405,6 +406,10 @@ module Samagotchi
|
|
|
405
406
|
# for #cut_for_steer.
|
|
406
407
|
def input_epoch = @steer_cut.input_epoch
|
|
407
408
|
|
|
409
|
+
# The running generation's phase (SteerCut#generation_snapshot): what
|
|
410
|
+
# Bridge#snapshot puts in current_turn.generation.
|
|
411
|
+
def generation_snapshot = @steer_cut.generation_snapshot
|
|
412
|
+
|
|
408
413
|
# Like #steer, for the turn that begins next (a continue turn answered
|
|
409
414
|
# with a text): it joins that turn at its first boundary. Callable from
|
|
410
415
|
# any thread; a turn that never begins keeps it for the next one, unless
|
|
@@ -1082,7 +1087,7 @@ module Samagotchi
|
|
|
1082
1087
|
@last_context_status = saved_context_status(@metrics.snapshot[:context]) || @last_context_status if @last_context_status
|
|
1083
1088
|
end
|
|
1084
1089
|
|
|
1085
|
-
# The effective model is on a chat host (api: openai), whose loop uses
|
|
1090
|
+
# The effective model is on a chat host (api: openai or anthropic), whose loop uses
|
|
1086
1091
|
# no prompt profile.
|
|
1087
1092
|
def chat_model? = @host_registry.resolve(@effective_model_name).entry.chat?
|
|
1088
1093
|
|
|
@@ -2285,7 +2290,7 @@ module Samagotchi
|
|
|
2285
2290
|
# A retry's nudge at the tail goes: this note says it all. An earlier
|
|
2286
2291
|
# turn's note stays: it says why that turn ended.
|
|
2287
2292
|
if result.empty_answer?
|
|
2288
|
-
note = TurnNote.empty(retries: result.empty_retries, steps: result.empty_steps)
|
|
2293
|
+
note = TurnNote.empty(retries: result.empty_retries, steps: result.empty_steps, refusal: result.refusal)
|
|
2289
2294
|
conversation&.replace(TurnNote.without_trailing_nudge(conversation))
|
|
2290
2295
|
saved = TurnNote.without_trailing_nudge(conversation || turn.session.messages)
|
|
2291
2296
|
conversation << note if conversation
|
|
@@ -2482,6 +2487,15 @@ module Samagotchi
|
|
|
2482
2487
|
nil
|
|
2483
2488
|
end
|
|
2484
2489
|
|
|
2490
|
+
# The host refused a sampling field (the empty-answer retry's
|
|
2491
|
+
# temperature on a model that takes none) and the chat loop asked again
|
|
2492
|
+
# without them, as it will for the model from now on: a log line.
|
|
2493
|
+
def sampling_refused(event)
|
|
2494
|
+
Log.info(:model, "sampling_refused", model: event[:model], detail: event[:detail])
|
|
2495
|
+
rescue StandardError
|
|
2496
|
+
nil
|
|
2497
|
+
end
|
|
2498
|
+
|
|
2485
2499
|
# The host refused the model's reasoning sent back (reasoning_history)
|
|
2486
2500
|
# and the chat loop asked the step again without it, as it will from
|
|
2487
2501
|
# now on: logged, and said once per session and host.
|
|
@@ -2549,7 +2563,7 @@ module Samagotchi
|
|
|
2549
2563
|
context_status: result.context_status&.dup
|
|
2550
2564
|
}
|
|
2551
2565
|
# No answer: every UI shows its notice (TurnNote.empty_answer_line).
|
|
2552
|
-
summary[:empty_answer] = { retries: result.empty_retries.to_i } if result.empty_answer?
|
|
2566
|
+
summary[:empty_answer] = { retries: result.empty_retries.to_i, refusal: result.refusal }.compact if result.empty_answer?
|
|
2553
2567
|
summary
|
|
2554
2568
|
end
|
|
2555
2569
|
|
|
@@ -2734,19 +2748,36 @@ module Samagotchi
|
|
|
2734
2748
|
|
|
2735
2749
|
# ctx.ask_model's request: the session's current model on its host, as
|
|
2736
2750
|
# a turn resolves them (a /model switch counts), through its own
|
|
2737
|
-
# IdleClient, so it shares nothing with the turn's backend.
|
|
2751
|
+
# IdleClient, so it shares nothing with the turn's backend. The client
|
|
2752
|
+
# is kept while the target and timeout stay (what it learned, e.g. a
|
|
2753
|
+
# refused thinking-off, holds for the next request); the session's id
|
|
2754
|
+
# goes with the request.
|
|
2738
2755
|
# @return [String] the answer
|
|
2739
2756
|
def ask_side_model(request, timeout:, max_tokens:, cancel_controller:)
|
|
2740
2757
|
target = session_model_recap_target
|
|
2741
|
-
client =
|
|
2758
|
+
client = side_model_client(target, timeout)
|
|
2742
2759
|
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
2743
|
-
answer = client.ask(request, max_tokens: max_tokens, cancel_controller: cancel_controller)
|
|
2760
|
+
answer = client.ask(request, max_tokens: max_tokens, cancel_controller: cancel_controller, session_id: @session&.id)
|
|
2744
2761
|
Log.info(:plugins, "ask_model", model: target.label, answer_model: answer.model, chars: answer.text.length,
|
|
2745
2762
|
ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round)
|
|
2746
2763
|
answer.text
|
|
2747
2764
|
end
|
|
2748
2765
|
private :ask_side_model
|
|
2749
2766
|
|
|
2767
|
+
# The IdleClient for +target+ at +timeout+: the last one when both are
|
|
2768
|
+
# the same, else a new one.
|
|
2769
|
+
def side_model_client(target, timeout)
|
|
2770
|
+
@side_client_mutex.synchronize do
|
|
2771
|
+
key = [target, timeout]
|
|
2772
|
+
unless @side_client && @side_client_key == key
|
|
2773
|
+
@side_client = IdleClient.for(target, timeout: timeout)
|
|
2774
|
+
@side_client_key = key
|
|
2775
|
+
end
|
|
2776
|
+
@side_client
|
|
2777
|
+
end
|
|
2778
|
+
end
|
|
2779
|
+
private :side_model_client
|
|
2780
|
+
|
|
2750
2781
|
# The session's current model as a recap target: its host's OpenAI API
|
|
2751
2782
|
# (native llama.cpp hosts serve /v1/chat/completions too), key variable
|
|
2752
2783
|
# and bare model name, as a turn resolves them.
|
|
@@ -2813,6 +2844,7 @@ module Samagotchi
|
|
|
2813
2844
|
# reasoning sent back: a notice, not an event of its own.
|
|
2814
2845
|
next thinking_refused(event) if event[:type] == :thinking_refused
|
|
2815
2846
|
next reasoning_history_refused(event) if event[:type] == :reasoning_history_refused
|
|
2847
|
+
next sampling_refused(event) if event[:type] == :sampling_refused
|
|
2816
2848
|
|
|
2817
2849
|
event = with_generation_report(event) if event[:type] == :generation_completed
|
|
2818
2850
|
emit_event(on_event, event)
|
|
@@ -14,6 +14,12 @@ module Samagotchi
|
|
|
14
14
|
# A thinking delta this recent means the model is still thinking.
|
|
15
15
|
FRESH_SECONDS = 2.0
|
|
16
16
|
|
|
17
|
+
# What a page joining mid-generation needs for its delivery line
|
|
18
|
+
# (Bridge#snapshot's current_turn.generation): whether it has streamed
|
|
19
|
+
# only thinking so far (thinking, no text, no tool call; freshness
|
|
20
|
+
# aside) and its thinking's #age (nil before any thinking).
|
|
21
|
+
Snapshot = Data.define(:thinking_only, :age)
|
|
22
|
+
|
|
17
23
|
# @param clock [#call] monotonic seconds
|
|
18
24
|
def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
19
25
|
@clock = clock
|
|
@@ -71,6 +77,17 @@ module Samagotchi
|
|
|
71
77
|
@mutex.synchronize { @running && @thinking_since ? @clock.call - @thinking_since : nil }
|
|
72
78
|
end
|
|
73
79
|
|
|
80
|
+
# The running generation as a Snapshot, its age rounded to a tenth;
|
|
81
|
+
# nil with none running.
|
|
82
|
+
def snapshot
|
|
83
|
+
@mutex.synchronize do
|
|
84
|
+
next nil unless @running
|
|
85
|
+
|
|
86
|
+
age = @thinking_since && (@clock.call - @thinking_since).round(1)
|
|
87
|
+
Snapshot.new(thinking_only: !@thinking_since.nil? && !@text && !@tool_call, age: age)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
74
91
|
private
|
|
75
92
|
|
|
76
93
|
def clear_seen
|
|
@@ -4,6 +4,7 @@ require "uri"
|
|
|
4
4
|
require_relative "config"
|
|
5
5
|
require_relative "host_model"
|
|
6
6
|
require_relative "llm_context_strategy"
|
|
7
|
+
require_relative "llm/dialect"
|
|
7
8
|
require_relative "reasoning_history"
|
|
8
9
|
|
|
9
10
|
module Samagotchi
|
|
@@ -15,12 +16,20 @@ module Samagotchi
|
|
|
15
16
|
#
|
|
16
17
|
# name: the lowercased entry name; host, port, scheme: where it is (from
|
|
17
18
|
# url: when given); url: the configured url, nil when host/port; transport,
|
|
18
|
-
# api: symbols, nil when unset; the
|
|
19
|
-
#
|
|
20
|
-
|
|
19
|
+
# api: symbols, nil when unset; auth: the header the API key goes in
|
|
20
|
+
# (LLM::ApiKey::SCHEMES), nil when unset; dialect: an api: anthropic
|
|
21
|
+
# host's LLM::Dialect name, nil when unset; the rest as
|
|
22
|
+
# HostRegistry::HostEntry describes them, nil when unset; models:
|
|
23
|
+
# {downcased id => HostModel}.
|
|
24
|
+
HostConfig = Data.define(:name, :host, :port, :scheme, :url, :transport, :api, :api_key_env, :auth, :dialect, :profile,
|
|
21
25
|
:first_token_timeout, :vision, :sampling, :thinking, :reasoning_history, :remote,
|
|
22
26
|
:window_tokens, :steer_cut_after, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
|
|
23
27
|
:models) do
|
|
28
|
+
# An api: anthropic host without url/host talks to Anthropic's own API.
|
|
29
|
+
ANTHROPIC_URL = "https://api.anthropic.com"
|
|
30
|
+
# auth: as written => the LLM::ApiKey scheme.
|
|
31
|
+
AUTH_SCHEMES = { "bearer" => :bearer, "x-api-key" => :x_api_key }.freeze
|
|
32
|
+
|
|
24
33
|
# Every field but name may be left out: nil, models {}.
|
|
25
34
|
def initialize(**fields)
|
|
26
35
|
super(**members.to_h { |m| [m, nil] }, models: {}, **fields)
|
|
@@ -69,8 +78,17 @@ module Samagotchi
|
|
|
69
78
|
return invalid(name, "api_key_env must be an environment variable name")
|
|
70
79
|
end
|
|
71
80
|
|
|
72
|
-
|
|
81
|
+
auth = value.call(:auth).to_s.strip.downcase
|
|
82
|
+
fields[:auth] = AUTH_SCHEMES[auth] unless auth.empty?
|
|
83
|
+
return invalid(name, "auth must be one of #{AUTH_SCHEMES.keys.join(", ")}") if !auth.empty? && fields[:auth].nil?
|
|
84
|
+
|
|
85
|
+
if fields[:auth] && fields[:api_key_env].nil?
|
|
86
|
+
ConfigFile.warn_once "Warning: #{where}: auth has no effect without api_key_env (no key is sent)"
|
|
87
|
+
end
|
|
88
|
+
|
|
73
89
|
kinds = parse_kinds(name, value.call(:transport), value.call(:api)) or return nil
|
|
90
|
+
location = parse_location(name, value, api: kinds[:api]) or return nil
|
|
91
|
+
fields[:dialect] = LLM::Dialect.parse(value.call(:dialect), where, api: kinds[:api])
|
|
74
92
|
new(name: name.downcase, **location, **kinds, **fields)
|
|
75
93
|
rescue StandardError => e
|
|
76
94
|
# One entry that trips a reader is dropped on its own, and said so;
|
|
@@ -99,11 +117,13 @@ module Samagotchi
|
|
|
99
117
|
end
|
|
100
118
|
|
|
101
119
|
# host/port/scheme/url from url:, or from host: and port:; nil (warned)
|
|
102
|
-
# when they are wrong.
|
|
103
|
-
|
|
120
|
+
# when they are wrong. An api: anthropic entry with neither has
|
|
121
|
+
# ANTHROPIC_URL.
|
|
122
|
+
def self.parse_location(name, value, api: nil)
|
|
104
123
|
host = value.call(:host)
|
|
105
124
|
port = value.call(:port)
|
|
106
125
|
url = value.call(:url).to_s.strip
|
|
126
|
+
url = ANTHROPIC_URL if url.empty? && api == :anthropic && host.to_s.strip.empty? && port.to_s.strip.empty?
|
|
107
127
|
scheme = "http"
|
|
108
128
|
unless url.empty?
|
|
109
129
|
return invalid(name, "give url or host/port, not both") unless host.to_s.strip.empty? && port.to_s.strip.empty?
|
|
@@ -168,6 +188,7 @@ module Samagotchi
|
|
|
168
188
|
def to_env_h
|
|
169
189
|
location = url ? { "url" => url } : { "host" => host, "port" => port }
|
|
170
190
|
location.merge("transport" => transport&.to_s, "api" => api&.to_s, "api_key_env" => api_key_env,
|
|
191
|
+
"auth" => (AUTH_SCHEMES.key(auth) if auth), "dialect" => dialect,
|
|
171
192
|
"profile" => profile, "first_token_timeout" => first_token_timeout,
|
|
172
193
|
"vision" => vision, "sampling" => sampling, "thinking" => thinking&.to_s,
|
|
173
194
|
"reasoning_history" => reasoning_history&.to_s, "remote" => remote, "window_tokens" => window_tokens,
|
|
@@ -6,7 +6,8 @@ require_relative "log"
|
|
|
6
6
|
require_relative "model_ref"
|
|
7
7
|
require_relative "client"
|
|
8
8
|
require_relative "model_list_store"
|
|
9
|
-
require_relative "llm/
|
|
9
|
+
require_relative "llm/adapters"
|
|
10
|
+
require_relative "llm/dialect"
|
|
10
11
|
|
|
11
12
|
module Samagotchi
|
|
12
13
|
# HostRegistry manages multiple model hosts (llama.cpp / mlx / oMLX) and
|
|
@@ -30,6 +31,8 @@ module Samagotchi
|
|
|
30
31
|
|
|
31
32
|
# url: the configured url, when the entry has one (host, port and scheme
|
|
32
33
|
# come from it); api_key_env: the variable holding the host's API key;
|
|
34
|
+
# auth: the header it goes in (LLM::ApiKey::SCHEMES), nil for the api's own default;
|
|
35
|
+
# dialect: an api: anthropic host's LLM::Dialect name (#dialect_profile), nil when unset;
|
|
33
36
|
# profile: the configured prompt profile name, if any;
|
|
34
37
|
# first_token_timeout: the configured first-token limit (see #first_token_limit);
|
|
35
38
|
# vision: the configured true/false (VisionSupport), nil when unset;
|
|
@@ -43,13 +46,13 @@ module Samagotchi
|
|
|
43
46
|
# llm_context_apply: the configured apply rule (LLMContextStrategy), nil when unset;
|
|
44
47
|
# llm_context_budget_tokens: the configured context budget (LLMContextStrategy), nil when unset;
|
|
45
48
|
# models: the ids the host serves whatever it lists, {downcased id => HostModel} ({} when none).
|
|
46
|
-
HostEntry = Struct.new(:name, :host, :port, :transport, :client, :api, :scheme, :url, :api_key_env, :
|
|
47
|
-
:first_token_timeout, :vision, :sampling, :thinking, :reasoning_history, :remote,
|
|
49
|
+
HostEntry = Struct.new(:name, :host, :port, :transport, :client, :api, :scheme, :url, :api_key_env, :auth, :dialect,
|
|
50
|
+
:profile, :first_token_timeout, :vision, :sampling, :thinking, :reasoning_history, :remote,
|
|
48
51
|
:window_tokens, :steer_cut_after, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
|
|
49
52
|
:models, keyword_init: true) do
|
|
50
|
-
# Talks
|
|
51
|
-
# raw-prompt loop.
|
|
52
|
-
def chat? = api
|
|
53
|
+
# Talks a chat API through an adapter (LLM::Adapters: the chat loop);
|
|
54
|
+
# nil and raw apis use the raw-prompt loop.
|
|
55
|
+
def chat? = LLM::Adapters.chat_api?(api)
|
|
53
56
|
|
|
54
57
|
# The server root, e.g. for llama.cpp's own endpoints and recap.
|
|
55
58
|
def root_url = "#{scheme || "http"}://#{host}:#{port}"
|
|
@@ -58,6 +61,25 @@ module Samagotchi
|
|
|
58
61
|
# configured, else the root's /v1.
|
|
59
62
|
def openai_base_url = url || "#{root_url}/v1"
|
|
60
63
|
|
|
64
|
+
# The API base the host's chat adapter talks to (LLM::Adapters): for
|
|
65
|
+
# api: openai the OpenAI-compatible base; for api: anthropic the API
|
|
66
|
+
# root without /v1 (as ANTHROPIC_BASE_URL; a url that ends in /v1
|
|
67
|
+
# loses it, so requests never go to /v1/v1).
|
|
68
|
+
def api_base_url
|
|
69
|
+
return openai_base_url unless api&.to_sym == :anthropic
|
|
70
|
+
|
|
71
|
+
(url || root_url).chomp("/").delete_suffix("/v1")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The header the host's key goes in: auth: when set, else the api's
|
|
75
|
+
# own (for api: anthropic its dialect's, x-api-key unless the dialect
|
|
76
|
+
# says otherwise; Bearer for every other api).
|
|
77
|
+
def auth_scheme = auth || (api&.to_sym == :anthropic ? dialect_profile.auth : :bearer)
|
|
78
|
+
|
|
79
|
+
# The LLM::Dialect an api: anthropic host's requests follow (STANDARD
|
|
80
|
+
# when it names none).
|
|
81
|
+
def dialect_profile = LLM::Dialect.for(dialect)
|
|
82
|
+
|
|
61
83
|
# A provider on the network rather than a local server: hosts.<name>.remote
|
|
62
84
|
# when set, else by its address (HostRegistry.remote_address?). An API
|
|
63
85
|
# key doesn't decide: a llama.cpp on the LAN can have one. A remote
|
|
@@ -106,7 +128,7 @@ module Samagotchi
|
|
|
106
128
|
entry = HostEntry.new(**fields)
|
|
107
129
|
entry.client = Client.new(host: cfg.host, port: cfg.port, transport: cfg.transport, scheme: cfg.scheme,
|
|
108
130
|
first_token_timeout: entry.first_token_limit, name: entry.name,
|
|
109
|
-
api_key_env: entry.api_key_env, env: env)
|
|
131
|
+
api_key_env: entry.api_key_env, auth: entry.auth_scheme, env: env)
|
|
110
132
|
@entries[entry.name] = entry
|
|
111
133
|
end
|
|
112
134
|
# Fallback single entry (should already be synthesized by hosts_config, but guard)
|
|
@@ -217,12 +239,13 @@ module Samagotchi
|
|
|
217
239
|
end
|
|
218
240
|
|
|
219
241
|
# The chat adapter for a host (one per host, so its cached model list
|
|
220
|
-
# serves the context window)
|
|
242
|
+
# serves the context window), as LLM::Adapters builds it for the host's
|
|
243
|
+
# api. Only chat hosts use it for turns.
|
|
221
244
|
# @return [LLM::OpenAIChat]
|
|
222
245
|
def adapter_for(entry)
|
|
223
246
|
@mutex.synchronize do
|
|
224
|
-
@adapters[entry.name] ||= LLM::
|
|
225
|
-
|
|
247
|
+
@adapters[entry.name] ||= LLM::Adapters.for(entry, models_ttl: entry.models_ttl,
|
|
248
|
+
first_token_timeout: entry.first_token_limit)
|
|
226
249
|
end
|
|
227
250
|
end
|
|
228
251
|
|
|
@@ -390,7 +413,7 @@ module Samagotchi
|
|
|
390
413
|
# the registry).
|
|
391
414
|
def inspect
|
|
392
415
|
hosts = @entries.values.map do |entry|
|
|
393
|
-
parts = [entry.
|
|
416
|
+
parts = [entry.api_base_url]
|
|
394
417
|
parts << "key=#{entry.api_key_env}" if entry.api_key_env
|
|
395
418
|
parts << "models=#{entry.models.values.map(&:id).join(",")}" unless entry.models.empty?
|
|
396
419
|
"#{entry.name}(#{parts.join(" ")})"
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
-
require_relative "llm/
|
|
5
|
-
require_relative "thinking"
|
|
4
|
+
require_relative "llm/adapters"
|
|
6
5
|
require_relative "log"
|
|
7
6
|
|
|
8
7
|
module Samagotchi
|
|
9
8
|
# Standalone summarizer for the idle session-recap feature.
|
|
10
9
|
#
|
|
11
10
|
# Asks an OpenAI-compatible /chat/completions endpoint (the local llama.cpp,
|
|
12
|
-
# or a recap host's) through its own
|
|
11
|
+
# or a recap host's) through its own adapter (LLM::Adapters.build, by the
|
|
12
|
+
# target's api; OpenAIChat today): one plain request, no
|
|
13
13
|
# tools, no retries, its own timeout. It is NEVER wired into the Engine's
|
|
14
14
|
# kernel/backend, so it shares no engine concurrency. The Engine's idle
|
|
15
15
|
# detector only snapshots session.messages and calls #summarize; this object
|
|
@@ -38,6 +38,11 @@ module Samagotchi
|
|
|
38
38
|
DEFAULT_TIMEOUT_SECONDS = 30.0
|
|
39
39
|
# Up to 10 sentences (recap.sentences) need ~350 tokens with thinking off.
|
|
40
40
|
MAX_TOKENS = 512
|
|
41
|
+
# The least max_tokens a side request gets when its model refused
|
|
42
|
+
# thinking off and the adapter asks for a little thinking instead (an
|
|
43
|
+
# always-thinking model on a Messages host): the thinking counts in the
|
|
44
|
+
# limit, so 512 could all go to it and leave no answer.
|
|
45
|
+
THINKING_MAX_TOKENS = 4096
|
|
41
46
|
|
|
42
47
|
# @param base_url [String] the OpenAI API base, e.g. http://host:8081/v1
|
|
43
48
|
# @param api_key_env [String, nil] the variable holding the host's key
|
|
@@ -46,19 +51,26 @@ module Samagotchi
|
|
|
46
51
|
# summarize thread can't outlive the recap attempt by minutes.
|
|
47
52
|
# @param purpose [String] what the requests are for, in the HTTP log
|
|
48
53
|
# lines and error messages ("recap", "broadcast")
|
|
49
|
-
|
|
54
|
+
# @param api [Symbol] the wire to talk (LLM::Adapters; IdleTarget#api)
|
|
55
|
+
# @param auth [Symbol, nil] the header the key goes in (IdleTarget#auth)
|
|
56
|
+
# @param dialect [String, nil] a Messages host's dialect (IdleTarget#dialect)
|
|
57
|
+
def initialize(model:, base_url: nil, api_key_env: nil, timeout: DEFAULT_TIMEOUT_SECONDS, env: ENV, purpose: "recap",
|
|
58
|
+
api: :openai, auth: nil, dialect: nil)
|
|
50
59
|
@model = model
|
|
51
60
|
# A recap is best-effort: one short attempt, no retries. The idle job
|
|
52
|
-
# tries again after the next activity, never on its own.
|
|
53
|
-
|
|
54
|
-
|
|
61
|
+
# tries again after the next activity, never on its own. Only the
|
|
62
|
+
# Messages adapter takes a dialect.
|
|
63
|
+
@chat = LLM::Adapters.build(api: api, base_url: base_url.to_s, host_name: purpose, api_key_env: api_key_env,
|
|
64
|
+
auth: auth, stream: false, retries: false, timeout: timeout, env: env, purpose: purpose,
|
|
65
|
+
**(dialect ? { dialect: dialect } : {}))
|
|
55
66
|
end
|
|
56
67
|
|
|
57
68
|
# A client for +target+ (an IdleTarget: a recap's, ctx.ask_model's, a
|
|
58
|
-
# broadcast's triage model).
|
|
69
|
+
# broadcast's triage model), on its api's adapter.
|
|
59
70
|
# @return [IdleClient]
|
|
60
71
|
def self.for(target, **)
|
|
61
|
-
new(model: target.model, base_url: target.base_url, api_key_env: target.api_key_env,
|
|
72
|
+
new(model: target.model, base_url: target.base_url, api_key_env: target.api_key_env, api: target.api,
|
|
73
|
+
auth: target.auth, dialect: target.dialect, **)
|
|
62
74
|
end
|
|
63
75
|
|
|
64
76
|
THINK_RE = /<\|think\|.*?\|think\|>/m
|
|
@@ -86,11 +98,13 @@ module Samagotchi
|
|
|
86
98
|
# when there is nothing to summarize. Any failure raises SummarizeError
|
|
87
99
|
# (the caller isolates it).
|
|
88
100
|
# @return [Summary, nil]
|
|
89
|
-
|
|
101
|
+
# +session_id+: the session it is about, sent as the turns send it
|
|
102
|
+
# (Session-Id, a dialect's affinity header), nil for none.
|
|
103
|
+
def summarize(prompt, session_id: nil)
|
|
90
104
|
messages = prompt.is_a?(Array) ? prompt : [{ role: "user", content: prompt.to_s.strip }]
|
|
91
105
|
return nil if messages.all? { |m| m[:content].to_s.strip.empty? }
|
|
92
106
|
|
|
93
|
-
response = generate(messages)
|
|
107
|
+
response = generate(messages, session_id: session_id)
|
|
94
108
|
cleaned = response.text.to_s.strip
|
|
95
109
|
cleaned.empty? ? nil : Summary.new(text: cleaned, model: response.model)
|
|
96
110
|
rescue SummarizeError
|
|
@@ -106,14 +120,15 @@ module Samagotchi
|
|
|
106
120
|
# @param kind [String] what it is for, in the usage log line
|
|
107
121
|
# @param options [Hash] more request body fields (logprobs: true,
|
|
108
122
|
# top_logprobs: 5)
|
|
123
|
+
# @param session_id [String, nil] the session it is about (#summarize)
|
|
109
124
|
# @return [Summary] the answer ("" when the model said nothing)
|
|
110
125
|
# @raise [SummarizeError] any failure but a cancel; its #cause is an
|
|
111
126
|
# LLM::BadRequest when the host refused the request (+options+ it
|
|
112
127
|
# doesn't take, say)
|
|
113
128
|
# @raise [LLM::RequestCancelled] +cancel_controller+ was cancelled
|
|
114
|
-
def ask(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, kind: "ask", options: {})
|
|
129
|
+
def ask(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, kind: "ask", options: {}, session_id: nil)
|
|
115
130
|
response = generate(messages, max_tokens: max_tokens, cancel_controller: cancel_controller, whole_sentences: false,
|
|
116
|
-
kind: kind, options: options)
|
|
131
|
+
kind: kind, options: options, session_id: session_id)
|
|
117
132
|
Summary.new(text: response.text.to_s.strip, model: response.model, top_logprobs: response.top_logprobs)
|
|
118
133
|
rescue SummarizeError, LLM::RequestCancelled
|
|
119
134
|
raise
|
|
@@ -130,17 +145,15 @@ module Samagotchi
|
|
|
130
145
|
# the reply has neither content nor reasoning_content. Cut off by
|
|
131
146
|
# +max_tokens+, the text keeps its finished sentences (+whole_sentences+)
|
|
132
147
|
# or all of it, with "…".
|
|
133
|
-
def generate(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, whole_sentences: true, kind: "recap", options: {}
|
|
148
|
+
def generate(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, whole_sentences: true, kind: "recap", options: {},
|
|
149
|
+
session_id: nil)
|
|
134
150
|
response = begin
|
|
135
|
-
request(messages, max_tokens, cancel_controller, options)
|
|
151
|
+
request(messages, max_tokens, cancel_controller, options, session_id)
|
|
136
152
|
rescue LLM::BadRequest => e
|
|
137
|
-
raise unless e.reasoning_refused? &&
|
|
153
|
+
raise unless e.reasoning_refused? && next_thinking_step
|
|
138
154
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
@thinking_refused = true
|
|
142
|
-
Log.info(:recap, "thinking_refused", model: @model, detail: e.detail)
|
|
143
|
-
request(messages, max_tokens, cancel_controller, options)
|
|
155
|
+
Log.info(:recap, "thinking_refused", model: @model, step: @thinking_step, detail: e.detail)
|
|
156
|
+
retry
|
|
144
157
|
end
|
|
145
158
|
log_usage(response, kind)
|
|
146
159
|
content = response.text
|
|
@@ -173,16 +186,40 @@ module Samagotchi
|
|
|
173
186
|
cached: fields[:cached_tokens], cache_write: fields[:cache_write_tokens])
|
|
174
187
|
end
|
|
175
188
|
|
|
176
|
-
|
|
189
|
+
# After a host refused this step's thinking fields: the next step, kept
|
|
190
|
+
# from now on (true), or false when none is left.
|
|
191
|
+
# 1. The host won't turn thinking off (gpt-oss, an always-thinking
|
|
192
|
+
# model): the adapter's fields for that (none on the OpenAI API, low
|
|
193
|
+
# effort on Messages).
|
|
194
|
+
# 2. Those refused too (a Messages model that takes no thinking fields
|
|
195
|
+
# at all): none.
|
|
196
|
+
def next_thinking_step
|
|
197
|
+
step = @thinking_step.to_i
|
|
198
|
+
return false if step >= 2 || (step == 1 && @chat.thinking_off_refused_fields.empty?)
|
|
199
|
+
|
|
200
|
+
@thinking_step = step + 1
|
|
201
|
+
true
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def request(messages, max_tokens, cancel_controller, options, session_id)
|
|
205
|
+
fields = thinking_fields
|
|
206
|
+
max_tokens = [max_tokens, THINKING_MAX_TOKENS].max if @thinking_step == 1 && !fields.empty?
|
|
177
207
|
@chat.chat(messages: messages, model: @model, tools: [], cancel_controller: cancel_controller,
|
|
178
|
-
options: { max_tokens: max_tokens, **
|
|
208
|
+
options: { max_tokens: max_tokens, **fields, **options }, session_id: session_id)
|
|
179
209
|
end
|
|
180
210
|
|
|
181
211
|
# A recap or side answer is short and tool-less: thinking off, whatever
|
|
182
212
|
# the model's level (a reasoning model otherwise spends the budget
|
|
183
|
-
# thinking and the recap stops mid-sentence).
|
|
213
|
+
# thinking and the recap stops mid-sentence). Once the host refused
|
|
214
|
+
# that, the adapter's fields for a model that always thinks (with a
|
|
215
|
+
# limit of at least THINKING_MAX_TOKENS when there are any), then none
|
|
216
|
+
# (#next_thinking_step).
|
|
184
217
|
def thinking_fields
|
|
185
|
-
@
|
|
218
|
+
case @thinking_step.to_i
|
|
219
|
+
when 0 then @chat.thinking_fields(:off)
|
|
220
|
+
when 1 then @chat.thinking_off_refused_fields
|
|
221
|
+
else {}
|
|
222
|
+
end
|
|
186
223
|
end
|
|
187
224
|
end
|
|
188
225
|
end
|
|
@@ -287,10 +287,13 @@ module Samagotchi
|
|
|
287
287
|
sentences: RecapPrompt::DEFAULT_SENTENCES,
|
|
288
288
|
client: nil,
|
|
289
289
|
store: nil,
|
|
290
|
+
session_id: -> {},
|
|
290
291
|
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
291
292
|
raise ArgumentError, "IdleRecap requires an engine" unless engine
|
|
292
293
|
|
|
293
294
|
@engine = engine
|
|
295
|
+
# The recapped session's id, sent with the request (IdleClient#summarize).
|
|
296
|
+
@session_id = session_id
|
|
294
297
|
@target = target || lambda {
|
|
295
298
|
IdleTarget.new(base_url: base_url, api_key_env: api_key_env, model: model, label: model)
|
|
296
299
|
}
|
|
@@ -534,7 +537,7 @@ module Samagotchi
|
|
|
534
537
|
def client_for(asked)
|
|
535
538
|
return @client_override if @client_override
|
|
536
539
|
|
|
537
|
-
key =
|
|
540
|
+
key = asked
|
|
538
541
|
unless @client && @client_key == key
|
|
539
542
|
@client = IdleClient.for(asked, timeout: @timeout)
|
|
540
543
|
@client_key = key
|
|
@@ -544,7 +547,7 @@ module Samagotchi
|
|
|
544
547
|
|
|
545
548
|
def spawn_summarize(client, prompt)
|
|
546
549
|
Thread.new do
|
|
547
|
-
client.summarize(prompt)
|
|
550
|
+
client.summarize(prompt, session_id: @session_id.call)
|
|
548
551
|
rescue StandardError => e
|
|
549
552
|
# No recap this time (the next idle window tries again); say why.
|
|
550
553
|
Log.exception(:recap, "summarize_failed", e)
|
|
@@ -2,13 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
module Samagotchi
|
|
4
4
|
# Where a side request goes (a recap, a plugin's ctx.ask_model, a
|
|
5
|
-
# broadcast's triage): a model on a host's
|
|
5
|
+
# broadcast's triage): a model on a host's chat API, asked through its
|
|
6
6
|
# own IdleClient (IdleClient.for), apart from the session's turns.
|
|
7
|
-
# @!attribute base_url [String] the host's
|
|
7
|
+
# @!attribute base_url [String] the host's API base (HostEntry#api_base_url)
|
|
8
8
|
# @!attribute api_key_env [String, nil] the variable holding its key
|
|
9
9
|
# @!attribute model [String] the model id sent to the server
|
|
10
10
|
# @!attribute label [String] the model as configured, for logs
|
|
11
|
-
|
|
11
|
+
# @!attribute api [Symbol] the wire the side client talks (LLM::Adapters):
|
|
12
|
+
# a chat host's api, else :openai (a raw host's server, or a bare url)
|
|
13
|
+
# @!attribute auth [Symbol, nil] the header its key goes in (the host's
|
|
14
|
+
# auth:, LLM::ApiKey::SCHEMES); nil: the api's own default
|
|
15
|
+
# @!attribute dialect [String, nil] an api: anthropic host's dialect:
|
|
16
|
+
# (LLM::Dialect), so a side request gets its auth and fields
|
|
17
|
+
IdleTarget = Data.define(:base_url, :api_key_env, :model, :label, :api, :auth, :dialect) do
|
|
18
|
+
def initialize(api: :openai, auth: nil, dialect: nil, **) = super
|
|
19
|
+
|
|
20
|
+
# The api a side request to +entry+ talks: a chat host's own, else the
|
|
21
|
+
# OpenAI API (a raw host's llama.cpp serves /v1 too).
|
|
22
|
+
def self.api_of(entry) = entry.chat? ? entry.api : :openai
|
|
23
|
+
|
|
24
|
+
# The dialect a side request to +entry+ talks (only a chat host has one).
|
|
25
|
+
def self.dialect_of(entry) = entry.chat? ? entry.dialect : nil
|
|
26
|
+
|
|
12
27
|
# A configured side model can't be resolved.
|
|
13
28
|
# @!attribute reason [Symbol] :host_ref_unknown (no such host),
|
|
14
29
|
# :model_host_mismatch (the model names another host than host_ref)
|
|
@@ -32,8 +47,9 @@ module Samagotchi
|
|
|
32
47
|
# @return [IdleTarget]
|
|
33
48
|
def self.of_model(host_registry, name)
|
|
34
49
|
resolved = host_registry.resolve(name)
|
|
35
|
-
|
|
36
|
-
|
|
50
|
+
entry = resolved.entry
|
|
51
|
+
new(base_url: entry.api_base_url, api_key_env: entry.api_key_env, model: resolved.bare_model,
|
|
52
|
+
label: name.to_s.strip, api: api_of(entry), auth: entry.auth, dialect: dialect_of(entry))
|
|
37
53
|
end
|
|
38
54
|
|
|
39
55
|
# A side model's settings (recap.*, broadcast.triage_*) as a target:
|
|
@@ -55,12 +71,18 @@ module Samagotchi
|
|
|
55
71
|
|
|
56
72
|
host_ref ||= parsed.host_name if parsed && base_url.nil?
|
|
57
73
|
api_key_env = nil
|
|
74
|
+
api = :openai
|
|
75
|
+
auth = nil
|
|
76
|
+
dialect = nil
|
|
58
77
|
if host_ref
|
|
59
78
|
entry = host_registry.find_entry(host_ref)
|
|
60
79
|
raise self::Unresolved.new(:host_ref_unknown, model: model, host_ref: host_ref) unless entry
|
|
61
80
|
|
|
62
|
-
base_url = entry.
|
|
81
|
+
base_url = entry.api_base_url
|
|
63
82
|
api_key_env = entry.api_key_env
|
|
83
|
+
api = api_of(entry)
|
|
84
|
+
auth = entry.auth
|
|
85
|
+
dialect = dialect_of(entry)
|
|
64
86
|
named = parsed&.host_conflict || parsed&.host_name
|
|
65
87
|
if named && named != entry.name
|
|
66
88
|
raise self::Unresolved.new(:model_host_mismatch, model: model, host_ref: host_ref, named: named)
|
|
@@ -72,7 +94,8 @@ module Samagotchi
|
|
|
72
94
|
end
|
|
73
95
|
raise self::Unresolved.new(:incomplete, model: model, host_ref: host_ref) if blank(base_url).nil? || blank(model).nil?
|
|
74
96
|
|
|
75
|
-
new(base_url: base_url.to_s.strip, api_key_env: api_key_env, model: model.to_s.strip, label: label.strip
|
|
97
|
+
new(base_url: base_url.to_s.strip, api_key_env: api_key_env, model: model.to_s.strip, label: label.strip, api: api, auth: auth,
|
|
98
|
+
dialect: dialect)
|
|
76
99
|
end
|
|
77
100
|
|
|
78
101
|
# Whether the settings fix a host (+base_url+, +host_ref+, or a model
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "openai_chat"
|
|
4
|
+
require_relative "anthropic_messages"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
module LLM
|
|
8
|
+
# Which chat adapter talks a host's api: the one place that names the
|
|
9
|
+
# adapter classes. A host whose api is a key of BY_API runs the chat loop
|
|
10
|
+
# (HostRegistry::HostEntry#chat?); any other api (nil, raw) the
|
|
11
|
+
# raw-prompt loop.
|
|
12
|
+
module Adapters
|
|
13
|
+
BY_API = { openai: OpenAIChat, anthropic: AnthropicMessages }.freeze
|
|
14
|
+
|
|
15
|
+
# true when +api+ (a Symbol or String) has a chat adapter.
|
|
16
|
+
def self.chat_api?(api) = !api.nil? && BY_API.key?(api.to_sym)
|
|
17
|
+
|
|
18
|
+
# A host entry's adapter (its api, API base, name, key and remoteness).
|
|
19
|
+
# @param entry [HostRegistry::HostEntry]
|
|
20
|
+
# @param options [Hash] more adapter options (models_ttl:, first_token_timeout:, ...)
|
|
21
|
+
# @raise [ArgumentError] the entry's api has no chat adapter
|
|
22
|
+
def self.for(entry, **)
|
|
23
|
+
adapter_class(entry.api).for(entry, **)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# An adapter by api, for a side request that has a url rather than a
|
|
27
|
+
# host entry (IdleClient: a recap, ctx.ask_model, a broadcast's triage).
|
|
28
|
+
# @param api [Symbol, String] a key of BY_API
|
|
29
|
+
# @param options [Hash] more adapter options (stream:, retries:, timeout:, ...)
|
|
30
|
+
# @raise [ArgumentError] +api+ has no chat adapter
|
|
31
|
+
def self.build(api:, base_url:, host_name:, api_key_env: nil, **)
|
|
32
|
+
adapter_class(api).new(base_url: base_url, host_name: host_name, api_key_env: api_key_env, **)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.adapter_class(api)
|
|
36
|
+
BY_API.fetch(api&.to_sym) { raise ArgumentError, "no chat adapter for api #{api.inspect}" }
|
|
37
|
+
end
|
|
38
|
+
private_class_method :adapter_class
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|