samagotchi 0.63.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -1
  3. data/docs/architecture.md +32 -5
  4. data/docs/cli.md +41 -9
  5. data/docs/configuration.md +143 -11
  6. data/docs/internals/engine.md +25 -6
  7. data/docs/plugins.md +7 -0
  8. data/docs/sessions.md +14 -10
  9. data/docs/sub-agent.md +21 -4
  10. data/docs/testing.md +6 -0
  11. data/lib/samagotchi/answer_command.rb +5 -1
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  13. data/lib/samagotchi/bridge.rb +29 -5
  14. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -3
  16. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/child_reports.rb +7 -2
  19. data/lib/samagotchi/cli/parent_wait.rb +9 -2
  20. data/lib/samagotchi/client.rb +4 -2
  21. data/lib/samagotchi/composer_route.rb +90 -0
  22. data/lib/samagotchi/config.rb +11 -7
  23. data/lib/samagotchi/continue_offer.rb +17 -3
  24. data/lib/samagotchi/delivery.rb +13 -8
  25. data/lib/samagotchi/engine.rb +41 -7
  26. data/lib/samagotchi/generation_phase.rb +17 -0
  27. data/lib/samagotchi/host_config.rb +33 -12
  28. data/lib/samagotchi/host_model.rb +2 -2
  29. data/lib/samagotchi/host_registry.rb +34 -11
  30. data/lib/samagotchi/idle_client.rb +62 -25
  31. data/lib/samagotchi/idle_recap.rb +5 -2
  32. data/lib/samagotchi/idle_target.rb +30 -7
  33. data/lib/samagotchi/interrupted_turn.rb +7 -3
  34. data/lib/samagotchi/last_turn.rb +5 -2
  35. data/lib/samagotchi/llm/adapters.rb +41 -0
  36. data/lib/samagotchi/llm/anthropic_assembly.rb +210 -0
  37. data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
  38. data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
  39. data/lib/samagotchi/llm/api_key.rb +26 -9
  40. data/lib/samagotchi/llm/chat_loop.rb +113 -14
  41. data/lib/samagotchi/llm/dialect.rb +57 -0
  42. data/lib/samagotchi/llm/errors.rb +81 -12
  43. data/lib/samagotchi/llm/model_listing.rb +111 -0
  44. data/lib/samagotchi/llm/model_result.rb +9 -2
  45. data/lib/samagotchi/llm/openai_chat.rb +46 -93
  46. data/lib/samagotchi/model_profile.rb +1 -1
  47. data/lib/samagotchi/pending_input_queue.rb +10 -3
  48. data/lib/samagotchi/plugin/sessions.rb +27 -5
  49. data/lib/samagotchi/reasoning_history.rb +14 -4
  50. data/lib/samagotchi/recap_setup.rb +2 -1
  51. data/lib/samagotchi/reply_wait.rb +171 -6
  52. data/lib/samagotchi/self_report.rb +18 -28
  53. data/lib/samagotchi/send_command.rb +63 -19
  54. data/lib/samagotchi/session.rb +3 -2
  55. data/lib/samagotchi/session_commands.rb +1 -1
  56. data/lib/samagotchi/session_inbox.rb +63 -7
  57. data/lib/samagotchi/session_manager.rb +49 -20
  58. data/lib/samagotchi/session_metrics.rb +6 -11
  59. data/lib/samagotchi/steer_cut.rb +4 -0
  60. data/lib/samagotchi/terminal_ui/attached_loop.rb +55 -28
  61. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  62. data/lib/samagotchi/terminal_ui/formatting.rb +7 -22
  63. data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
  64. data/lib/samagotchi/terminal_ui.rb +72 -57
  65. data/lib/samagotchi/tool_activity.rb +5 -5
  66. data/lib/samagotchi/tools/delegate.rb +28 -1
  67. data/lib/samagotchi/tools/delegate_cursor.rb +9 -4
  68. data/lib/samagotchi/tools/delegate_wait.rb +17 -5
  69. data/lib/samagotchi/tools/execute.rb +11 -4
  70. data/lib/samagotchi/turn_checkpoint.rb +9 -3
  71. data/lib/samagotchi/turn_note.rb +22 -5
  72. data/lib/samagotchi/version.rb +1 -1
  73. data/lib/samagotchi/web/app.rb +2 -1
  74. data/lib/samagotchi/web/public/activity.js +3 -0
  75. data/lib/samagotchi/web/public/app.js +96 -13
  76. data/lib/samagotchi/web/public/commands.js +8 -8
  77. data/lib/samagotchi/web/public/composer.js +3 -3
  78. data/lib/samagotchi/web/public/delivery_line.js +228 -0
  79. data/lib/samagotchi/web/public/index.html +29 -0
  80. data/lib/samagotchi/web/public/live_turn.js +8 -0
  81. data/lib/samagotchi/web/public/stage_model.js +27 -7
  82. data/lib/samagotchi/web/public/stage_view.js +32 -10
  83. data/lib/samagotchi/web/public/stream_handlers.js +8 -2
  84. data/lib/samagotchi/web/public/timing.js +2 -1
  85. data/lib/samagotchi/web/public/turn_events.js +22 -8
  86. data/lib/samagotchi/web/public/turn_html.js +20 -4
  87. data/lib/samagotchi/web/public/turn_model.js +29 -12
  88. data/lib/samagotchi/web/public/turn_view.js +6 -4
  89. data/lib/samagotchi/worker.rb +7 -5
  90. metadata +9 -1
