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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +86 -1
- data/docs/architecture.md +32 -5
- data/docs/cli.md +41 -9
- data/docs/configuration.md +143 -11
- data/docs/internals/engine.md +25 -6
- data/docs/plugins.md +7 -0
- data/docs/sessions.md +14 -10
- data/docs/sub-agent.md +21 -4
- data/docs/testing.md +6 -0
- data/lib/samagotchi/answer_command.rb +5 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
- data/lib/samagotchi/bridge.rb +29 -5
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -3
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/child_reports.rb +7 -2
- data/lib/samagotchi/cli/parent_wait.rb +9 -2
- data/lib/samagotchi/client.rb +4 -2
- data/lib/samagotchi/composer_route.rb +90 -0
- data/lib/samagotchi/config.rb +11 -7
- data/lib/samagotchi/continue_offer.rb +17 -3
- data/lib/samagotchi/delivery.rb +13 -8
- data/lib/samagotchi/engine.rb +41 -7
- data/lib/samagotchi/generation_phase.rb +17 -0
- data/lib/samagotchi/host_config.rb +33 -12
- data/lib/samagotchi/host_model.rb +2 -2
- 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/interrupted_turn.rb +7 -3
- data/lib/samagotchi/last_turn.rb +5 -2
- 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/pending_input_queue.rb +10 -3
- data/lib/samagotchi/plugin/sessions.rb +27 -5
- data/lib/samagotchi/reasoning_history.rb +14 -4
- data/lib/samagotchi/recap_setup.rb +2 -1
- data/lib/samagotchi/reply_wait.rb +171 -6
- data/lib/samagotchi/self_report.rb +18 -28
- data/lib/samagotchi/send_command.rb +63 -19
- data/lib/samagotchi/session.rb +3 -2
- data/lib/samagotchi/session_commands.rb +1 -1
- data/lib/samagotchi/session_inbox.rb +63 -7
- data/lib/samagotchi/session_manager.rb +49 -20
- data/lib/samagotchi/session_metrics.rb +6 -11
- data/lib/samagotchi/steer_cut.rb +4 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +55 -28
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -22
- data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
- data/lib/samagotchi/terminal_ui.rb +72 -57
- data/lib/samagotchi/tool_activity.rb +5 -5
- data/lib/samagotchi/tools/delegate.rb +28 -1
- data/lib/samagotchi/tools/delegate_cursor.rb +9 -4
- data/lib/samagotchi/tools/delegate_wait.rb +17 -5
- data/lib/samagotchi/tools/execute.rb +11 -4
- data/lib/samagotchi/turn_checkpoint.rb +9 -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 +96 -13
- data/lib/samagotchi/web/public/commands.js +8 -8
- data/lib/samagotchi/web/public/composer.js +3 -3
- 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/timing.js +2 -1
- 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 +29 -12
- data/lib/samagotchi/web/public/turn_view.js +6 -4
- data/lib/samagotchi/worker.rb +7 -5
- metadata +9 -1
|
@@ -5,14 +5,23 @@ require_relative "errors"
|
|
|
5
5
|
module Samagotchi
|
|
6
6
|
module LLM
|
|
7
7
|
# A host's API key: the environment variable its api_key_env: names,
|
|
8
|
-
# sent
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
|
|
8
|
+
# sent on every request LLM::HTTP makes for the host (llama.cpp started
|
|
9
|
+
# with --api-key, or a provider) in the header its +scheme+ names:
|
|
10
|
+
# :bearer `Authorization: Bearer <key>`, :x_api_key `x-api-key: <key>`
|
|
11
|
+
# (Anthropic's Messages API, which refuses a request carrying both).
|
|
12
|
+
# The key itself never goes into a message.
|
|
13
|
+
ApiKey = Data.define(:env_name, :host, :env, :scheme) do
|
|
12
14
|
# nil for a host without api_key_env: its requests carry no header.
|
|
13
|
-
|
|
15
|
+
# @param scheme [Symbol, nil] one of ApiKey::SCHEMES; nil is :bearer
|
|
16
|
+
def self.for(env_name, host:, env: ENV, scheme: nil)
|
|
14
17
|
name = env_name.to_s.strip
|
|
15
|
-
name.empty? ? nil : new(env_name: name, host: host.to_s, env: env)
|
|
18
|
+
name.empty? ? nil : new(env_name: name, host: host.to_s, env: env, scheme: scheme || :bearer)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(env_name:, host:, env:, scheme: :bearer)
|
|
22
|
+
raise ArgumentError, "unknown auth scheme #{scheme.inspect}" unless self.class::SCHEMES.include?(scheme)
|
|
23
|
+
|
|
24
|
+
super
|
|
16
25
|
end
|
|
17
26
|
|
|
18
27
|
# What to try after a 401/403 from a host that has no api_key_env.
|
|
@@ -20,22 +29,30 @@ module Samagotchi
|
|
|
20
29
|
"the server may want an API key: put it in an environment variable and name it with api_key_env: on host #{host}"
|
|
21
30
|
end
|
|
22
31
|
|
|
23
|
-
# Sets the header
|
|
32
|
+
# Sets the scheme's header (exactly one of the two), or raises
|
|
33
|
+
# AuthError when the variable is not set.
|
|
24
34
|
def authorize(request)
|
|
25
35
|
key = env[env_name].to_s
|
|
26
36
|
raise AuthError.new("#{host}: set #{env_name} (the API key for host #{host})", host: host) if key.strip.empty?
|
|
27
37
|
|
|
28
|
-
|
|
38
|
+
if scheme == :x_api_key
|
|
39
|
+
request.delete("Authorization")
|
|
40
|
+
request["x-api-key"] = key
|
|
41
|
+
else
|
|
42
|
+
request.delete("x-api-key")
|
|
43
|
+
request["Authorization"] = "Bearer #{key}"
|
|
44
|
+
end
|
|
29
45
|
end
|
|
30
46
|
|
|
31
47
|
# What to try after a 401/403 with the key sent.
|
|
32
48
|
def hint = "check #{env_name} (the API key for host #{host})"
|
|
33
49
|
|
|
34
50
|
# Without the environment: it holds this key and every other secret.
|
|
35
|
-
def inspect = "#<#{self.class.name} #{env_name} host=#{host}>"
|
|
51
|
+
def inspect = "#<#{self.class.name} #{env_name} host=#{host}#{" scheme=#{scheme}" unless scheme == :bearer}>"
|
|
36
52
|
alias_method :to_s, :inspect
|
|
37
53
|
|
|
38
54
|
def pretty_print(printer) = printer.text(inspect)
|
|
39
55
|
end
|
|
56
|
+
ApiKey::SCHEMES = %i[bearer x_api_key].freeze
|
|
40
57
|
end
|
|
41
58
|
end
|
|
@@ -49,6 +49,10 @@ module Samagotchi
|
|
|
49
49
|
class ChatLoop < ModelBackend
|
|
50
50
|
include LLM::StreamDrops
|
|
51
51
|
|
|
52
|
+
# The line after an answer an OpenAI-compatible host's content filter
|
|
53
|
+
# cut (finish_reason content_filter with text): kept, not asked again.
|
|
54
|
+
FILTERED_MARK = "[cut off by the provider's content filter]"
|
|
55
|
+
|
|
52
56
|
attr_accessor :adapter
|
|
53
57
|
# The session every request is for; Engine sets it with the adapter.
|
|
54
58
|
# Goes out as a Session-Id header (OpenAIChat#chat).
|
|
@@ -147,6 +151,11 @@ module Samagotchi
|
|
|
147
151
|
conversation = llm_context_view.messages(conversation)
|
|
148
152
|
paired = paired_call_ids(conversation)
|
|
149
153
|
boundary = reasoning_boundary(conversation, since: since, level: reasoning)
|
|
154
|
+
# The last reply with tool calls keeps its thinking blocks past the
|
|
155
|
+
# boundary: a run stopped between steps (Stop, the step limit) left
|
|
156
|
+
# its tool_use last, and Messages wants the thinking before it.
|
|
157
|
+
last_calls = conversation.rindex { |entry| entry[:role].to_s == "model" }
|
|
158
|
+
last_calls = nil unless last_calls && !Array(conversation[last_calls][:tool_calls]).empty?
|
|
150
159
|
plan = ImagePlan.new(conversation, vision)
|
|
151
160
|
tool_images = []
|
|
152
161
|
wire = []
|
|
@@ -162,6 +171,8 @@ module Samagotchi
|
|
|
162
171
|
when "model"
|
|
163
172
|
message = assistant_message(entry, paired)
|
|
164
173
|
message[:reasoning_content] = entry[:thinking] if boundary && index >= boundary && thought?(entry)
|
|
174
|
+
blocks = replay_blocks(entry, paired, keep_thinking: (boundary && index >= boundary) || index == last_calls)
|
|
175
|
+
message[:anthropic_content] = blocks if blocks
|
|
165
176
|
wire << message
|
|
166
177
|
when "tool_response"
|
|
167
178
|
id = entry[:tool_call_id]
|
|
@@ -226,13 +237,20 @@ module Samagotchi
|
|
|
226
237
|
|
|
227
238
|
# The turn's ReasoningHistory level (the kernel's turn_settings); drop
|
|
228
239
|
# without settings, and for a +model+ whose host refused it
|
|
229
|
-
# (#reasoning_history_refused!).
|
|
240
|
+
# (#reasoning_history_refused!). An adapter that sends its blocks back
|
|
241
|
+
# (#replays_content?) is floored at turn: the running run's thinking
|
|
242
|
+
# blocks always go back (a tool loop with thinking needs the last
|
|
243
|
+
# assistant turn's), whatever the level says.
|
|
230
244
|
def reasoning_history(model = nil)
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
245
|
+
level = model && @reasoning_history_refused&.include?(model) ? :drop : @kernel.turn_settings&.reasoning_history
|
|
246
|
+
level ||= :drop
|
|
247
|
+
level == :drop && replays_content? ? :turn : level
|
|
234
248
|
end
|
|
235
249
|
|
|
250
|
+
# Whether the adapter sends a model entry's saved blocks back verbatim
|
|
251
|
+
# (AnthropicMessages); the OpenAI wire doesn't.
|
|
252
|
+
def replays_content? = @adapter.respond_to?(:replays_content?) && @adapter.replays_content?
|
|
253
|
+
|
|
236
254
|
# The host refused +model+'s reasoning sent back: send none from now on
|
|
237
255
|
# (this worker's lifetime, as #thinking_refused!).
|
|
238
256
|
def reasoning_history_refused!(model)
|
|
@@ -252,12 +270,13 @@ module Samagotchi
|
|
|
252
270
|
# The turn's thinking level (the kernel's turn_settings).
|
|
253
271
|
def thinking = @kernel.turn_settings.thinking || Thinking::DEFAULT
|
|
254
272
|
|
|
255
|
-
# The request fields the thinking level adds
|
|
256
|
-
#
|
|
273
|
+
# The request fields the thinking level adds, as the adapter's wire
|
|
274
|
+
# takes them (OpenAIChat#thinking_fields: Thinking.chat_fields); none
|
|
275
|
+
# for a model whose host refused them (#thinking_refused!).
|
|
257
276
|
def thinking_fields(model = nil)
|
|
258
277
|
return {} if model && (@thinking_refused ||= Set.new).include?(model)
|
|
259
278
|
|
|
260
|
-
|
|
279
|
+
adapter.thinking_fields(thinking)
|
|
261
280
|
end
|
|
262
281
|
|
|
263
282
|
# The host refused +model+'s thinking fields: leave them out from now on.
|
|
@@ -270,14 +289,26 @@ module Samagotchi
|
|
|
270
289
|
# under the sampling (a sampling key wins, chat_template_kwargs merges
|
|
271
290
|
# per sub-key), the empty-answer retry's temperature on top, then every
|
|
272
291
|
# null dropped at any depth (a sampling null means "don't send it").
|
|
292
|
+
# A +model+ whose host refused a sampling field (#sampling_refused!)
|
|
293
|
+
# gets none of SAMPLING_KEYS.
|
|
273
294
|
def request_options(retry_generation: false, model: nil)
|
|
274
295
|
max_tokens = Samagotchi::Config.max_tokens
|
|
275
296
|
options = deep_merge(thinking_fields(model), sampling)
|
|
276
297
|
options = { max_tokens: max_tokens }.merge(options) if max_tokens
|
|
277
298
|
options = EmptyAnswerRetry.sampling(options) if retry_generation
|
|
299
|
+
options = options.except(*SAMPLING_KEYS) if model && @sampling_refused&.include?(model)
|
|
278
300
|
deep_compact(options)
|
|
279
301
|
end
|
|
280
302
|
|
|
303
|
+
# The fields a sampling refusal (BadRequest#sampling_refused?) drops.
|
|
304
|
+
SAMPLING_KEYS = %i[temperature top_p top_k].freeze
|
|
305
|
+
|
|
306
|
+
# The host refused a sampling field for +model+: send none of
|
|
307
|
+
# SAMPLING_KEYS from now on (this worker's lifetime, as #thinking_refused!).
|
|
308
|
+
def sampling_refused!(model)
|
|
309
|
+
(@sampling_refused ||= Set.new) << model
|
|
310
|
+
end
|
|
311
|
+
|
|
281
312
|
def strip_model_thought(text) = @kernel.strip_model_thought(text)
|
|
282
313
|
|
|
283
314
|
def tool_runner
|
|
@@ -323,6 +354,7 @@ module Samagotchi
|
|
|
323
354
|
message[:turn_id] = entry[:turn_id] if entry[:turn_id]
|
|
324
355
|
message[:turn_start] = true if entry[:turn_start] == true
|
|
325
356
|
message[:thinking] = entry[:thinking] if entry[:thinking].is_a?(String) && !entry[:thinking].empty?
|
|
357
|
+
message[:anthropic_content] = entry[:anthropic_content] if entry[:anthropic_content].is_a?(Array)
|
|
326
358
|
ToolResponse::SAVED_KEYS.each { |key| message[key] = entry[key] if entry[key] }
|
|
327
359
|
message[TurnNote::RETRY_NUDGE] = true if TurnNote.retry_nudge?(entry)
|
|
328
360
|
marker = TurnNote.empty_answer(entry)
|
|
@@ -408,6 +440,24 @@ module Samagotchi
|
|
|
408
440
|
|
|
409
441
|
def thought?(entry) = entry[:thinking].is_a?(String) && !entry[:thinking].strip.empty?
|
|
410
442
|
|
|
443
|
+
THINKING_BLOCKS = %w[thinking redacted_thinking].freeze
|
|
444
|
+
|
|
445
|
+
# A model entry's saved blocks (anthropic_content) to send back
|
|
446
|
+
# verbatim, for an adapter that replays them: nil when there are none,
|
|
447
|
+
# for an interrupted entry (its partial blocks have no signature), and
|
|
448
|
+
# for one with unpaired calls (it goes as flattened text: a tool_use
|
|
449
|
+
# without its tool_result would fail every later request). Before the
|
|
450
|
+
# reasoning boundary (+keep_thinking+ false) the thinking blocks are
|
|
451
|
+
# left out, which removes them only from the front of the history.
|
|
452
|
+
def replay_blocks(entry, paired, keep_thinking:)
|
|
453
|
+
blocks = entry[:anthropic_content]
|
|
454
|
+
return nil unless replays_content? && blocks.is_a?(Array) && !blocks.empty? && !entry[:interrupted]
|
|
455
|
+
return nil unless Array(entry[:tool_calls]).all? { |call| paired.include?(call[:id]) }
|
|
456
|
+
|
|
457
|
+
blocks = blocks.reject { |block| block.is_a?(Hash) && THINKING_BLOCKS.include?((block["type"] || block[:type]).to_s) } unless keep_thinking
|
|
458
|
+
blocks.empty? ? nil : blocks
|
|
459
|
+
end
|
|
460
|
+
|
|
411
461
|
def scrub(text)
|
|
412
462
|
text.encoding == Encoding::UTF_8 && !text.valid_encoding? ? text.scrub("?") : text
|
|
413
463
|
end
|
|
@@ -415,6 +465,11 @@ module Samagotchi
|
|
|
415
465
|
# One turn's state: the conversation, the stream sink and tool
|
|
416
466
|
# activity.
|
|
417
467
|
class Run
|
|
468
|
+
# Messages' refusal; OpenAI's content filter, a refusal only when
|
|
469
|
+
# it left no text (else the text stays, marked FILTERED_MARK).
|
|
470
|
+
REFUSAL = "refusal"
|
|
471
|
+
FILTERED = "content_filter"
|
|
472
|
+
|
|
418
473
|
# +start+: where the run a step-limit Continue resumes started (nil:
|
|
419
474
|
# this run starts at the conversation's end).
|
|
420
475
|
def initialize(loop, conversation, on_stream_event, cancel_controller, model_name, pending_input, start: nil)
|
|
@@ -457,13 +512,22 @@ module Samagotchi
|
|
|
457
512
|
end
|
|
458
513
|
|
|
459
514
|
last_text = @loop.strip_model_thought(response.text)
|
|
460
|
-
|
|
515
|
+
# A provider's refusal (or its content filter) ends the turn here:
|
|
516
|
+
# tool calls that came with it are not run.
|
|
517
|
+
@refusal = refusal_of(response, last_text)
|
|
518
|
+
stopped = @refusal || response.finish_reason.to_s == FILTERED
|
|
519
|
+
if response.tool_calls.empty? || stopped
|
|
520
|
+
# A refusal is no answer, never asked again; text it streamed
|
|
521
|
+
# before is not kept as one. A filtered answer's text stays,
|
|
522
|
+
# marked as cut by the filter.
|
|
523
|
+
last_text = "" if @refusal
|
|
524
|
+
last_text = "#{last_text}\n#{FILTERED_MARK}" if !@refusal && response.finish_reason.to_s == FILTERED
|
|
461
525
|
# Only whitespace is no answer either (as in the native loop).
|
|
462
526
|
empty = last_text.strip.empty?
|
|
463
527
|
# Kept before a merge too: the model answers the merged line
|
|
464
528
|
# knowing what it just said.
|
|
465
529
|
@conversation << with_thinking({ role: "model", content: last_text }, response) unless empty
|
|
466
|
-
retry_empty = empty &&
|
|
530
|
+
retry_empty = empty && !stopped &&
|
|
467
531
|
@empty_retry.retry_empty?(iteration: iteration, cancelled: @cancel_controller&.cancelled?,
|
|
468
532
|
finish_reason: response.finish_reason,
|
|
469
533
|
used_tokens: response.usage&.total_tokens, window_tokens: @window&.tokens)
|
|
@@ -495,6 +559,18 @@ module Samagotchi
|
|
|
495
559
|
|
|
496
560
|
private
|
|
497
561
|
|
|
562
|
+
# A provider's refusal as ModelResult#refusal ({category:} when its
|
|
563
|
+
# stop_details name one): finish reason refusal, or content_filter
|
|
564
|
+
# with no visible +text+.
|
|
565
|
+
def refusal_of(response, text)
|
|
566
|
+
reason = response.finish_reason.to_s
|
|
567
|
+
return nil unless reason == REFUSAL || (reason == FILTERED && text.strip.empty?)
|
|
568
|
+
|
|
569
|
+
details = response.stop_details
|
|
570
|
+
category = details["category"] || details[:category] if details.is_a?(Hash)
|
|
571
|
+
category.to_s.strip.empty? ? {} : { category: category.to_s }
|
|
572
|
+
end
|
|
573
|
+
|
|
498
574
|
# The hidden nudge before the next generation (EmptyAnswerRetry),
|
|
499
575
|
# which runs at the retry temperature. Returns true.
|
|
500
576
|
def nudge_empty_answer(iteration, response)
|
|
@@ -521,10 +597,14 @@ module Samagotchi
|
|
|
521
597
|
# The host's reasoning, kept on the model message as +thinking+ for
|
|
522
598
|
# the web turn view's reload (the whole of it, as the live view
|
|
523
599
|
# shows), and sent back to the model as ChatLoop#wire_messages says
|
|
524
|
-
# (reasoning_history).
|
|
600
|
+
# (reasoning_history). A wire that sends its blocks back keeps them
|
|
601
|
+
# as +anthropic_content+ (ChatResponse#provider_content: string
|
|
602
|
+
# keys, as received, so a resumed session sends the same bytes).
|
|
525
603
|
def with_thinking(message, response)
|
|
526
604
|
reasoning = response.reasoning.to_s
|
|
527
|
-
|
|
605
|
+
message = message.merge(thinking: reasoning) unless reasoning.strip.empty?
|
|
606
|
+
blocks = response.provider_content
|
|
607
|
+
blocks.is_a?(Array) && !blocks.empty? ? message.merge(anthropic_content: blocks) : message
|
|
528
608
|
end
|
|
529
609
|
|
|
530
610
|
# One streamed request. Returns [response, nil], or [reason, partial
|
|
@@ -551,7 +631,11 @@ module Samagotchi
|
|
|
551
631
|
begin
|
|
552
632
|
ask.call
|
|
553
633
|
rescue BadRequest => e
|
|
554
|
-
|
|
634
|
+
# A wire that replays its blocks sends them under the turn
|
|
635
|
+
# floor anyway: asking again without reasoning would resend
|
|
636
|
+
# the same body.
|
|
637
|
+
if e.sampling_refused? && sampling_sent?(retry_generation) then without_sampling(iteration, e, ask)
|
|
638
|
+
elsif refused?(e) && reasoning_sent? && !@loop.replays_content? then without_reasoning(iteration, e, ask)
|
|
555
639
|
elsif thinking_refused?(e) then without_thinking(iteration, e, ask)
|
|
556
640
|
else raise
|
|
557
641
|
end
|
|
@@ -613,7 +697,7 @@ module Samagotchi
|
|
|
613
697
|
event = { type: :generation_chunk, iteration: iteration, content: reasoning + content, text: content,
|
|
614
698
|
thinking: reasoning, payload: payload }
|
|
615
699
|
# A tool call streaming: a steer doesn't cut it (Engine#cut_for_steer).
|
|
616
|
-
tool_call =
|
|
700
|
+
tool_call = @loop.adapter.tool_call_delta?(payload)
|
|
617
701
|
event[:tool_call] = true if tool_call
|
|
618
702
|
streamed_any&.call if !content.to_s.empty? || !reasoning.to_s.empty? || tool_call
|
|
619
703
|
emit(event)
|
|
@@ -708,6 +792,21 @@ module Samagotchi
|
|
|
708
792
|
@without_reasoning = false
|
|
709
793
|
end
|
|
710
794
|
|
|
795
|
+
# Whether this step's request sends a sampling field.
|
|
796
|
+
def sampling_sent?(retry_generation)
|
|
797
|
+
options = @loop.request_options(retry_generation: retry_generation, model: @model_name)
|
|
798
|
+
SAMPLING_KEYS.any? { |key| options.key?(key) }
|
|
799
|
+
end
|
|
800
|
+
|
|
801
|
+
# Once per model: asked again without the sampling fields (the
|
|
802
|
+
# thinking fields stay: an effort level with the empty-answer
|
|
803
|
+
# retry's temperature keeps thinking on).
|
|
804
|
+
def without_sampling(iteration, error, ask)
|
|
805
|
+
@loop.sampling_refused!(@model_name)
|
|
806
|
+
emit(type: :sampling_refused, iteration: iteration, model: @model_name, detail: error.detail)
|
|
807
|
+
ask.call
|
|
808
|
+
end
|
|
809
|
+
|
|
711
810
|
# Once per model: asked again without the thinking fields.
|
|
712
811
|
def without_thinking(iteration, error, ask)
|
|
713
812
|
@loop.thinking_refused!(@model_name)
|
|
@@ -814,7 +913,7 @@ module Samagotchi
|
|
|
814
913
|
@loop.apply_llm_context!(@conversation, moment: :turn_end, emit: method(:emit)) unless exhausted
|
|
815
914
|
ModelResult.new(text: text, conversation: @loop.plain(@conversation), exhausted: exhausted,
|
|
816
915
|
tool_activity: @tool_activity, empty_steps: @empty_steps, empty_retries: @empty_retry.attempts,
|
|
817
|
-
context_status: @context.display)
|
|
916
|
+
context_status: @context.display, refusal: (@refusal unless exhausted))
|
|
818
917
|
end
|
|
819
918
|
|
|
820
919
|
def emit(event)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module LLM
|
|
5
|
+
# How a provider's Messages API (`api: anthropic`) departs from
|
|
6
|
+
# Anthropic's own: a small set of overrides on the standard wire, picked
|
|
7
|
+
# per host with `dialect:` (HostConfig#dialect, HostEntry#dialect_profile).
|
|
8
|
+
# A host without one is STANDARD (Anthropic, Splash, a gateway passing
|
|
9
|
+
# Claude through).
|
|
10
|
+
#
|
|
11
|
+
# name: the config value ("standard" for none);
|
|
12
|
+
# auth: the header the key goes in when the host sets no auth:
|
|
13
|
+
# (LLM::ApiKey::SCHEMES);
|
|
14
|
+
# thinking_display: the `display` sent with adaptive or budget thinking,
|
|
15
|
+
# nil for none;
|
|
16
|
+
# cache_breakpoints: whether chat requests carry cache_control;
|
|
17
|
+
# session_header: a header that carries the session id (beside
|
|
18
|
+
# Session-Id), nil for none.
|
|
19
|
+
Dialect = Data.define(:name, :auth, :thinking_display, :cache_breakpoints, :session_header) do
|
|
20
|
+
# The dialect for a `dialect:` name (HostConfig#dialect); STANDARD for
|
|
21
|
+
# nil or an unknown one.
|
|
22
|
+
def self.for(name) = self::BY_NAME.fetch(name.to_s, self::STANDARD)
|
|
23
|
+
|
|
24
|
+
# A hosts entry's `dialect:` as written: the name, or nil when unset.
|
|
25
|
+
# An unknown name, or one on a host whose api isn't anthropic, warns
|
|
26
|
+
# once (naming +where+) and is ignored.
|
|
27
|
+
# @param api [Symbol, nil] the entry's api
|
|
28
|
+
# @return [String, nil]
|
|
29
|
+
def self.parse(value, where, api:)
|
|
30
|
+
text = value.to_s.strip.downcase
|
|
31
|
+
return nil if text.empty?
|
|
32
|
+
|
|
33
|
+
require_relative "../config"
|
|
34
|
+
|
|
35
|
+
unless self::BY_NAME.key?(text)
|
|
36
|
+
ConfigFile.warn_once "Warning: #{where}: dialect must be one of #{self::BY_NAME.keys.join(", ")}; ignored"
|
|
37
|
+
return nil
|
|
38
|
+
end
|
|
39
|
+
return text if api&.to_sym == :anthropic
|
|
40
|
+
|
|
41
|
+
ConfigFile.warn_once "Warning: #{where}: dialect has no effect on api: #{api || "(none)"}; ignored"
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
Dialect::STANDARD = Dialect.new(name: "standard", auth: :x_api_key, thinking_display: "summarized",
|
|
47
|
+
cache_breakpoints: true, session_header: nil)
|
|
48
|
+
# Fireworks' /v1/messages (docs.fireworks.ai, anthropic-compatibility):
|
|
49
|
+
# Bearer auth, no `display` in its thinking schema (every object refuses
|
|
50
|
+
# extra fields; thinking comes back by default), an automatic prefix
|
|
51
|
+
# cache (no breakpoints), and x-session-affinity to keep a session on
|
|
52
|
+
# one replica, where its cache is.
|
|
53
|
+
Dialect::FIREWORKS = Dialect.new(name: "fireworks", auth: :bearer, thinking_display: nil,
|
|
54
|
+
cache_breakpoints: false, session_header: "x-session-affinity")
|
|
55
|
+
Dialect::BY_NAME = { "standard" => Dialect::STANDARD, "fireworks" => Dialect::FIREWORKS }.freeze
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -28,7 +28,8 @@ module Samagotchi
|
|
|
28
28
|
# OverBudget 402 metadata.reason weight_exceeds_budget: the
|
|
29
29
|
# request alone exceeds the key's budget; an
|
|
30
30
|
# OutOfCredits, never retried
|
|
31
|
-
# ServerError 5xx or a
|
|
31
|
+
# ServerError 5xx, or a mid-stream error event with no status or
|
|
32
|
+
# Anthropic error.type
|
|
32
33
|
# AuthError 401/403, or an API key variable that is not set
|
|
33
34
|
# BadRequest other 4xx (a context overflow is one, whatever status;
|
|
34
35
|
# so is a model that can't take tools)
|
|
@@ -136,11 +137,14 @@ module Samagotchi
|
|
|
136
137
|
TOOLS_HINT = "this model can't use tools, and chi needs them: pick another model (/model) or host"
|
|
137
138
|
|
|
138
139
|
# @param hint [String, nil] what to try, added to the summary
|
|
139
|
-
def initialize(message = nil, context_overflow: false, tools_unsupported: false, reasoning_refused: false,
|
|
140
|
-
**)
|
|
140
|
+
def initialize(message = nil, context_overflow: false, tools_unsupported: false, reasoning_refused: false,
|
|
141
|
+
thinking_signature: false, thinking_binding_unsupported: false, sampling_refused: false, hint: nil, **)
|
|
142
|
+
@sampling_refused = sampling_refused
|
|
141
143
|
@context_overflow = context_overflow
|
|
142
144
|
@tools_unsupported = tools_unsupported
|
|
143
145
|
@reasoning_refused = reasoning_refused
|
|
146
|
+
@thinking_signature = thinking_signature
|
|
147
|
+
@thinking_binding_unsupported = thinking_binding_unsupported
|
|
144
148
|
@hint = hint
|
|
145
149
|
super(message, **)
|
|
146
150
|
end
|
|
@@ -158,6 +162,24 @@ module Samagotchi
|
|
|
158
162
|
# be what the host refuses (gpt-oss: "Reasoning is mandatory").
|
|
159
163
|
def reasoning_refused? = @reasoning_refused
|
|
160
164
|
|
|
165
|
+
# A 400 about a replayed thinking block's signature (Anthropic: "Invalid
|
|
166
|
+
# `signature` in `thinking` block", bound to a different conversation
|
|
167
|
+
# or unreadable). The block is what's wrong, not the thinking fields:
|
|
168
|
+
# never #reasoning_refused?.
|
|
169
|
+
def thinking_signature? = @thinking_signature
|
|
170
|
+
|
|
171
|
+
# A 400 refusing the thinking binding controls: the
|
|
172
|
+
# thinking.block_binding field ("Extra inputs are not permitted") or
|
|
173
|
+
# the thinking-binding-controls beta header. Never #reasoning_refused?.
|
|
174
|
+
def thinking_binding_unsupported? = @thinking_binding_unsupported
|
|
175
|
+
|
|
176
|
+
# A 400 that refuses a sampling field (temperature, top_p, top_k):
|
|
177
|
+
# Claude's "temperature may only be set to 1 when thinking is
|
|
178
|
+
# enabled" for the empty-answer retry's temperature. Not
|
|
179
|
+
# reasoning_refused? although it says "thinking": ChatLoop asks again
|
|
180
|
+
# without those fields instead.
|
|
181
|
+
def sampling_refused? = @sampling_refused
|
|
182
|
+
|
|
161
183
|
def kind = :bad_request
|
|
162
184
|
|
|
163
185
|
def summary
|
|
@@ -277,15 +299,35 @@ module Samagotchi
|
|
|
277
299
|
# Builds the ProviderError for an HTTP error response or a server's error
|
|
278
300
|
# event.
|
|
279
301
|
module ProviderErrors
|
|
280
|
-
|
|
302
|
+
# A prompt larger than the model's window; Anthropic: "prompt is too
|
|
303
|
+
# long: 208310 tokens > 200000 maximum", or "input length and
|
|
304
|
+
# `max_tokens` exceed context limit: 188240 + 21333 > 200000".
|
|
305
|
+
CONTEXT_OVERFLOW_RE = Regexp.union(
|
|
306
|
+
/exceeds? (the )?(available )?context (size|length|window)|context[_ ]length[_ ]exceeded|exceed_context_size/i,
|
|
307
|
+
/maximum context length|prompt is too long: \d+ tokens/i,
|
|
308
|
+
/input length and `?max_tokens`? exceed context limit: \d+ \+ \d+ > \d+/i
|
|
309
|
+
)
|
|
281
310
|
# A model that takes no tools: OpenRouter when no endpoint of the model
|
|
282
311
|
# does ("No endpoints found that support tool use", 404), Ollama ("…
|
|
283
312
|
# does not support tools"), vLLM started without a tool parser.
|
|
284
313
|
TOOLS_UNSUPPORTED_RE = /support tool use|(does not|doesn't) support tools|tool choice requires --enable-auto-tool-choice/i
|
|
285
314
|
# A model or server that can't take images (see VisionUnsupported).
|
|
286
315
|
VISION_UNSUPPORTED_RE = /support image input|image input is not supported|mmproj/i
|
|
287
|
-
# A 400 about the thinking fields chi sent (Thinking.chat_fields).
|
|
316
|
+
# A 400 about the thinking fields chi sent (Thinking.chat_fields). Broad;
|
|
317
|
+
# a signature or binding-control 400 (below) is not one.
|
|
288
318
|
REASONING_REFUSED_RE = /reasoning|thinking/i
|
|
319
|
+
# Messages' 400 for an assistant turn sent back without its thinking
|
|
320
|
+
# block before tool_use: about the history sent, not a refusal of
|
|
321
|
+
# reasoning (it would turn thinking off for the model).
|
|
322
|
+
THINKING_BLOCK_MISSING_RE = /expected `?thinking`? or `?redacted_thinking`?|must start with a thinking block/i
|
|
323
|
+
# A 400 about a sampling field (BadRequest#sampling_refused?).
|
|
324
|
+
SAMPLING_REFUSED_RE = /\b(temperature|top_p|top_k)\b/i
|
|
325
|
+
# A 400 about a thinking block's signature (BadRequest#thinking_signature?).
|
|
326
|
+
THINKING_SIGNATURE_RE = /invalid `?signature`? in `?thinking`? block|bound to a different conversation/i
|
|
327
|
+
# A 400 refusing the binding controls (BadRequest#thinking_binding_unsupported?):
|
|
328
|
+
# the field without the beta header, or the beta header itself.
|
|
329
|
+
THINKING_BINDING_UNSUPPORTED_RE = Regexp.union(/block_binding:? extra inputs are not permitted/i,
|
|
330
|
+
/unexpected value\(s\) `?thinking-binding-controls/i)
|
|
289
331
|
# A 402 about credit held by in-flight requests (see CreditsHeld).
|
|
290
332
|
CREDITS_HELD_RE = /in-flight requests/i
|
|
291
333
|
# A 402's error.metadata.reason when the request can never fit the
|
|
@@ -294,6 +336,20 @@ module Samagotchi
|
|
|
294
336
|
# Seconds to wait before retrying a CreditsHeld without a Retry-After.
|
|
295
337
|
CREDITS_SETTLE_DELAY = 20.0
|
|
296
338
|
RETRYABLE_SERVER_STATUSES = [500, 502, 503, 504, 529].freeze
|
|
339
|
+
# The HTTP status an Anthropic error.type stands for, for an error
|
|
340
|
+
# object that carries no numeric code (a Messages stream's error event,
|
|
341
|
+
# an OpenAI error whose code is null or a name).
|
|
342
|
+
ERROR_TYPE_STATUS = {
|
|
343
|
+
"invalid_request_error" => 400, "authentication_error" => 401, "billing_error" => 402,
|
|
344
|
+
"permission_error" => 403, "not_found_error" => 404, "request_too_large" => 413,
|
|
345
|
+
"rate_limit_error" => 429, "api_error" => 500, "overloaded_error" => 529, "timeout_error" => 504
|
|
346
|
+
}.freeze
|
|
347
|
+
# The HTTP status a Google-style error.code stands for (Fireworks'
|
|
348
|
+
# errors before a request reaches a model: {"error":{"code":"NOT_FOUND"}}).
|
|
349
|
+
ERROR_CODE_STATUS = {
|
|
350
|
+
"INVALID_ARGUMENT" => 400, "UNAUTHENTICATED" => 401, "PERMISSION_DENIED" => 403, "NOT_FOUND" => 404,
|
|
351
|
+
"RESOURCE_EXHAUSTED" => 429
|
|
352
|
+
}.freeze
|
|
297
353
|
|
|
298
354
|
module_function
|
|
299
355
|
|
|
@@ -322,10 +378,7 @@ module Samagotchi
|
|
|
322
378
|
else
|
|
323
379
|
OutOfCredits.new(text, **options)
|
|
324
380
|
end
|
|
325
|
-
when 400..499
|
|
326
|
-
tools = TOOLS_UNSUPPORTED_RE.match?(message)
|
|
327
|
-
BadRequest.new(text, tools_unsupported: tools,
|
|
328
|
-
reasoning_refused: status == 400 && !tools && REASONING_REFUSED_RE.match?(message), **options)
|
|
381
|
+
when 400..499 then bad_request(text, status, message, options)
|
|
329
382
|
when 500..599
|
|
330
383
|
ServerError.new(text, retryable: RETRYABLE_SERVER_STATUSES.include?(status),
|
|
331
384
|
retry_after: parse_retry_after(retry_after), **options)
|
|
@@ -359,15 +412,31 @@ module Samagotchi
|
|
|
359
412
|
from_error_json(body.to_s, host: host)
|
|
360
413
|
end
|
|
361
414
|
|
|
415
|
+
# A BadRequest for a 4xx, with its predicates from +message+.
|
|
416
|
+
def bad_request(text, status, message, options)
|
|
417
|
+
tools = TOOLS_UNSUPPORTED_RE.match?(message)
|
|
418
|
+
signature = THINKING_SIGNATURE_RE.match?(message)
|
|
419
|
+
binding = !signature && THINKING_BINDING_UNSUPPORTED_RE.match?(message)
|
|
420
|
+
sampling = status == 400 && !tools && !signature && !binding && SAMPLING_REFUSED_RE.match?(message)
|
|
421
|
+
missing = THINKING_BLOCK_MISSING_RE.match?(message)
|
|
422
|
+
refused = status == 400 && !tools && !signature && !binding && !sampling && !missing &&
|
|
423
|
+
REASONING_REFUSED_RE.match?(message)
|
|
424
|
+
BadRequest.new(text, tools_unsupported: tools, thinking_signature: signature, thinking_binding_unsupported: binding,
|
|
425
|
+
sampling_refused: sampling, reasoning_refused: refused, **options)
|
|
426
|
+
end
|
|
427
|
+
|
|
362
428
|
# The ProviderError for a JSON error object, by its error.code (an HTTP
|
|
363
|
-
# status)
|
|
429
|
+
# status, or a name in ERROR_CODE_STATUS), else by its error.type
|
|
430
|
+
# (ERROR_TYPE_STATUS); 500 when it has neither.
|
|
364
431
|
def from_error_json(raw, host:)
|
|
365
432
|
parsed = parse_json(raw)
|
|
366
433
|
error = parsed.is_a?(Hash) && parsed["error"].is_a?(Hash) ? parsed["error"] : parsed
|
|
367
434
|
code = error.is_a?(Hash) ? error["code"] : nil
|
|
368
435
|
code = code.to_i if code.is_a?(String) && code.match?(/\A\d{3}\z/)
|
|
369
|
-
status = code.is_a?(Integer) && code.between?(400, 599)
|
|
370
|
-
|
|
436
|
+
status = code if code.is_a?(Integer) && code.between?(400, 599)
|
|
437
|
+
status ||= ERROR_CODE_STATUS[code] if code.is_a?(String)
|
|
438
|
+
status ||= ERROR_TYPE_STATUS[error["type"]] if error.is_a?(Hash)
|
|
439
|
+
from_response(status: status || 500, body: raw, host: host)
|
|
371
440
|
end
|
|
372
441
|
|
|
373
442
|
# The human part of an error body: error.message, message, error (a
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module LLM
|
|
5
|
+
# A model a host lists. Unknown fields are nil.
|
|
6
|
+
ModelInfo = Data.define(:id, :context_window, :supports_tools, :raw) do
|
|
7
|
+
# true when the list says the model takes images, false when it says
|
|
8
|
+
# it doesn't, else nil: OpenRouter's architecture.input_modalities,
|
|
9
|
+
# Anthropic's capabilities.image_input.supported, Splash's top-level
|
|
10
|
+
# input_modalities or vision, Fireworks' supports_image_input,
|
|
11
|
+
# llama.cpp's "multimodal" capability.
|
|
12
|
+
def image_input
|
|
13
|
+
return nil unless raw.is_a?(Hash)
|
|
14
|
+
|
|
15
|
+
modalities = raw.dig("architecture", "input_modalities")
|
|
16
|
+
return modalities.include?("image") if modalities.is_a?(Array)
|
|
17
|
+
|
|
18
|
+
capabilities = raw["capabilities"]
|
|
19
|
+
supported = capabilities.dig("image_input", "supported") if capabilities.is_a?(Hash)
|
|
20
|
+
return supported if [true, false].include?(supported)
|
|
21
|
+
return raw["input_modalities"].include?("image") if raw["input_modalities"].is_a?(Array)
|
|
22
|
+
return raw["vision"] if [true, false].include?(raw["vision"])
|
|
23
|
+
return raw["supports_image_input"] if [true, false].include?(raw["supports_image_input"])
|
|
24
|
+
return true if capabilities.is_a?(Array) && capabilities.include?("multimodal")
|
|
25
|
+
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# A chat adapter's model list, kept per models_ttl: what #context_window
|
|
31
|
+
# and #image_input read. The adapter defines #list_models (its wire's
|
|
32
|
+
# GET) and calls #init_model_listing from its initializer.
|
|
33
|
+
module ModelListing
|
|
34
|
+
# The window (tokens) the host lists for +model+, or nil. The list is
|
|
35
|
+
# read once per models_ttl; a failed listing answers nil.
|
|
36
|
+
def context_window(model:)
|
|
37
|
+
listed_model(model)&.context_window
|
|
38
|
+
rescue StandardError
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Whether the host's list says +model+ takes images (ModelInfo#image_input),
|
|
43
|
+
# or nil when it doesn't say or the listing fails.
|
|
44
|
+
def image_input(model:)
|
|
45
|
+
listed_model(model)&.image_input
|
|
46
|
+
rescue StandardError
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def init_model_listing(models_ttl)
|
|
53
|
+
@models_ttl = models_ttl
|
|
54
|
+
@models_mutex = Mutex.new
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# +model+'s entry in the cached list: its exact id, else any case.
|
|
58
|
+
def listed_model(model)
|
|
59
|
+
cached_models.find { |m| m.id == model.to_s } || cached_models.find { |m| m.id.casecmp?(model.to_s) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def model_entries(body)
|
|
63
|
+
return [] unless body.is_a?(Hash)
|
|
64
|
+
|
|
65
|
+
entries = body["data"].is_a?(Array) ? body["data"] : body["models"]
|
|
66
|
+
Array(entries).select { |entry| entry.is_a?(Hash) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def model_info(raw)
|
|
70
|
+
id = (raw["id"] || raw["model"] || raw["name"]).to_s
|
|
71
|
+
ModelInfo.new(id: id, context_window: window_of(raw), supports_tools: tools_support(raw), raw: raw)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The running window when the list says: context_length, context_window,
|
|
75
|
+
# max_model_len, or llama.cpp's meta.n_ctx (not n_ctx_train).
|
|
76
|
+
def window_of(raw)
|
|
77
|
+
[raw["context_length"], raw["context_window"], raw["max_model_len"], raw.dig("meta", "n_ctx")].each do |value|
|
|
78
|
+
return value if value.is_a?(Integer) && value.positive?
|
|
79
|
+
end
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Whether the list says the model takes tools: OpenRouter's
|
|
84
|
+
# supported_parameters or llama.cpp's capabilities naming "tools"
|
|
85
|
+
# (true, else nil), or Fireworks' supports_tools (true or false).
|
|
86
|
+
def tools_support(raw)
|
|
87
|
+
return raw["supports_tools"] if [true, false].include?(raw["supports_tools"])
|
|
88
|
+
|
|
89
|
+
listed = raw["supported_parameters"] || raw["capabilities"]
|
|
90
|
+
return nil unless listed.is_a?(Array)
|
|
91
|
+
|
|
92
|
+
listed.include?("tools") || nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def cached_models
|
|
96
|
+
@models_mutex.synchronize do
|
|
97
|
+
fresh = @models_at && (monotonic - @models_at) < @models_ttl
|
|
98
|
+
return @models if fresh
|
|
99
|
+
end
|
|
100
|
+
models = list_models
|
|
101
|
+
@models_mutex.synchronize do
|
|
102
|
+
@models = models
|
|
103
|
+
@models_at = monotonic
|
|
104
|
+
end
|
|
105
|
+
models
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|