samagotchi 0.93.0 → 0.95.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 +60 -1
- data/README.md +8 -8
- data/bin/chi +6 -2
- data/docs/architecture.md +4 -5
- data/docs/cli.md +4 -4
- data/docs/configuration.md +49 -86
- data/docs/hooks.md +2 -2
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/context-telemetry.md +1 -1
- data/docs/internals/engine.md +7 -4
- data/docs/memory.md +26 -1
- data/docs/plugins.md +37 -0
- data/docs/releasing.md +2 -2
- data/docs/sessions.md +1 -1
- data/docs/testing.md +16 -11
- data/lib/samagotchi/bootstrap/config_writer.rb +1 -5
- data/lib/samagotchi/bootstrap_command.rb +1 -0
- data/lib/samagotchi/bundle_command/try/sandbox.rb +8 -2
- data/lib/samagotchi/bundle_command.rb +1 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/model-notes/manifest.yml +11 -0
- data/lib/samagotchi/bundles/model-notes/model_notes_deepseek.md +7 -0
- data/lib/samagotchi/bundles/model-notes/notes/README.md +37 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +6 -7
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/client.rb +59 -196
- data/lib/samagotchi/config.rb +74 -15
- data/lib/samagotchi/engine.rb +7 -7
- data/lib/samagotchi/host_config.rb +20 -27
- data/lib/samagotchi/host_registry.rb +8 -8
- data/lib/samagotchi/kernel_loop.rb +46 -8
- data/lib/samagotchi/memory_bundle/profile.rb +22 -4
- data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -1
- data/lib/samagotchi/model_profile.rb +2 -3
- data/lib/samagotchi/self_report.rb +0 -2
- data/lib/samagotchi/system_prompt.rb +7 -21
- data/lib/samagotchi/terminal_ui/thinking_line.rb +2 -4
- data/lib/samagotchi/testing/sandbox.rb +26 -0
- data/lib/samagotchi/thinking.rb +35 -2
- data/lib/samagotchi/thinking_notices.rb +11 -4
- data/lib/samagotchi/update_command.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +0 -3
- data/lib/samagotchi/web/public/app.js +15 -82
- data/lib/samagotchi/web/public/dock_reserve.js +103 -0
- data/lib/samagotchi/web/public/index.html +3 -3
- data/lib/samagotchi/web/public/toast_place.js +1 -1
- metadata +5 -1
data/lib/samagotchi/client.rb
CHANGED
|
@@ -12,14 +12,13 @@ require_relative "vision_support"
|
|
|
12
12
|
require_relative "sampling_settings"
|
|
13
13
|
|
|
14
14
|
module Samagotchi
|
|
15
|
-
# Thin HTTP client for llama.cpp's native /completion endpoint
|
|
16
|
-
#
|
|
15
|
+
# Thin HTTP client for llama.cpp's native /completion endpoint (chi's
|
|
16
|
+
# raw-prompt loop; any other server is a chat host, api: openai).
|
|
17
17
|
# Configured by the server.* settings (Samagotchi::Config):
|
|
18
18
|
# server.host (default: localhost)
|
|
19
|
-
# server.port (default: 8080
|
|
19
|
+
# server.port (default: 8080)
|
|
20
20
|
# server.open_timeout (default: 10 seconds)
|
|
21
21
|
# server.read_timeout (default: 600 seconds)
|
|
22
|
-
# server.transport (llama_cpp|mlx|omlx, default: llama_cpp)
|
|
23
22
|
class Client
|
|
24
23
|
# The shared HTTP layer's errors, under their old names.
|
|
25
24
|
RequestCancelled = LLM::RequestCancelled
|
|
@@ -36,8 +35,13 @@ module Samagotchi
|
|
|
36
35
|
# #clear_props_failures!).
|
|
37
36
|
PROPS_FAILURE_TTL = 30
|
|
38
37
|
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
# llama.cpp's native API: the name in error lines (when the host has
|
|
39
|
+
# none) and its endpoints. llama.cpp's /props carries the per-slot
|
|
40
|
+
# n_ctx (-c split across --parallel slots) and the chat template.
|
|
41
|
+
LABEL = "llama.cpp"
|
|
42
|
+
COMPLETION_PATH = "/completion"
|
|
43
|
+
MODELS_PATH = "/models"
|
|
44
|
+
PROPS_PATH = "/props"
|
|
41
45
|
|
|
42
46
|
# One /props cache per host per process. A host has more than one Client
|
|
43
47
|
# in a run (the registry's, a rebuilt HostRegistry's, the Engines of a
|
|
@@ -64,98 +68,30 @@ module Samagotchi
|
|
|
64
68
|
end
|
|
65
69
|
end
|
|
66
70
|
|
|
67
|
-
#
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
class Transport
|
|
73
|
-
attr_reader :name
|
|
74
|
-
|
|
75
|
-
# @param name [Symbol] one of Client::VALID_TRANSPORTS
|
|
76
|
-
# @param model_resolver [Proc, nil] client-installed resolver for the
|
|
77
|
-
# request's `model` field (oMLX resolves against its /v1/models list;
|
|
78
|
-
# mlx installs one that always returns nil to omit the field); nil
|
|
79
|
-
# means forward the selector verbatim (llama.cpp default)
|
|
80
|
-
def initialize(name, model_resolver: nil)
|
|
81
|
-
@name = name
|
|
82
|
-
@model_resolver = model_resolver
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def label
|
|
86
|
-
# oMLX gets its own label so its error paths read "omlx ...", not "mlx ...".
|
|
87
|
-
@name == :llama_cpp ? "llama.cpp" : @name.to_s
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
def completion_path
|
|
91
|
-
openai_compatible? ? "/v1/completions" : "/completion"
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def models_path
|
|
95
|
-
openai_compatible? ? "/v1/models" : "/models"
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def token_limit_key
|
|
99
|
-
openai_compatible? ? :max_tokens : :n_predict
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
# Where the server describes itself (context window, chat template), or
|
|
103
|
-
# nil when it has no such route. llama.cpp's /props carries the per-slot
|
|
104
|
-
# n_ctx (-c split across --parallel slots) and the chat template.
|
|
105
|
-
# mlx_lm.server and oMLX expose neither.
|
|
106
|
-
def props_path
|
|
107
|
-
openai_compatible? ? nil : "/props"
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def context_window_from(body)
|
|
111
|
-
n_ctx = body.is_a?(Hash) ? body.dig("default_generation_settings", "n_ctx") : nil
|
|
112
|
-
n_ctx.is_a?(Integer) && n_ctx.positive? ? n_ctx : nil
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
# Text content carried by one streamed `data:` payload.
|
|
116
|
-
def content_from_payload(payload)
|
|
117
|
-
openai_compatible? ? payload.dig("choices", 0, "text").to_s : payload.fetch("content", "")
|
|
118
|
-
end
|
|
71
|
+
# The context window (n_ctx) in a /props body, or nil.
|
|
72
|
+
def self.context_window_from(body)
|
|
73
|
+
n_ctx = body.is_a?(Hash) ? body.dig("default_generation_settings", "n_ctx") : nil
|
|
74
|
+
n_ctx.is_a?(Integer) && n_ctx.positive? ? n_ctx : nil
|
|
75
|
+
end
|
|
119
76
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
return nil unless payload.is_a?(Hash)
|
|
127
|
-
return payload.dig("choices", 0, "finish_reason") if openai_compatible?
|
|
128
|
-
|
|
129
|
-
case payload["stop_type"]
|
|
130
|
-
when "limit" then "length"
|
|
131
|
-
when "eos", "word" then "stop"
|
|
132
|
-
end
|
|
133
|
-
end
|
|
77
|
+
# Why the stream stopped, from its last payload, as an OpenAI finish
|
|
78
|
+
# reason ("length" for the token cap or a full context, "stop"
|
|
79
|
+
# otherwise); nil for a payload that doesn't say. llama.cpp's
|
|
80
|
+
# /completion names it stop_type ("limit", "eos", "word").
|
|
81
|
+
def self.finish_reason_from(payload)
|
|
82
|
+
return nil unless payload.is_a?(Hash)
|
|
134
83
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
# server's own `--model` CLI flag).
|
|
139
|
-
# - oMLX: MUST send a model id that exists in the server's `/v1/models`
|
|
140
|
-
# list, or oMLX 400s with "model: Field required". The client-installed
|
|
141
|
-
# resolver maps the short selector (e.g. `gemma-4-26b-a4b-it-4bit`) to
|
|
142
|
-
# the exact registered id (which may be prefixed, e.g.
|
|
143
|
-
# `mlx-community--...`) by matching against the loaded /v1/models list.
|
|
144
|
-
def model_for_payload(model)
|
|
145
|
-
return @model_resolver.call(model) if @model_resolver
|
|
146
|
-
|
|
147
|
-
value = model.to_s.strip
|
|
148
|
-
value.empty? ? nil : value
|
|
84
|
+
case payload["stop_type"]
|
|
85
|
+
when "limit" then "length"
|
|
86
|
+
when "eos", "word" then "stop"
|
|
149
87
|
end
|
|
88
|
+
end
|
|
150
89
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
def openai_compatible?
|
|
157
|
-
@name == :mlx || @name == :omlx
|
|
158
|
-
end
|
|
90
|
+
# The request's `model` field: the selector verbatim, nil (omit the
|
|
91
|
+
# field) when blank.
|
|
92
|
+
def self.model_for_payload(model)
|
|
93
|
+
value = model.to_s.strip
|
|
94
|
+
value.empty? ? nil : value
|
|
159
95
|
end
|
|
160
96
|
|
|
161
97
|
# One /props probe's outcome. `answered?` is false when the probe failed:
|
|
@@ -191,14 +127,14 @@ module Samagotchi
|
|
|
191
127
|
# @param first_token_timeout [Numeric, nil] seconds a completion may take
|
|
192
128
|
# to stream its first text (LLM::HTTP); nil: no limit
|
|
193
129
|
# @param name [String, nil] the host's config name, for error lines
|
|
194
|
-
# (default:
|
|
130
|
+
# (default: LABEL)
|
|
195
131
|
# @param api_key_env [String, nil] the variable holding the host's API
|
|
196
132
|
# key (llama.cpp's --api-key), sent as a bearer token on every request;
|
|
197
133
|
# nil sends no Authorization header
|
|
198
134
|
# @param auth [Symbol, nil] the header the key goes in (LLM::ApiKey::SCHEMES;
|
|
199
135
|
# HostEntry#auth_scheme), nil: Bearer
|
|
200
136
|
# @param env [Hash] where the key variable is read
|
|
201
|
-
def initialize(host: nil, port: nil, open_timeout: nil, read_timeout: nil,
|
|
137
|
+
def initialize(host: nil, port: nil, open_timeout: nil, read_timeout: nil, sleeper: nil, scheme: nil,
|
|
202
138
|
first_token_timeout: nil, name: nil, api_key_env: nil, auth: nil, env: ENV)
|
|
203
139
|
# Unified config precedence: CLI > ENV > file > default (via Samagotchi::Config)
|
|
204
140
|
@host = host || Samagotchi::Config.get("server.host")
|
|
@@ -206,7 +142,6 @@ module Samagotchi
|
|
|
206
142
|
@scheme = scheme || "http"
|
|
207
143
|
@open_timeout = Samagotchi::Config.positive_seconds("server.open_timeout", open_timeout)
|
|
208
144
|
@read_timeout = Samagotchi::Config.positive_seconds("server.read_timeout", read_timeout)
|
|
209
|
-
@transport = build_transport(resolve_transport(transport))
|
|
210
145
|
# This host's process-wide /props entry (see self.props_entry): its
|
|
211
146
|
# answers and failures are shared with every other Client of the same
|
|
212
147
|
# server.
|
|
@@ -216,7 +151,7 @@ module Samagotchi
|
|
|
216
151
|
@props_mutex = @props_entry[:mutex]
|
|
217
152
|
@first_token_timeout = first_token_timeout
|
|
218
153
|
@host_name = name
|
|
219
|
-
@label = name.to_s.empty? ?
|
|
154
|
+
@label = name.to_s.empty? ? LABEL : name.to_s
|
|
220
155
|
@http = LLM::HTTP.new(label: @label, open_timeout: @open_timeout, read_timeout: @read_timeout,
|
|
221
156
|
sleeper: sleeper, first_token_timeout: first_token_timeout,
|
|
222
157
|
api_key: LLM::ApiKey.for(api_key_env, host: @label, env: env, scheme: auth))
|
|
@@ -228,9 +163,6 @@ module Samagotchi
|
|
|
228
163
|
# The host's config name, or nil when built without one.
|
|
229
164
|
attr_reader :host_name
|
|
230
165
|
|
|
231
|
-
# The wire-format strategy for this client's transport.
|
|
232
|
-
attr_reader :transport
|
|
233
|
-
|
|
234
166
|
# Send a raw prompt and return the model's completion text.
|
|
235
167
|
#
|
|
236
168
|
# llama.cpp can stream completion chunks as newline-delimited `data: {...}`
|
|
@@ -286,23 +218,20 @@ module Samagotchi
|
|
|
286
218
|
|
|
287
219
|
# Prefill +prompt+ and keep it in the server's cache, so the next
|
|
288
220
|
# request that starts with it only prefills what follows (PromptWarmup:
|
|
289
|
-
# the turn-end warm-up).
|
|
290
|
-
# any other transport. One non-streamed request generating one token
|
|
221
|
+
# the turn-end warm-up). One non-streamed request generating one token
|
|
291
222
|
# (n_predict 0 does the same on current builds, 1 on every one): no
|
|
292
223
|
# retries, no first-token limit, no stream events. Errors are raised.
|
|
293
224
|
# @param slot [Integer, nil] the slot to run on (`id_slot`): the one the
|
|
294
225
|
# last request used, which still holds its state
|
|
295
226
|
# @param images [Array<String>] as #complete's
|
|
296
|
-
# @return [Warmup
|
|
227
|
+
# @return [Warmup]
|
|
297
228
|
def warm_up(prompt, model: nil, slot: nil, images: [])
|
|
298
|
-
return nil unless @transport.native?
|
|
299
|
-
|
|
300
229
|
images = Array(images)
|
|
301
230
|
text = LLM::Utf8Scrub.call(images.empty? ? prompt : prompt.gsub(ImagePlan::NATIVE_PLACEHOLDER, media_marker!(model)))
|
|
302
231
|
payload = { prompt: images.empty? ? text : { prompt_string: text, multimodal_data: images },
|
|
303
232
|
n_predict: 1, cache_prompt: true, stream: false }
|
|
304
233
|
payload[:id_slot] = slot if slot.is_a?(Integer)
|
|
305
|
-
model_name =
|
|
234
|
+
model_name = Client.model_for_payload(model)
|
|
306
235
|
payload[:model] = model_name if model_name
|
|
307
236
|
uri = completion_uri
|
|
308
237
|
request = Net::HTTP::Post.new(uri)
|
|
@@ -317,13 +246,13 @@ module Samagotchi
|
|
|
317
246
|
|
|
318
247
|
# Whether llama.cpp's slot +slot+ is processing a request now (GET
|
|
319
248
|
# /slots, its is_processing): :busy or :idle. nil when unknown: no slot,
|
|
320
|
-
#
|
|
249
|
+
# the endpoint off (--no-slots), a slot it doesn't
|
|
321
250
|
# list, a bad body or a network error. One quick attempt (PromptWarmup:
|
|
322
251
|
# a warm-up pinned to a busy slot waits behind that request, and the
|
|
323
252
|
# next turn behind the warm-up).
|
|
324
253
|
# @return [Symbol, nil]
|
|
325
254
|
def slot_status(slot, model: nil)
|
|
326
|
-
return nil unless
|
|
255
|
+
return nil unless slot.is_a?(Integer)
|
|
327
256
|
|
|
328
257
|
model = model.to_s.strip
|
|
329
258
|
query = model.empty? ? "" : "?#{URI.encode_www_form(model: model)}"
|
|
@@ -368,7 +297,7 @@ module Samagotchi
|
|
|
368
297
|
next unless on_chunk
|
|
369
298
|
|
|
370
299
|
chunk = { content: content, payload: payload }
|
|
371
|
-
finish_reason =
|
|
300
|
+
finish_reason = Client.finish_reason_from(payload)
|
|
372
301
|
chunk[:finish_reason] = finish_reason if finish_reason
|
|
373
302
|
on_chunk.call(**chunk)
|
|
374
303
|
end
|
|
@@ -378,26 +307,26 @@ module Samagotchi
|
|
|
378
307
|
clear_props_failures!
|
|
379
308
|
result
|
|
380
309
|
rescue LLM::BadRequest => e
|
|
381
|
-
raise unless e.status == 404
|
|
310
|
+
raise unless e.status == 404
|
|
382
311
|
|
|
383
312
|
# No native /completion here: likely an OpenAI-compatible server.
|
|
384
313
|
raise LLM::BadRequest.new(e.message, host: e.host, status: e.status, attempts: e.attempts, hint: OPENAI_API_HINT)
|
|
385
314
|
rescue RequestCancelled, LLM::ProviderError
|
|
386
315
|
raise
|
|
387
316
|
rescue StandardError => e
|
|
388
|
-
raise "#{
|
|
317
|
+
raise "#{LABEL} request failed (#{@host}:#{@port}): #{e.message}"
|
|
389
318
|
end
|
|
390
319
|
|
|
391
320
|
# The running llama.cpp's media marker (/props), or a VisionUnsupported.
|
|
392
321
|
def media_marker!(model)
|
|
393
|
-
marker =
|
|
322
|
+
marker = VisionSupport.media_marker(server_props(model: model))
|
|
394
323
|
return marker if marker
|
|
395
324
|
|
|
396
325
|
raise LLM::VisionUnsupported.new("#{@label}: can't reach /props for the media marker", host: @label)
|
|
397
326
|
end
|
|
398
327
|
|
|
399
328
|
def list_models
|
|
400
|
-
uri = URI("#{@scheme}://#{@host}:#{@port}#{
|
|
329
|
+
uri = URI("#{@scheme}://#{@host}:#{@port}#{MODELS_PATH}")
|
|
401
330
|
response = @http.fetch(uri, Net::HTTP::Get.new(uri), log_fields: { purpose: "models" })
|
|
402
331
|
clear_props_failures!
|
|
403
332
|
parsed = JSON.parse(response.body.to_s)
|
|
@@ -405,11 +334,11 @@ module Samagotchi
|
|
|
405
334
|
rescue LLM::ProviderError
|
|
406
335
|
raise
|
|
407
336
|
rescue StandardError => e
|
|
408
|
-
raise "#{
|
|
337
|
+
raise "#{LABEL} model listing failed (#{@host}:#{@port}): #{e.message}"
|
|
409
338
|
end
|
|
410
339
|
|
|
411
|
-
# What the running server says about itself (/props), as a ServerProps
|
|
412
|
-
#
|
|
340
|
+
# What the running server says about itself (/props), as a ServerProps.
|
|
341
|
+
# One GET with short
|
|
413
342
|
# timeouts and no retry: it runs before generation and must never hold up
|
|
414
343
|
# a turn. The probe names the model (`?model=`): a llama.cpp router
|
|
415
344
|
# answers a stub without it, and a single-model server ignores it.
|
|
@@ -420,9 +349,6 @@ module Samagotchi
|
|
|
420
349
|
# or model list this client gets through clears the failures: a server
|
|
421
350
|
# that just answered is not the down or hung one the window remembered.
|
|
422
351
|
def server_props(model: nil)
|
|
423
|
-
path = @transport.props_path
|
|
424
|
-
return nil unless path
|
|
425
|
-
|
|
426
352
|
key = model.to_s
|
|
427
353
|
@props_mutex.synchronize do
|
|
428
354
|
return @props_cache[key] if @props_cache.key?(key)
|
|
@@ -431,7 +357,7 @@ module Samagotchi
|
|
|
431
357
|
return failed if failed && monotonic_now - failed_at < PROPS_FAILURE_TTL
|
|
432
358
|
end
|
|
433
359
|
|
|
434
|
-
props = probe_props(
|
|
360
|
+
props = probe_props(PROPS_PATH, key)
|
|
435
361
|
@props_mutex.synchronize do
|
|
436
362
|
case props.status
|
|
437
363
|
when :cancelled then nil
|
|
@@ -451,10 +377,10 @@ module Samagotchi
|
|
|
451
377
|
end
|
|
452
378
|
|
|
453
379
|
# The context window (tokens) the running server was started with, or nil
|
|
454
|
-
# when the
|
|
380
|
+
# when the server reports none or the probe fails (see #server_props).
|
|
455
381
|
def context_window(model: nil)
|
|
456
382
|
props = server_props(model: model)
|
|
457
|
-
props&.answered? ?
|
|
383
|
+
props&.answered? ? Client.context_window_from(props.body) : nil
|
|
458
384
|
rescue StandardError
|
|
459
385
|
nil
|
|
460
386
|
end
|
|
@@ -505,28 +431,8 @@ module Samagotchi
|
|
|
505
431
|
nil
|
|
506
432
|
end
|
|
507
433
|
|
|
508
|
-
def resolve_transport(transport)
|
|
509
|
-
value = (transport || Samagotchi::Config.get("server.transport")).to_s.strip.downcase.to_sym
|
|
510
|
-
VALID_TRANSPORTS.include?(value) ? value : DEFAULT_TRANSPORT
|
|
511
|
-
end
|
|
512
|
-
|
|
513
|
-
# Build the wire-format strategy for a resolved transport name. oMLX gets
|
|
514
|
-
# a resolver that maps the short selector to the exact /v1/models id (see
|
|
515
|
-
# #resolve_omlx_model); mlx gets a resolver that always returns nil so the
|
|
516
|
-
# `model` field is omitted; llama.cpp uses the strategy's default (forward
|
|
517
|
-
# the selector verbatim).
|
|
518
|
-
def build_transport(name)
|
|
519
|
-
resolver = case name
|
|
520
|
-
when :omlx
|
|
521
|
-
->(model) { resolve_omlx_model(model) }
|
|
522
|
-
when :mlx
|
|
523
|
-
->(_model) {}
|
|
524
|
-
end
|
|
525
|
-
Transport.new(name, model_resolver: resolver)
|
|
526
|
-
end
|
|
527
|
-
|
|
528
434
|
def completion_uri
|
|
529
|
-
URI("#{@scheme}://#{@host}:#{@port}#{
|
|
435
|
+
URI("#{@scheme}://#{@host}:#{@port}#{COMPLETION_PATH}")
|
|
530
436
|
end
|
|
531
437
|
|
|
532
438
|
def completion_payload(prompt, stop:, n_predict:, model:, sampling: {}, slot: nil, cache_prompt: true)
|
|
@@ -534,16 +440,14 @@ module Samagotchi
|
|
|
534
440
|
# A non-positive cap is dropped, which leaves the length to the server
|
|
535
441
|
# (unbounded): 0 here never means "only process the prompt". The
|
|
536
442
|
# turn-end warm-up (#warm_up) sends its own payload for that reason.
|
|
537
|
-
payload[
|
|
538
|
-
model_name =
|
|
443
|
+
payload[:n_predict] = n_predict if n_predict && n_predict.to_i.positive?
|
|
444
|
+
model_name = Client.model_for_payload(model)
|
|
539
445
|
payload[:model] = model_name if model_name
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
payload[:id_slot] = slot if slot.is_a?(Integer)
|
|
546
|
-
end
|
|
446
|
+
# llama.cpp's default today, sent anyway: chi's next turn relies on it.
|
|
447
|
+
# false only for the retry of a malformed generation (KernelLoop),
|
|
448
|
+
# which a poisoned cache must not produce again.
|
|
449
|
+
payload[:cache_prompt] = cache_prompt != false
|
|
450
|
+
payload[:id_slot] = slot if slot.is_a?(Integer)
|
|
547
451
|
sendable_sampling(sampling).merge(payload)
|
|
548
452
|
end
|
|
549
453
|
|
|
@@ -553,48 +457,8 @@ module Samagotchi
|
|
|
553
457
|
(sampling || {}).reject { |key, value| value.nil? || ConfigFile::SAMPLING_RESERVED_KEYS.include?(key.to_s) }
|
|
554
458
|
end
|
|
555
459
|
|
|
556
|
-
# Resolve a user-facing SAMAGOTCHI_DEFAULT_MODEL selector to the exact id oMLX
|
|
557
|
-
# expects in the request body (an id from its `/v1/models` list).
|
|
558
|
-
#
|
|
559
|
-
# Resolution order: exact (case-insensitive) match first, then the first
|
|
560
|
-
# substring match, else the selector passes through unchanged so oMLX returns
|
|
561
|
-
# its own 404 listing the available models. An empty selector yields nil (no
|
|
562
|
-
# model field). The id list is loaded once per client and memoized, but
|
|
563
|
-
# resolution runs on every completion so a runtime model switch re-resolves.
|
|
564
|
-
def resolve_omlx_model(raw)
|
|
565
|
-
value = raw.to_s.strip
|
|
566
|
-
return nil if value.empty?
|
|
567
|
-
|
|
568
|
-
ids = fetch_omlx_model_ids
|
|
569
|
-
return value if ids.empty?
|
|
570
|
-
|
|
571
|
-
ids.find { |id| id.casecmp?(value) } ||
|
|
572
|
-
ids.select { |id| id.downcase.include?(value.downcase) }.first ||
|
|
573
|
-
value
|
|
574
|
-
end
|
|
575
|
-
|
|
576
|
-
# Load and memoize the list of model ids from oMLX's `/v1/models`.
|
|
577
|
-
#
|
|
578
|
-
# Only memoize on success: a failed `list_models` must leave the cache unset
|
|
579
|
-
# so the next completion retries (a transient blip shouldn't disable
|
|
580
|
-
# resolution for the whole session). On failure we return [] so an unknown
|
|
581
|
-
# selector still passes through raw, letting oMLX return its own 400/404
|
|
582
|
-
# (server decides), as documented in docs/configuration.md.
|
|
583
|
-
def fetch_omlx_model_ids
|
|
584
|
-
return @omlx_model_ids if defined?(@omlx_model_ids)
|
|
585
|
-
|
|
586
|
-
begin
|
|
587
|
-
ids = list_models.map { |m| m.is_a?(Hash) ? m["id"] : m }
|
|
588
|
-
rescue StandardError
|
|
589
|
-
return []
|
|
590
|
-
end
|
|
591
|
-
|
|
592
|
-
@omlx_model_ids = ids
|
|
593
|
-
ids
|
|
594
|
-
end
|
|
595
|
-
|
|
596
460
|
# Returns [content, payload] for a streamed SSE line, or nil to skip
|
|
597
|
-
# (blank lines, non-data lines, and
|
|
461
|
+
# (blank lines, non-data lines, and a `[DONE]` sentinel).
|
|
598
462
|
# Raises the ProviderError of a server's error event.
|
|
599
463
|
def parse_stream_line(line)
|
|
600
464
|
error = LLM::HTTP.sse_error(line, host: @label)
|
|
@@ -609,8 +473,7 @@ module Samagotchi
|
|
|
609
473
|
rescue JSON::ParserError => e
|
|
610
474
|
raise LLM::ProtocolError.new("#{@label}: malformed stream chunk: #{e.message[0, 200]}", host: @label)
|
|
611
475
|
end
|
|
612
|
-
content
|
|
613
|
-
[content, payload]
|
|
476
|
+
[payload.fetch("content", ""), payload]
|
|
614
477
|
end
|
|
615
478
|
end
|
|
616
479
|
end
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -84,7 +84,6 @@ module Samagotchi
|
|
|
84
84
|
# The prompt profile for every model in this process (ModelProfile::NAMES).
|
|
85
85
|
# No config key: per-model and per-host profiles live in models: and hosts:.
|
|
86
86
|
Entry.new(key: "model.profile", yaml_path: %w[model profile], type: :enum, default: nil, expose: %i[env cli], enum_values: %w[qwen36 gemma4]),
|
|
87
|
-
Entry.new(key: "server.transport", yaml_path: %w[server transport], type: :enum, default: "llama_cpp", expose: %i[env config cli], enum_values: %w[llama_cpp mlx omlx], lifecycle: :worker_start),
|
|
88
87
|
Entry.new(key: "server.host", yaml_path: %w[server host], type: :string, default: "localhost", expose: %i[env config cli], lifecycle: :worker_start),
|
|
89
88
|
Entry.new(key: "server.port", yaml_path: %w[server port], type: :integer, default: 8080, expose: %i[env config cli], lifecycle: :worker_start),
|
|
90
89
|
Entry.new(key: "server.open_timeout", yaml_path: %w[server open_timeout], type: :integer, default: 10, expose: %i[env config cli], lifecycle: :worker_start),
|
|
@@ -205,7 +204,6 @@ module Samagotchi
|
|
|
205
204
|
# The policy sentence forget_outputs' description carries (the forget layer); blank: none.
|
|
206
205
|
Entry.new(key: "llm_context.policy", yaml_path: %w[llm_context policy], type: :string, default: LLMContextStrategy::DEFAULT_POLICY, expose: %i[env config], lifecycle: :worker_start),
|
|
207
206
|
|
|
208
|
-
Entry.new(key: "thinking.turn_preamble", yaml_path: %w[thinking turn_preamble], type: :bool, default: true, expose: %i[env config cli], lifecycle: :prompt_rebuild),
|
|
209
207
|
# How much models think: off|low|medium|high|default, parsed by Thinking
|
|
210
208
|
# (a :string, since YAML reads an unquoted off as false). The CLI flag
|
|
211
209
|
# is bin/chi's own --thinking; models: and hosts: entries rank below
|
|
@@ -312,7 +310,7 @@ module Samagotchi
|
|
|
312
310
|
# Maps of named entries and the keys an entry may hold
|
|
313
311
|
# (ConfigFile.hosts_config, ConfigFile.model_settings).
|
|
314
312
|
MAP_ENTRY_KEYS = {
|
|
315
|
-
"hosts" => %w[host port url
|
|
313
|
+
"hosts" => %w[host port url api api_key_env auth dialect profile first_token_timeout vision sampling thinking
|
|
316
314
|
reasoning_history enabled remote window_tokens steer_cut_after llm_context_strategy llm_context_apply llm_context_budget_tokens models].freeze,
|
|
317
315
|
"models" => %w[profile vision sampling thinking reasoning_history window_tokens steer_cut_after llm_context_strategy llm_context_apply
|
|
318
316
|
llm_context_budget_tokens].freeze
|
|
@@ -644,6 +642,7 @@ module Samagotchi
|
|
|
644
642
|
# nil when +dotted+ is a key the file may hold.
|
|
645
643
|
def key_problem(dotted)
|
|
646
644
|
return nil if known_config_keys.include?(dotted)
|
|
645
|
+
return "config: #{ConfigFile.removed_transport_problem("'#{dotted}'")}" if dotted == "server.transport"
|
|
647
646
|
if (replaced = REPLACED_KEYS[dotted])
|
|
648
647
|
return "config: unknown key '#{dotted}': replaced by '#{replaced}' (hard, standard or soft; see docs/guardrails.md)"
|
|
649
648
|
end
|
|
@@ -669,6 +668,8 @@ module Samagotchi
|
|
|
669
668
|
next [] unless entry.is_a?(Hash)
|
|
670
669
|
|
|
671
670
|
(entry.keys.map(&:to_s) - allowed).map do |k|
|
|
671
|
+
next "config: #{ConfigFile.removed_transport_problem("'hosts.#{name}.transport'")}" if map == "hosts" && k == "transport"
|
|
672
|
+
|
|
672
673
|
unknown_key_message("#{map}.#{name}.#{k}", k, allowed, prefix: "#{map}.#{name}.")
|
|
673
674
|
end + (map == "hosts" ? host_model_problems(name, entry["models"]) : [])
|
|
674
675
|
end
|
|
@@ -887,13 +888,17 @@ module Samagotchi
|
|
|
887
888
|
end
|
|
888
889
|
|
|
889
890
|
# Check config.yml once at start (warnings for keys the code doesn't
|
|
890
|
-
# read) and clear the CLI settings. Nothing is copied into ENV: every
|
|
891
|
+
# read, and for SAMAGOTCHI_SERVER_TRANSPORT) and clear the CLI settings. Nothing is copied into ENV: every
|
|
891
892
|
# Config.get reads the file again when it changed, so a long-lived
|
|
892
893
|
# process (chi web) and the workers it spawns see edits, and a file
|
|
893
894
|
# value reports origin :file. Returns true if the file existed.
|
|
894
895
|
def load!(env: ENV, path: global_path(env: env))
|
|
895
896
|
existed = File.file?(path)
|
|
896
897
|
raw = read_yaml(env: env, path: path) || {}
|
|
898
|
+
check_removed_apis!(raw, env: env)
|
|
899
|
+
unless env[REMOVED_TRANSPORT_ENV].to_s.strip.empty?
|
|
900
|
+
Log.warn(:config, "config_key", echo: "Warning: #{removed_transport_problem(REMOVED_TRANSPORT_ENV)}")
|
|
901
|
+
end
|
|
897
902
|
if existed
|
|
898
903
|
Samagotchi::Config.validate_yaml_sections(raw).each { |w| Log.warn(:config, "config_key", echo: "Warning: #{w}") }
|
|
899
904
|
end
|
|
@@ -901,6 +906,39 @@ module Samagotchi
|
|
|
901
906
|
existed
|
|
902
907
|
end
|
|
903
908
|
|
|
909
|
+
# Raises RemovedApi for the first api/transport in +data+ (config.yml)
|
|
910
|
+
# or the environment that names a removed one: config.yml's
|
|
911
|
+
# hosts.<name>.api, and the removed transport keys (server.transport,
|
|
912
|
+
# hosts.<name>.transport, SAMAGOTCHI_SERVER_TRANSPORT), which with
|
|
913
|
+
# another value only warn (#removed_transport_problem). Run once at
|
|
914
|
+
# start (#load!); a hosts entry read later says the same through
|
|
915
|
+
# HostConfig.parse and is left out.
|
|
916
|
+
def check_removed_apis!(data, env: ENV)
|
|
917
|
+
env_value = env[REMOVED_TRANSPORT_ENV]
|
|
918
|
+
raise RemovedApi.new(REMOVED_TRANSPORT_ENV, env_value.to_s.strip) if removed_api?(env_value)
|
|
919
|
+
|
|
920
|
+
server = data["server"] if data.is_a?(Hash)
|
|
921
|
+
value = server["transport"] if server.is_a?(Hash)
|
|
922
|
+
raise RemovedApi.new("config.yml server.transport", value) if removed_api?(value)
|
|
923
|
+
|
|
924
|
+
hosts = data[HOSTS_KEY] if data.is_a?(Hash)
|
|
925
|
+
return unless hosts.is_a?(Hash)
|
|
926
|
+
|
|
927
|
+
hosts.each do |name, cfg|
|
|
928
|
+
next unless cfg.is_a?(Hash)
|
|
929
|
+
|
|
930
|
+
%w[api transport].each do |key|
|
|
931
|
+
value = cfg[key]
|
|
932
|
+
raise RemovedApi.new("config.yml hosts.#{name}.#{key}", value) if removed_api?(value)
|
|
933
|
+
end
|
|
934
|
+
end
|
|
935
|
+
end
|
|
936
|
+
|
|
937
|
+
# Whether +value+ (as written) names a removed api/transport.
|
|
938
|
+
def removed_api?(value)
|
|
939
|
+
REMOVED_APIS.include?(value.to_s.strip.downcase)
|
|
940
|
+
end
|
|
941
|
+
|
|
904
942
|
def config_dir(env: ENV)
|
|
905
943
|
File.join(Paths.config_home(env: env), CONFIG_DIR)
|
|
906
944
|
end
|
|
@@ -909,18 +947,42 @@ module Samagotchi
|
|
|
909
947
|
File.join(config_dir(env: env), CONFIG_FILE)
|
|
910
948
|
end
|
|
911
949
|
|
|
950
|
+
# A key chi no longer reads, set to anything but a removed api (that is
|
|
951
|
+
# a RemovedApi): config.yml server.transport, a hosts entry's
|
|
952
|
+
# transport: or the environment variable. llama.cpp is the one
|
|
953
|
+
# raw-prompt transport, so the key changes nothing: a warning, the
|
|
954
|
+
# config still loads. +where+ names it as the reader shows it.
|
|
955
|
+
def removed_transport_problem(where)
|
|
956
|
+
"#{where} was removed: llama.cpp is chi's only raw-prompt transport " \
|
|
957
|
+
"(a hosts entry without api: is api: llama_cpp); remove it"
|
|
958
|
+
end
|
|
959
|
+
|
|
960
|
+
REMOVED_TRANSPORT_ENV = "SAMAGOTCHI_SERVER_TRANSPORT"
|
|
912
961
|
HOSTS_KEY = "hosts"
|
|
913
|
-
|
|
914
|
-
#
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
962
|
+
# api/transport values chi no longer has: its own mlx_lm.server and
|
|
963
|
+
# oMLX transports, gone for their OpenAI chat endpoint (api: openai).
|
|
964
|
+
REMOVED_APIS = %w[mlx omlx].freeze
|
|
965
|
+
|
|
966
|
+
# A config naming a removed api/transport (REMOVED_APIS). The message
|
|
967
|
+
# is the one line the user sees: where, the value, what to write.
|
|
968
|
+
class RemovedApi < StandardError
|
|
969
|
+
# @param where [String] e.g. "config.yml hosts.mac.api"
|
|
970
|
+
# @param value [String] the removed value as written
|
|
971
|
+
def initialize(where, value)
|
|
972
|
+
super("#{where}: #{value} was removed; for mlx_lm.server or oMLX use url: http://HOST:PORT/v1, api: openai in a hosts: entry")
|
|
973
|
+
end
|
|
974
|
+
end
|
|
975
|
+
# How chi talks to a host: llama_cpp (the raw-prompt loop over
|
|
976
|
+
# llama.cpp's own API, also what a hosts entry without api: talks), or a
|
|
977
|
+
# chat api the chat loop talks (LLM::Adapters): openai (Chat Completions
|
|
978
|
+
# against <host>/v1) or anthropic (Messages).
|
|
979
|
+
VALID_APIS_FOR_CONFIG = %w[llama_cpp openai anthropic].freeze
|
|
918
980
|
HOST_NAME_RE = /\A[a-z0-9][a-z0-9._-]*\z/i
|
|
919
981
|
ENV_NAME_RE = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
920
982
|
|
|
921
983
|
# config.yml's hosts: by lowercased name; an entry that is disabled or
|
|
922
984
|
# invalid (warned) is left out. No entry left: "default", from
|
|
923
|
-
# server.host/port
|
|
985
|
+
# server.host/port.
|
|
924
986
|
# @return [Hash{String => HostConfig}]
|
|
925
987
|
def hosts_config(env: ENV, path: global_path(env: env))
|
|
926
988
|
data = read_yaml(env: env, path: path)
|
|
@@ -941,17 +1003,14 @@ module Samagotchi
|
|
|
941
1003
|
{}
|
|
942
1004
|
end
|
|
943
1005
|
|
|
944
|
-
# The host chi uses when hosts: names none: server.host/port
|
|
1006
|
+
# The host chi uses when hosts: names none: server.host/port.
|
|
945
1007
|
def default_host_config(data, env)
|
|
946
1008
|
opts = { file_data: data, env: env, cli_overrides: Samagotchi::Config.cli_overrides }
|
|
947
1009
|
host = Samagotchi::Config.resolve("server.host", **opts).to_s.strip
|
|
948
1010
|
host = "localhost" if host.empty?
|
|
949
1011
|
port = Samagotchi::Config.resolve("server.port", **opts).to_i
|
|
950
1012
|
port = 8080 if port <= 0 || port > 65_535
|
|
951
|
-
|
|
952
|
-
origin == :default ? nil : value.to_sym
|
|
953
|
-
end
|
|
954
|
-
HostConfig.new(name: "default", host: host, port: port, transport: transport)
|
|
1013
|
+
HostConfig.new(name: "default", host: host, port: port)
|
|
955
1014
|
end
|
|
956
1015
|
private_class_method :default_host_config
|
|
957
1016
|
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -1627,8 +1627,8 @@ module Samagotchi
|
|
|
1627
1627
|
# prompt prefix, and the server's KV cache for it, stay stable.
|
|
1628
1628
|
# @param model [EffectiveModel, nil]
|
|
1629
1629
|
# @return [String]
|
|
1630
|
-
# The native prompt depends on the thinking level too (Gemma's token
|
|
1631
|
-
#
|
|
1630
|
+
# The native prompt depends on the thinking level too (Gemma's token):
|
|
1631
|
+
# a level change builds it again. A build that
|
|
1632
1632
|
# misses the cache names the model and the level through its own
|
|
1633
1633
|
# lambdas (#model_identity, #turn_thinking), which resolve again.
|
|
1634
1634
|
def system_prompt(model = nil)
|
|
@@ -1644,8 +1644,8 @@ module Samagotchi
|
|
|
1644
1644
|
end
|
|
1645
1645
|
|
|
1646
1646
|
# The base prompt (specs, plugins' declarations).
|
|
1647
|
-
def assist_system_prompt(chat: false
|
|
1648
|
-
@prompt_builder.base(chat: chat
|
|
1647
|
+
def assist_system_prompt(chat: false)
|
|
1648
|
+
@prompt_builder.base(chat: chat)
|
|
1649
1649
|
end
|
|
1650
1650
|
|
|
1651
1651
|
# The --memory names activated while building the prompt; the TerminalUI
|
|
@@ -2048,10 +2048,10 @@ module Samagotchi
|
|
|
2048
2048
|
|
|
2049
2049
|
# Never a paid or remote host (cache.warmup auto means local only), nor
|
|
2050
2050
|
# the chat loop (Splash keeps its own cache; llama.cpp's chat path is
|
|
2051
|
-
# not measured), nor a
|
|
2051
|
+
# not measured), nor a client that can't warm up (a spec's stub).
|
|
2052
2052
|
def warmup_target?(model, client)
|
|
2053
2053
|
return false if model.chat? || model.entry.remote?
|
|
2054
|
-
return false unless client.respond_to?(:warm_up)
|
|
2054
|
+
return false unless client.respond_to?(:warm_up)
|
|
2055
2055
|
|
|
2056
2056
|
PromptWarmup.enabled?
|
|
2057
2057
|
end
|
|
@@ -2095,7 +2095,7 @@ module Samagotchi
|
|
|
2095
2095
|
# could change what a resolve gives (the hooks ran before).
|
|
2096
2096
|
model = effective_model
|
|
2097
2097
|
sync_kernel_client!(model)
|
|
2098
|
-
# Route model name as bare (without host prefix) to the
|
|
2098
|
+
# Route model name as bare (without host prefix) to the server;
|
|
2099
2099
|
# host selection already done via active client.
|
|
2100
2100
|
bare_for_backend = bare_model_name(model.name)
|
|
2101
2101
|
# The turn's settings, set once here. The chat loop dispatches tools
|