samagotchi 0.64.0 → 0.65.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +41 -1
- data/docs/architecture.md +20 -3
- data/docs/cli.md +25 -1
- data/docs/configuration.md +128 -3
- data/docs/internals/engine.md +15 -3
- data/docs/sessions.md +3 -2
- data/lib/samagotchi/bridge.rb +11 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/client.rb +4 -2
- data/lib/samagotchi/config.rb +5 -4
- data/lib/samagotchi/engine.rb +39 -7
- data/lib/samagotchi/generation_phase.rb +17 -0
- data/lib/samagotchi/host_config.rb +27 -6
- data/lib/samagotchi/host_registry.rb +34 -11
- data/lib/samagotchi/idle_client.rb +62 -25
- data/lib/samagotchi/idle_recap.rb +5 -2
- data/lib/samagotchi/idle_target.rb +30 -7
- data/lib/samagotchi/llm/adapters.rb +41 -0
- data/lib/samagotchi/llm/anthropic_assembly.rb +210 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
- data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
- data/lib/samagotchi/llm/api_key.rb +26 -9
- data/lib/samagotchi/llm/chat_loop.rb +113 -14
- data/lib/samagotchi/llm/dialect.rb +57 -0
- data/lib/samagotchi/llm/errors.rb +81 -12
- data/lib/samagotchi/llm/model_listing.rb +111 -0
- data/lib/samagotchi/llm/model_result.rb +9 -2
- data/lib/samagotchi/llm/openai_chat.rb +46 -93
- data/lib/samagotchi/model_profile.rb +1 -1
- data/lib/samagotchi/reasoning_history.rb +14 -4
- data/lib/samagotchi/recap_setup.rb +2 -1
- data/lib/samagotchi/self_report.rb +18 -28
- data/lib/samagotchi/session.rb +3 -2
- data/lib/samagotchi/session_metrics.rb +6 -11
- data/lib/samagotchi/steer_cut.rb +4 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +8 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +4 -3
- data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
- data/lib/samagotchi/turn_note.rb +22 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -1
- data/lib/samagotchi/web/public/activity.js +3 -0
- data/lib/samagotchi/web/public/app.js +90 -13
- data/lib/samagotchi/web/public/delivery_line.js +228 -0
- data/lib/samagotchi/web/public/index.html +29 -0
- data/lib/samagotchi/web/public/live_turn.js +8 -0
- data/lib/samagotchi/web/public/stage_model.js +27 -7
- data/lib/samagotchi/web/public/stage_view.js +32 -10
- data/lib/samagotchi/web/public/stream_handlers.js +8 -2
- data/lib/samagotchi/web/public/turn_events.js +22 -8
- data/lib/samagotchi/web/public/turn_html.js +20 -4
- data/lib/samagotchi/web/public/turn_model.js +16 -9
- data/lib/samagotchi/web/public/turn_view.js +6 -4
- metadata +8 -1
|
@@ -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
|
|
@@ -15,17 +15,24 @@ module Samagotchi
|
|
|
15
15
|
# conversation) and .empty_retries (the retries spent): the Engine saves
|
|
16
16
|
# them on the turn's note for the UIs (TurnNote.empty).
|
|
17
17
|
#
|
|
18
|
+
# .refusal is set when the provider refused the answer (Messages'
|
|
19
|
+
# stop_reason refusal, OpenAI's content_filter): {category:} as the
|
|
20
|
+
# provider names it, {} without one; nil otherwise. Such a turn is an
|
|
21
|
+
# empty answer the loop never asked again.
|
|
22
|
+
#
|
|
18
23
|
# .run_start is where the turn's run started in the conversation (the
|
|
19
24
|
# length of the messages the loop got), set by the Engine after the loop
|
|
20
25
|
# returns; nil from a loop called directly. A step-limit offer keeps it
|
|
21
26
|
# (TurnFlow), so a Continue resumes that run (Engine#run_turn resume_from:).
|
|
22
27
|
class ModelResult
|
|
23
28
|
attr_reader :text, :conversation, :canceled, :cancellation_reason, :exhausted,
|
|
24
|
-
:tool_activity, :context_status, :pending_tool_calls, :empty_steps, :empty_retries
|
|
29
|
+
:tool_activity, :context_status, :pending_tool_calls, :empty_steps, :empty_retries, :refusal
|
|
25
30
|
attr_accessor :run_start
|
|
26
31
|
|
|
27
32
|
def initialize(text:, conversation: nil, canceled: false, cancellation_reason: nil, exhausted: false,
|
|
28
|
-
tool_activity: [], context_status: nil, pending_tool_calls: false, empty_steps: [], empty_retries: 0
|
|
33
|
+
tool_activity: [], context_status: nil, pending_tool_calls: false, empty_steps: [], empty_retries: 0,
|
|
34
|
+
refusal: nil)
|
|
35
|
+
@refusal = refusal
|
|
29
36
|
@empty_steps = Array(empty_steps)
|
|
30
37
|
@empty_retries = empty_retries.to_i
|
|
31
38
|
@text = text
|
|
@@ -4,30 +4,16 @@ require "json"
|
|
|
4
4
|
require "uri"
|
|
5
5
|
require_relative "../config"
|
|
6
6
|
require_relative "../sampling_settings"
|
|
7
|
+
require_relative "../thinking"
|
|
7
8
|
require_relative "errors"
|
|
8
9
|
require_relative "http"
|
|
10
|
+
require_relative "model_listing"
|
|
9
11
|
require_relative "prompt_cache"
|
|
10
12
|
require_relative "usage"
|
|
11
13
|
require_relative "utf8_scrub"
|
|
12
14
|
|
|
13
15
|
module Samagotchi
|
|
14
16
|
module LLM
|
|
15
|
-
# A model a host lists. Unknown fields are nil.
|
|
16
|
-
ModelInfo = Data.define(:id, :context_window, :supports_tools, :raw) do
|
|
17
|
-
# true when the list says the model takes images (OpenRouter's
|
|
18
|
-
# architecture.input_modalities, llama.cpp's "multimodal" capability),
|
|
19
|
-
# false when it lists input modalities without "image", else nil.
|
|
20
|
-
def image_input
|
|
21
|
-
return nil unless raw.is_a?(Hash)
|
|
22
|
-
|
|
23
|
-
modalities = raw.dig("architecture", "input_modalities")
|
|
24
|
-
return modalities.include?("image") if modalities.is_a?(Array)
|
|
25
|
-
return true if Array(raw["capabilities"]).include?("multimodal")
|
|
26
|
-
|
|
27
|
-
nil
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
|
|
31
17
|
# A tool call the model made. arguments: the parsed Hash, or the raw
|
|
32
18
|
# String when it isn't valid JSON (the tool then reports the error).
|
|
33
19
|
ToolCall = Data.define(:id, :name, :arguments)
|
|
@@ -43,9 +29,17 @@ module Samagotchi
|
|
|
43
29
|
# +top_logprobs+: the first answer token's likeliest alternatives
|
|
44
30
|
# (TokenLogprob) when the request asked for logprobs and the server
|
|
45
31
|
# sent them (a non-streamed answer only); [] otherwise.
|
|
32
|
+
# +provider_content+: the response's content blocks in order, as the
|
|
33
|
+
# server sent them (string keys), for a wire that sends them back
|
|
34
|
+
# verbatim (AnthropicMessages: thinking blocks with their signatures);
|
|
35
|
+
# nil on the OpenAI wire.
|
|
36
|
+
# +stop_details+: why the provider stopped, when it says more than the
|
|
37
|
+
# finish reason (Messages' stop_details on a refusal: type, category,
|
|
38
|
+
# explanation; string keys); nil otherwise.
|
|
46
39
|
ChatResponse = Data.define(:text, :reasoning, :tool_calls, :usage, :finish_reason, :model, :provider, :cut,
|
|
47
|
-
:top_logprobs) do
|
|
48
|
-
def initialize(model: nil, provider: nil, cut: nil, top_logprobs: [].freeze,
|
|
40
|
+
:top_logprobs, :provider_content, :stop_details) do
|
|
41
|
+
def initialize(model: nil, provider: nil, cut: nil, top_logprobs: [].freeze, provider_content: nil, stop_details: nil,
|
|
42
|
+
**fields) = super
|
|
49
43
|
end
|
|
50
44
|
|
|
51
45
|
# One alternative for an answer token: its text and log probability.
|
|
@@ -75,6 +69,8 @@ module Samagotchi
|
|
|
75
69
|
# default, with errors mapped to ProviderError. It speaks the wire format
|
|
76
70
|
# only; the chat loop owns the conversation and the tools.
|
|
77
71
|
class OpenAIChat
|
|
72
|
+
include ModelListing
|
|
73
|
+
|
|
78
74
|
MAX_MODEL_PAGES = 20
|
|
79
75
|
# How much of a 200's non-stream body is read as a possible JSON error.
|
|
80
76
|
PLAIN_ERROR_LIMIT = 64 * 1024
|
|
@@ -90,14 +86,11 @@ module Samagotchi
|
|
|
90
86
|
|
|
91
87
|
# @param entry [HostRegistry::HostEntry]
|
|
92
88
|
def self.for(entry, **)
|
|
93
|
-
new(base_url: entry.
|
|
94
|
-
remote: entry.remote?, default_max_tokens: (OPENROUTER_MAX_TOKENS if openrouter?(entry.
|
|
89
|
+
new(base_url: entry.api_base_url, host_name: entry.name, api_key_env: entry.api_key_env, auth: entry.auth,
|
|
90
|
+
remote: entry.remote?, default_max_tokens: (OPENROUTER_MAX_TOKENS if openrouter?(entry.api_base_url)),
|
|
95
91
|
**)
|
|
96
92
|
end
|
|
97
93
|
|
|
98
|
-
# true when a streamed +payload+ carries a tool-call delta.
|
|
99
|
-
def self.tool_call_delta?(payload) = Assembly.tool_call_delta?(payload)
|
|
100
|
-
|
|
101
94
|
# true when +url+ is OpenRouter's API (openrouter.ai or a subdomain).
|
|
102
95
|
def self.openrouter?(url)
|
|
103
96
|
host = URI(url.to_s).host.to_s.downcase
|
|
@@ -134,19 +127,23 @@ module Samagotchi
|
|
|
134
127
|
# @param host_name [String] names the host in errors
|
|
135
128
|
# @param api_key_env [String, nil] the variable holding the API key;
|
|
136
129
|
# nil sends no Authorization header (a local server)
|
|
130
|
+
# @param auth [Symbol, nil] the header the key goes in
|
|
131
|
+
# (ApiKey::SCHEMES); nil: Authorization: Bearer
|
|
137
132
|
# @param stream [Boolean] stream the reply (false: one JSON body)
|
|
138
133
|
# @param retries [Boolean] false: one attempt
|
|
139
134
|
# @param timeout [Numeric, nil] read timeout in seconds (default
|
|
140
135
|
# server.read_timeout); the connect timeout is capped by it
|
|
136
|
+
# @param open_timeout [Numeric, nil] connect timeout in seconds, when
|
|
137
|
+
# shorter than that (a reachability probe's)
|
|
141
138
|
# @param models_ttl [Numeric] how long #context_window reuses the list
|
|
142
139
|
# @param remote [Boolean] a provider on the network, not a local server
|
|
143
140
|
# @param first_token_timeout [Numeric, nil] seconds a streamed answer
|
|
144
141
|
# may take to show something (LLM::HTTP); nil: no limit
|
|
145
142
|
# @param default_max_tokens [Integer, nil] max_tokens for a request
|
|
146
143
|
# whose options name none (OPENROUTER_MAX_TOKENS); nil sends none
|
|
147
|
-
def initialize(base_url:, host_name:, api_key_env: nil, stream: true, retries: true, timeout: nil,
|
|
144
|
+
def initialize(base_url:, host_name:, api_key_env: nil, auth: nil, stream: true, retries: true, timeout: nil,
|
|
148
145
|
env: ENV, sleeper: nil, retry_policy: nil, models_ttl: DEFAULT_MODELS_TTL, remote: false,
|
|
149
|
-
first_token_timeout: nil, purpose: "chat", default_max_tokens: nil)
|
|
146
|
+
first_token_timeout: nil, purpose: "chat", default_max_tokens: nil, open_timeout: nil)
|
|
150
147
|
@remote = remote
|
|
151
148
|
@default_max_tokens = default_max_tokens
|
|
152
149
|
# What its requests are for, in the log (LLM::HTTP::LOGGED_PURPOSES).
|
|
@@ -156,13 +153,12 @@ module Samagotchi
|
|
|
156
153
|
@host_name = host_name.to_s
|
|
157
154
|
@api_key_env = api_key_env
|
|
158
155
|
@stream = stream
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
open_timeout, read_timeout = timeouts(timeout)
|
|
156
|
+
init_model_listing(models_ttl)
|
|
157
|
+
open_timeout, read_timeout = timeouts(timeout, open_timeout)
|
|
162
158
|
policy = retry_policy || (retries ? nil : HTTP::RetryPolicy.none)
|
|
163
159
|
@http = HTTP.new(label: @host_name, open_timeout: open_timeout, read_timeout: read_timeout,
|
|
164
160
|
retry_policy: policy, sleeper: sleeper, first_token_timeout: first_token_timeout,
|
|
165
|
-
api_key: ApiKey.for(api_key_env, host: @host_name, env: env))
|
|
161
|
+
api_key: ApiKey.for(api_key_env, host: @host_name, env: env, scheme: auth))
|
|
166
162
|
end
|
|
167
163
|
|
|
168
164
|
# @param messages [Array<Hash>] wire messages (role, content, tool_calls,
|
|
@@ -216,6 +212,25 @@ module Samagotchi
|
|
|
216
212
|
# A provider on the network: no llama.cpp /props to ask for the window.
|
|
217
213
|
def remote? = @remote
|
|
218
214
|
|
|
215
|
+
# Model entries go back as text, tool_calls and reasoning_content,
|
|
216
|
+
# never as saved provider blocks (AnthropicMessages does).
|
|
217
|
+
def replays_content? = false
|
|
218
|
+
|
|
219
|
+
# true when a streamed chunk's +payload+ (as #chat's on_delta gets it)
|
|
220
|
+
# carries part of a tool call (ChatLoop marks the chunk's event).
|
|
221
|
+
def tool_call_delta?(payload) = Assembly.tool_call_delta?(payload)
|
|
222
|
+
|
|
223
|
+
# The request fields a thinking +level+ (Thinking::LEVELS) adds on
|
|
224
|
+
# this wire: Thinking.chat_fields's OpenAI-style switches.
|
|
225
|
+
# @return [Hash] frozen
|
|
226
|
+
def thinking_fields(level) = Thinking.chat_fields(level)
|
|
227
|
+
|
|
228
|
+
# The thinking fields for a short side answer from a model that
|
|
229
|
+
# refused thinking off (gpt-oss: "Reasoning is mandatory"): none, the
|
|
230
|
+
# model's own default.
|
|
231
|
+
# @return [Hash] frozen
|
|
232
|
+
def thinking_off_refused_fields = {}.freeze
|
|
233
|
+
|
|
219
234
|
# @return [Array<ModelInfo>] the host's models (every page)
|
|
220
235
|
def list_models
|
|
221
236
|
models = []
|
|
@@ -231,26 +246,6 @@ module Samagotchi
|
|
|
231
246
|
models
|
|
232
247
|
end
|
|
233
248
|
|
|
234
|
-
# The window (tokens) the host lists for +model+, or nil. The list is
|
|
235
|
-
# read once per models_ttl; a failed listing answers nil.
|
|
236
|
-
def context_window(model:)
|
|
237
|
-
info = cached_models.find { |m| m.id == model.to_s } ||
|
|
238
|
-
cached_models.find { |m| m.id.casecmp?(model.to_s) }
|
|
239
|
-
info&.context_window
|
|
240
|
-
rescue StandardError
|
|
241
|
-
nil
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
# Whether the host's list says +model+ takes images (ModelInfo#image_input),
|
|
245
|
-
# or nil when it doesn't say or the listing fails.
|
|
246
|
-
def image_input(model:)
|
|
247
|
-
info = cached_models.find { |m| m.id == model.to_s } ||
|
|
248
|
-
cached_models.find { |m| m.id.casecmp?(model.to_s) }
|
|
249
|
-
info&.image_input
|
|
250
|
-
rescue StandardError
|
|
251
|
-
nil
|
|
252
|
-
end
|
|
253
|
-
|
|
254
249
|
def self.served_model(payload)
|
|
255
250
|
model = payload.is_a?(Hash) ? payload["model"] : nil
|
|
256
251
|
model.is_a?(String) && !model.strip.empty? ? model : nil
|
|
@@ -265,9 +260,10 @@ module Samagotchi
|
|
|
265
260
|
|
|
266
261
|
private
|
|
267
262
|
|
|
268
|
-
def timeouts(timeout)
|
|
263
|
+
def timeouts(timeout, open_limit = nil)
|
|
269
264
|
open_timeout = Samagotchi::Config.positive_seconds("server.open_timeout")
|
|
270
265
|
read_timeout = Samagotchi::Config.positive_seconds("server.read_timeout")
|
|
266
|
+
open_timeout = [open_timeout, open_limit].min if open_limit
|
|
271
267
|
return [open_timeout, read_timeout] unless timeout
|
|
272
268
|
|
|
273
269
|
[[open_timeout, timeout].min, timeout]
|
|
@@ -411,49 +407,6 @@ module Samagotchi
|
|
|
411
407
|
|
|
412
408
|
def get_request(uri) = Net::HTTP::Get.new(uri)
|
|
413
409
|
|
|
414
|
-
def model_entries(body)
|
|
415
|
-
return [] unless body.is_a?(Hash)
|
|
416
|
-
|
|
417
|
-
entries = body["data"].is_a?(Array) ? body["data"] : body["models"]
|
|
418
|
-
Array(entries).select { |entry| entry.is_a?(Hash) }
|
|
419
|
-
end
|
|
420
|
-
|
|
421
|
-
def model_info(raw)
|
|
422
|
-
id = (raw["id"] || raw["model"] || raw["name"]).to_s
|
|
423
|
-
ModelInfo.new(id: id, context_window: window_of(raw), supports_tools: tools_support(raw), raw: raw)
|
|
424
|
-
end
|
|
425
|
-
|
|
426
|
-
# The running window when the list says: context_length, context_window,
|
|
427
|
-
# max_model_len, or llama.cpp's meta.n_ctx (not n_ctx_train).
|
|
428
|
-
def window_of(raw)
|
|
429
|
-
[raw["context_length"], raw["context_window"], raw["max_model_len"], raw.dig("meta", "n_ctx")].each do |value|
|
|
430
|
-
return value if value.is_a?(Integer) && value.positive?
|
|
431
|
-
end
|
|
432
|
-
nil
|
|
433
|
-
end
|
|
434
|
-
|
|
435
|
-
def tools_support(raw)
|
|
436
|
-
listed = raw["supported_parameters"] || raw["capabilities"]
|
|
437
|
-
return nil unless listed.is_a?(Array)
|
|
438
|
-
|
|
439
|
-
listed.include?("tools") || nil
|
|
440
|
-
end
|
|
441
|
-
|
|
442
|
-
def cached_models
|
|
443
|
-
@models_mutex.synchronize do
|
|
444
|
-
fresh = @models_at && (monotonic - @models_at) < @models_ttl
|
|
445
|
-
return @models if fresh
|
|
446
|
-
end
|
|
447
|
-
models = list_models
|
|
448
|
-
@models_mutex.synchronize do
|
|
449
|
-
@models = models
|
|
450
|
-
@models_at = monotonic
|
|
451
|
-
end
|
|
452
|
-
models
|
|
453
|
-
end
|
|
454
|
-
|
|
455
|
-
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
456
|
-
|
|
457
410
|
# Streamed deltas put together: text, reasoning, tool calls by index,
|
|
458
411
|
# the finish reason, the usage chunk, the served model and provider.
|
|
459
412
|
class Assembly
|
|
@@ -484,7 +484,7 @@ module Samagotchi
|
|
|
484
484
|
private_class_method :configured
|
|
485
485
|
|
|
486
486
|
# Only a native llama.cpp host has /props with a chat template; a chat
|
|
487
|
-
# host (api: openai) barely uses a profile, and mlx/oMLX expose none.
|
|
487
|
+
# host (api: openai or anthropic) barely uses a profile, and mlx/oMLX expose none.
|
|
488
488
|
def self.probe?(entry, client)
|
|
489
489
|
return false if client.nil? || !client.respond_to?(:server_props)
|
|
490
490
|
return false if entry&.chat?
|
|
@@ -16,12 +16,17 @@ module Samagotchi
|
|
|
16
16
|
#
|
|
17
17
|
# Order (#resolve): the session's own level (/reasoning-history), then
|
|
18
18
|
# SAMAGOTCHI_REASONING_HISTORY, models.<key>.reasoning_history,
|
|
19
|
-
# hosts.<name>.reasoning_history, then
|
|
19
|
+
# hosts.<name>.reasoning_history, then the host's api default: all for
|
|
20
|
+
# api: anthropic (its thinking blocks go back verbatim, and removing
|
|
21
|
+
# earlier turns' would rewrite the cached prefix every turn; ChatLoop
|
|
22
|
+
# floors drop at turn there), turn elsewhere. Native hosts ignore it:
|
|
20
23
|
# KernelLoop keeps the running turn's reasoning on its own, from the
|
|
21
24
|
# run's start (.run_start).
|
|
22
25
|
module ReasoningHistory
|
|
23
26
|
LEVELS = %i[drop turn all].freeze
|
|
24
27
|
DEFAULT = :turn
|
|
28
|
+
# The default for a host's api when it isn't DEFAULT.
|
|
29
|
+
API_DEFAULTS = { anthropic: :all }.freeze
|
|
25
30
|
KEY = "reasoning_history"
|
|
26
31
|
ENV_NAME = "SAMAGOTCHI_REASONING_HISTORY"
|
|
27
32
|
# The source a session's own level reads as.
|
|
@@ -82,7 +87,7 @@ module Samagotchi
|
|
|
82
87
|
host = target.entry&.reasoning_history
|
|
83
88
|
return [host, "hosts.#{target.entry.name}"] if host
|
|
84
89
|
|
|
85
|
-
[DEFAULT, nil]
|
|
90
|
+
[API_DEFAULTS.fetch(target.entry&.api&.to_sym, DEFAULT), nil]
|
|
86
91
|
end
|
|
87
92
|
|
|
88
93
|
# #resolve, as a SettingExplained with the session's own level beside it.
|
|
@@ -112,10 +117,15 @@ module Samagotchi
|
|
|
112
117
|
end
|
|
113
118
|
|
|
114
119
|
# Whether +conversation+ has a model entry with thinking (what all
|
|
115
|
-
# sends back as reasoning_content; LLM::ChatLoop#thought?)
|
|
120
|
+
# sends back as reasoning_content; LLM::ChatLoop#thought?), or with a
|
|
121
|
+
# saved thinking block (a Messages host's, which may carry no text:
|
|
122
|
+
# Claude's omitted display).
|
|
116
123
|
def earlier_thought?(conversation)
|
|
117
124
|
Array(conversation).any? do |entry|
|
|
118
|
-
entry[:role].to_s == "model"
|
|
125
|
+
next false unless entry[:role].to_s == "model"
|
|
126
|
+
|
|
127
|
+
(entry[:thinking].is_a?(String) && !entry[:thinking].strip.empty?) ||
|
|
128
|
+
Array(entry[:anthropic_content]).any? { |block| block.is_a?(Hash) && %w[thinking redacted_thinking].include?(block["type"].to_s) }
|
|
119
129
|
end
|
|
120
130
|
end
|
|
121
131
|
|
|
@@ -65,7 +65,8 @@ module Samagotchi
|
|
|
65
65
|
min_user_turns: recap_number_setting(kwarg_config, :min_user_turns, "recap.min_user_turns", IdleRecap::DEFAULT_MIN_USER_TURNS, :int),
|
|
66
66
|
timeout: recap_number_setting(kwarg_config, :timeout, "recap.timeout", IdleRecap::DEFAULT_TIMEOUT_SECONDS, :float),
|
|
67
67
|
sentences: recap_sentences(string_config(kwarg_config, :sentences) || registry_string("recap.sentences")),
|
|
68
|
-
store: RecapStore.new(session_id_lookup: -> { session_id.call }, state_dir_lookup: -> { state_dir.call })
|
|
68
|
+
store: RecapStore.new(session_id_lookup: -> { session_id.call }, state_dir_lookup: -> { state_dir.call }),
|
|
69
|
+
session_id: -> { session_id.call }
|
|
69
70
|
)
|
|
70
71
|
end
|
|
71
72
|
|