@@ -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
@@ -9,9 +9,13 @@ module Samagotchi
9
9
  #
10
10
  # id: the turn's id (nil when unknown); started_at / at: iso8601, when the
11
11
  # turn began and when this checkpoint was taken; iteration: the loop
12
- # boundaries it had passed (Integer).
13
- InterruptedTurn = Data.define(:id, :started_at, :at, :iteration) do
14
- def initialize(id: nil, started_at: nil, at: nil, iteration: nil) = super
12
+ # boundaries it had passed (Integer); enqueued_id: the input the turn
13
+ # runs (its ack's id, LastTurn#enqueued_id once it ends; a Continue turn
14
+ # carries its offer turn's), so any process that loads the session can
15
+ # tell which turn is running (ReplyWait.own_turn); nil when it ran none,
16
+ # or a worker from before it wrote one.
17
+ InterruptedTurn = Data.define(:id, :started_at, :at, :iteration, :enqueued_id) do
18
+ def initialize(id: nil, started_at: nil, at: nil, iteration: nil, enqueued_id: nil) = super
15
19
 
16
20
  # A saved record (string or symbol keys), an InterruptedTurn as it is;
17
21
  # nil for anything else.
@@ -12,9 +12,12 @@ module Samagotchi
12
12
  # children's reports) or "context" (one an attached context source's
13
13
  # change started); exhausted/limit: the turn ran out of iterations at
14
14
  # limit. The rest are its stop facts (LastTurn::STOP_FACTS), only what is
15
- # known. A field may be nil (unset; left out of the file).
15
+ # known. enqueued_id: the input's id when the turn ran one (a POST /turn
16
+ # ack's, an input file's), so a wait for a queued message (`chi send
17
+ # --queue --wait`, ReplyWait's turn:) tells its own turn's end from
18
+ # another's. A field may be nil (unset; left out of the file).
16
19
  LastTurn = Data.define(:outcome, :ended_at, :seconds, :origin, :exhausted, :limit,
17
- :error_kind, :retryable, :kept_steps, :cancel_reason, :stopped_by) do
20
+ :error_kind, :retryable, :kept_steps, :cancel_reason, :stopped_by, :enqueued_id) do
18
21
  def initialize(**fields)
19
22
  super(**members.to_h { |m| [m, nil] }, **fields)
20
23
  end
