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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/docs/architecture.md +20 -3
  4. data/docs/cli.md +25 -1
  5. data/docs/configuration.md +128 -3
  6. data/docs/internals/engine.md +15 -3
  7. data/docs/sessions.md +3 -2
  8. data/lib/samagotchi/bridge.rb +11 -1
  9. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  10. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  11. data/lib/samagotchi/client.rb +4 -2
  12. data/lib/samagotchi/config.rb +5 -4
  13. data/lib/samagotchi/engine.rb +39 -7
  14. data/lib/samagotchi/generation_phase.rb +17 -0
  15. data/lib/samagotchi/host_config.rb +27 -6
  16. data/lib/samagotchi/host_registry.rb +34 -11
  17. data/lib/samagotchi/idle_client.rb +62 -25
  18. data/lib/samagotchi/idle_recap.rb +5 -2
  19. data/lib/samagotchi/idle_target.rb +30 -7
  20. data/lib/samagotchi/llm/adapters.rb +41 -0
  21. data/lib/samagotchi/llm/anthropic_assembly.rb +210 -0
  22. data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
  23. data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
  24. data/lib/samagotchi/llm/api_key.rb +26 -9
  25. data/lib/samagotchi/llm/chat_loop.rb +113 -14
  26. data/lib/samagotchi/llm/dialect.rb +57 -0
  27. data/lib/samagotchi/llm/errors.rb +81 -12
  28. data/lib/samagotchi/llm/model_listing.rb +111 -0
  29. data/lib/samagotchi/llm/model_result.rb +9 -2
  30. data/lib/samagotchi/llm/openai_chat.rb +46 -93
  31. data/lib/samagotchi/model_profile.rb +1 -1
  32. data/lib/samagotchi/reasoning_history.rb +14 -4
  33. data/lib/samagotchi/recap_setup.rb +2 -1
  34. data/lib/samagotchi/self_report.rb +18 -28
  35. data/lib/samagotchi/session.rb +3 -2
  36. data/lib/samagotchi/session_metrics.rb +6 -11
  37. data/lib/samagotchi/steer_cut.rb +4 -0
  38. data/lib/samagotchi/terminal_ui/attached_loop.rb +8 -2
  39. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  40. data/lib/samagotchi/terminal_ui/formatting.rb +4 -3
  41. data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
  42. data/lib/samagotchi/turn_note.rb +22 -5
  43. data/lib/samagotchi/version.rb +1 -1
  44. data/lib/samagotchi/web/app.rb +2 -1
  45. data/lib/samagotchi/web/public/activity.js +3 -0
  46. data/lib/samagotchi/web/public/app.js +90 -13
  47. data/lib/samagotchi/web/public/delivery_line.js +228 -0
  48. data/lib/samagotchi/web/public/index.html +29 -0
  49. data/lib/samagotchi/web/public/live_turn.js +8 -0
  50. data/lib/samagotchi/web/public/stage_model.js +27 -7
  51. data/lib/samagotchi/web/public/stage_view.js +32 -10
  52. data/lib/samagotchi/web/public/stream_handlers.js +8 -2
  53. data/lib/samagotchi/web/public/turn_events.js +22 -8
  54. data/lib/samagotchi/web/public/turn_html.js +20 -4
  55. data/lib/samagotchi/web/public/turn_model.js +16 -9
  56. data/lib/samagotchi/web/public/turn_view.js +6 -4
  57. metadata +8 -1
@@ -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 hosts.
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 = IdleClient.for(target, timeout: timeout)
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 rest as HostRegistry::HostEntry
19
- # describes them, nil when unset; models: {downcased id => HostModel}.
20
- HostConfig = Data.define(:name, :host, :port, :scheme, :url, :transport, :api, :api_key_env, :profile,
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
- location = parse_location(name, value) or return nil
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
- def self.parse_location(name, value)
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/openai_chat"
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, :profile,
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 the OpenAI chat API (the chat loop); nil and raw apis use the
51
- # raw-prompt loop.
52
- def chat? = api == :openai
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). Only chat hosts use it for turns.
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::OpenAIChat.for(entry, models_ttl: entry.models_ttl,
225
- first_token_timeout: entry.first_token_limit)
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.openai_base_url]
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/openai_chat"
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 OpenAIChat: one plain request, no
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
- def initialize(model:, base_url: nil, api_key_env: nil, timeout: DEFAULT_TIMEOUT_SECONDS, env: ENV, purpose: "recap")
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
- @chat = LLM::OpenAIChat.new(base_url: base_url.to_s, host_name: purpose, api_key_env: api_key_env,
54
- stream: false, retries: false, timeout: timeout, env: env, purpose: purpose)
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
- def summarize(prompt)
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? && !@thinking_refused
153
+ raise unless e.reasoning_refused? && next_thinking_step
138
154
 
139
- # The host won't turn thinking off (gpt-oss): ask again without the
140
- # fields, and leave them out from now on.
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
- def request(messages, max_tokens, cancel_controller, options = {})
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, **thinking_fields, **options })
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). None once the host refused them.
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
- @thinking_refused ? {} : Thinking.chat_fields(:off)
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 = [asked.base_url, asked.api_key_env, asked.model]
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 OpenAI API, asked through its
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 OpenAI API base
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
- IdleTarget = Data.define(:base_url, :api_key_env, :model, :label) do
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
- new(base_url: resolved.openai_base_url, api_key_env: resolved.entry.api_key_env, model: resolved.bare_model,
36
- label: name.to_s.strip)
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.openai_base_url
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