@@ -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
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "openai_chat"
4
+ require_relative "usage"
5
+
6
+ module Samagotchi
7
+ module LLM
8
+ # A Messages response put together as ChatResponse, from a whole body
9
+ # (.from_message) or a stream's events (#add, by content-block index):
10
+ # the content blocks
11
+ # in order (kept as received, string keys: ChatResponse#provider_content,
12
+ # which ChatLoop saves and sends back), the text and reasoning they
13
+ # carry, the tool calls, the usage and the stop reason in the chat
14
+ # loop's terms.
15
+ #
16
+ # Usage: Messages counts input_tokens without the cache, so the prompt
17
+ # is input + cache reads + cache writes, in the OpenAI shape the rest of
18
+ # chi reads (TokenUsage: prompt_tokens_details.cached_tokens /
19
+ # cache_write_tokens). Every field is optional (Splash sends no cache
20
+ # writes), and a later value wins per field.
21
+ class AnthropicAssembly
22
+ # stop_reason → ChatResponse#finish_reason (the chat loop's words);
23
+ # pause_turn and anything else pass as they are.
24
+ FINISH_REASONS = {
25
+ "end_turn" => "stop", "stop_sequence" => "stop", "max_tokens" => "length",
26
+ "model_context_window_exceeded" => "length", "tool_use" => "tool_calls", "refusal" => "refusal"
27
+ }.freeze
28
+ USAGE_KEYS = %w[input_tokens output_tokens cache_read_input_tokens cache_creation_input_tokens].freeze
29
+ # Blocks whose text is the answer or the reasoning.
30
+ THINKING = "thinking"
31
+ TEXT = "text"
32
+
33
+ def self.finish_reason(stop_reason) = stop_reason && FINISH_REASONS.fetch(stop_reason.to_s, stop_reason.to_s)
34
+
35
+ # A non-streamed body (a `message` object).
36
+ def self.from_message(body) = new.tap { |assembly| assembly.add_message(body) }
37
+
38
+ def initialize
39
+ @blocks = []
40
+ # A streamed block by its index, and a tool_use's input JSON so far.
41
+ @by_index = {}
42
+ @json = {}
43
+ @usage = {}
44
+ @stop_reason = nil
45
+ @stop_details = nil
46
+ @model = nil
47
+ end
48
+
49
+ # A whole message: its model, usage, stop reason and blocks.
50
+ def add_message(message)
51
+ return unless message.is_a?(Hash)
52
+
53
+ @model = served(message["model"]) || @model
54
+ merge_usage(message["usage"])
55
+ @stop_reason = message["stop_reason"] if message["stop_reason"]
56
+ @stop_details = message["stop_details"] if message["stop_details"].is_a?(Hash)
57
+ Array(message["content"]).each { |block| @blocks << block.dup if block.is_a?(Hash) }
58
+ end
59
+
60
+ # One streamed event: message_start (served model, usage),
61
+ # content_block_start (text / thinking / redacted_thinking / tool_use),
62
+ # content_block_delta (text_delta, thinking_delta, signature_delta,
63
+ # input_json_delta), content_block_stop, message_delta (stop reason,
64
+ # stop_details, usage); ping, message_stop and anything else change nothing.
65
+ # @return [Array(String, String)] the event's text and reasoning
66
+ def add(event)
67
+ case event["type"]
68
+ when "message_start" then start_message(event["message"])
69
+ when "content_block_start" then start_block(event["index"], event["content_block"])
70
+ when "content_block_delta" then add_delta(@by_index[event["index"]], event["index"], event["delta"])
71
+ when "message_delta"
72
+ if event["delta"].is_a?(Hash)
73
+ @stop_reason = event.dig("delta", "stop_reason") || @stop_reason
74
+ @stop_details = event.dig("delta", "stop_details") if event.dig("delta", "stop_details").is_a?(Hash)
75
+ end
76
+ merge_usage(event["usage"])
77
+ ["", ""]
78
+ else ["", ""]
79
+ end
80
+ end
81
+
82
+ # Whether a streamed +event+ shows something (a block started, a delta):
83
+ # what the first-token limit waits for.
84
+ def self.shows?(event) = %w[content_block_start content_block_delta].include?(event["type"])
85
+
86
+ # Whether a streamed +event+ carries part of a tool call.
87
+ def self.tool_call_delta?(event)
88
+ case event["type"]
89
+ when "content_block_start" then event.dig("content_block", "type") == "tool_use"
90
+ when "content_block_delta" then event.dig("delta", "type") == "input_json_delta"
91
+ else false
92
+ end
93
+ end
94
+
95
+ # +event+ as on_delta passes it on: one that carries usage gets the
96
+ # usage so far in the OpenAI shape as its `usage` (SessionMetrics,
97
+ # ContextStatus and ctx.js read that).
98
+ def with_usage(event)
99
+ return event unless %w[message_start message_delta].include?(event["type"]) && (payload = usage_payload)
100
+
101
+ event.merge("usage" => payload)
102
+ end
103
+
104
+ # The usage so far as an OpenAI-shaped `usage` object, nil when the
105
+ # server sent no counts.
106
+ def usage_payload
107
+ return nil if @usage.empty?
108
+
109
+ read = @usage["cache_read_input_tokens"].to_i
110
+ write = @usage["cache_creation_input_tokens"].to_i
111
+ { "prompt_tokens" => @usage["input_tokens"].to_i + read + write, "completion_tokens" => @usage["output_tokens"].to_i,
112
+ "prompt_tokens_details" => { "cached_tokens" => read, "cache_write_tokens" => write } }
113
+ end
114
+
115
+ def response
116
+ blocks = content
117
+ ChatResponse.new(text: joined(blocks, TEXT, "text"), reasoning: joined(blocks, THINKING, "thinking"),
118
+ tool_calls: tool_calls, usage: usage, finish_reason: self.class.finish_reason(@stop_reason),
119
+ model: @model, provider_content: blocks, stop_details: @stop_details)
120
+ end
121
+
122
+ private
123
+
124
+ def usage
125
+ payload = usage_payload
126
+ (payload && Usage.from_payload({ "usage" => payload })) || Usage.none
127
+ end
128
+
129
+ def start_message(message)
130
+ return ["", ""] unless message.is_a?(Hash)
131
+
132
+ @model = served(message["model"]) || @model
133
+ merge_usage(message["usage"])
134
+ ["", ""]
135
+ end
136
+
137
+ def start_block(index, block)
138
+ return ["", ""] unless block.is_a?(Hash)
139
+
140
+ block = block.dup
141
+ @blocks << block
142
+ @by_index[index] = block
143
+ case block["type"]
144
+ when TEXT then [block["text"].to_s, ""]
145
+ when THINKING then ["", block["thinking"].to_s]
146
+ else ["", ""]
147
+ end
148
+ end
149
+
150
+ def add_delta(block, index, delta)
151
+ return ["", ""] unless block && delta.is_a?(Hash)
152
+
153
+ case delta["type"]
154
+ when "text_delta"
155
+ block["text"] = block["text"].to_s + delta["text"].to_s
156
+ [delta["text"].to_s, ""]
157
+ when "thinking_delta"
158
+ block["thinking"] = block["thinking"].to_s + delta["thinking"].to_s
159
+ ["", delta["thinking"].to_s]
160
+ when "signature_delta"
161
+ block["signature"] = block["signature"].to_s + delta["signature"].to_s
162
+ ["", ""]
163
+ when "input_json_delta"
164
+ (@json[index] ||= +"") << delta["partial_json"].to_s
165
+ block["input"] = @json[index]
166
+ ["", ""]
167
+ else ["", ""]
168
+ end
169
+ end
170
+
171
+ def merge_usage(usage)
172
+ return unless usage.is_a?(Hash)
173
+
174
+ USAGE_KEYS.each { |key| @usage[key] = usage[key] if usage[key].is_a?(Integer) }
175
+ end
176
+
177
+ # The blocks to keep and send back: an empty or blank text block is
178
+ # no text (Splash opens one after a thinking cut by max_tokens, and
179
+ # Messages refuses one sent back), a tool_use's input is an object,
180
+ # and a thinking block has a signature, "" when it came without one
181
+ # (a sent-back thinking block needs the key; Splash sends "", a
182
+ # provider may send none).
183
+ def content
184
+ @blocks.filter_map do |block|
185
+ type = block["type"].to_s
186
+ next if type == TEXT && block["text"].to_s.strip.empty?
187
+ next block.merge("signature" => "") if type == THINKING && !block.key?("signature")
188
+ next block unless type == "tool_use"
189
+
190
+ input = OpenAIChat.parse_arguments(block["input"])
191
+ block.merge("input" => input.is_a?(Hash) ? input : {})
192
+ end
193
+ end
194
+
195
+ def joined(blocks, type, key)
196
+ blocks.select { |block| block["type"] == type }.map { |block| block[key].to_s }.join
197
+ end
198
+
199
+ # The calls with their arguments as OpenAIChat.parse_arguments reads
200
+ # them: invalid JSON stays a String, so the tool reports it.
201
+ def tool_calls
202
+ @blocks.select { |block| block["type"] == "tool_use" }.map do |block|
203
+ ToolCall.new(id: block["id"], name: block["name"].to_s, arguments: OpenAIChat.parse_arguments(block["input"]))
204
+ end
205
+ end
206
+
207
+ def served(model) = model.is_a?(String) && !model.strip.empty? ? model : nil
208
+ end
209
+ end
210
+ end