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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -1
  3. data/README.md +8 -8
  4. data/bin/chi +6 -2
  5. data/docs/architecture.md +4 -5
  6. data/docs/cli.md +4 -4
  7. data/docs/configuration.md +49 -86
  8. data/docs/hooks.md +2 -2
  9. data/docs/internals/bundles.md +1 -1
  10. data/docs/internals/context-telemetry.md +1 -1
  11. data/docs/internals/engine.md +7 -4
  12. data/docs/memory.md +26 -1
  13. data/docs/plugins.md +37 -0
  14. data/docs/releasing.md +2 -2
  15. data/docs/sessions.md +1 -1
  16. data/docs/testing.md +16 -11
  17. data/lib/samagotchi/bootstrap/config_writer.rb +1 -5
  18. data/lib/samagotchi/bootstrap_command.rb +1 -0
  19. data/lib/samagotchi/bundle_command/try/sandbox.rb +8 -2
  20. data/lib/samagotchi/bundle_command.rb +1 -0
  21. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  22. data/lib/samagotchi/bundles/model-notes/manifest.yml +11 -0
  23. data/lib/samagotchi/bundles/model-notes/model_notes_deepseek.md +7 -0
  24. data/lib/samagotchi/bundles/model-notes/notes/README.md +37 -0
  25. data/lib/samagotchi/bundles/system/config_modification_protocol.md +6 -7
  26. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  27. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  28. data/lib/samagotchi/client.rb +59 -196
  29. data/lib/samagotchi/config.rb +74 -15
  30. data/lib/samagotchi/engine.rb +7 -7
  31. data/lib/samagotchi/host_config.rb +20 -27
  32. data/lib/samagotchi/host_registry.rb +8 -8
  33. data/lib/samagotchi/kernel_loop.rb +46 -8
  34. data/lib/samagotchi/memory_bundle/profile.rb +22 -4
  35. data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -1
  36. data/lib/samagotchi/model_profile.rb +2 -3
  37. data/lib/samagotchi/self_report.rb +0 -2
  38. data/lib/samagotchi/system_prompt.rb +7 -21
  39. data/lib/samagotchi/terminal_ui/thinking_line.rb +2 -4
  40. data/lib/samagotchi/testing/sandbox.rb +26 -0
  41. data/lib/samagotchi/thinking.rb +35 -2
  42. data/lib/samagotchi/thinking_notices.rb +11 -4
  43. data/lib/samagotchi/update_command.rb +1 -1
  44. data/lib/samagotchi/version.rb +1 -1
  45. data/lib/samagotchi/vision_support.rb +0 -3
  46. data/lib/samagotchi/web/public/app.js +15 -82
  47. data/lib/samagotchi/web/public/dock_reserve.js +103 -0
  48. data/lib/samagotchi/web/public/index.html +3 -3
  49. data/lib/samagotchi/web/public/toast_place.js +1 -1
  50. metadata +5 -1
@@ -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, or an
16
- # OpenAI-compatible /v1/completions endpoint (e.g. mlx_lm.server or oMLX).
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; oMLX's default is 8000, set it to match)
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
- DEFAULT_TRANSPORT = :llama_cpp
40
- VALID_TRANSPORTS = %i[llama_cpp mlx omlx].freeze
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
- # Wire-format strategy for one server transport. `Client` keeps the
68
- # transport-agnostic request/retry/stream loop; everything that differs
69
- # between llama.cpp's native API and the OpenAI-compatible servers
70
- # (mlx_lm.server, oMLX) lives here: endpoint paths, payload keys, streamed
71
- # content parsing, and the request's `model` field semantics.
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
- # Why the stream stopped, from its last payload, as an OpenAI finish
121
- # reason ("length" for the token cap or a full context, "stop"
122
- # otherwise); nil for a payload that doesn't say. llama.cpp's
123
- # /completion names it stop_type ("limit", "eos", "word"); the
124
- # OpenAI-compatible servers send their own finish_reason.
125
- def finish_reason_from(payload)
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
- # The request's `model` field for this transport (nil = omit the field):
136
- # - llama.cpp: forward the selector (SAMAGOTCHI_DEFAULT_MODEL) verbatim.
137
- # - mlx_lm.server: omit `model` entirely (use whatever was loaded via the
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
- # llama.cpp's own API: slots (`id_slot`), `cache_prompt`, /props.
152
- def native? = @name == :llama_cpp
153
-
154
- private
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: the transport's label)
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, transport: nil, sleeper: nil, scheme: 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? ? @transport.label : name.to_s
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). llama.cpp's native /completion only; nil on
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, nil]
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 = @transport.model_for_payload(model)
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
- # another transport, the endpoint off (--no-slots), a slot it doesn't
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 @transport.native? && slot.is_a?(Integer)
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 = @transport.finish_reason_from(payload)
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 && @transport.name == :llama_cpp
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 "#{@transport.label} request failed (#{@host}:#{@port}): #{e.message}"
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 = @transport.props_path && VisionSupport.media_marker(server_props(model: model))
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}#{@transport.models_path}")
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 "#{@transport.label} model listing failed (#{@host}:#{@port}): #{e.message}"
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
- # or nil when the transport has no such route. One GET with short
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(path, key)
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 transport reports none or the probe fails (see #server_props).
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? ? @transport.context_window_from(props.body) : nil
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}#{@transport.completion_path}")
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[@transport.token_limit_key] = n_predict if n_predict && n_predict.to_i.positive?
538
- model_name = @transport.model_for_payload(model)
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
- if @transport.native?
541
- # llama.cpp's default today, sent anyway: chi's next turn relies on it.
542
- # false only for the retry of a malformed generation (KernelLoop),
543
- # which a poisoned cache must not produce again.
544
- payload[:cache_prompt] = cache_prompt != false
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 the mlx/oMLX `[DONE]` sentinel).
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 = @transport.content_from_payload(payload)
613
- [content, payload]
476
+ [payload.fetch("content", ""), payload]
614
477
  end
615
478
  end
616
479
  end
@@ -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 transport api api_key_env auth dialect profile first_token_timeout vision sampling thinking
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
- VALID_TRANSPORTS_FOR_CONFIG = %w[llama_cpp mlx omlx].freeze
914
- # How chi talks to a host: a raw-prompt api (also the Client transport),
915
- # or a chat api the chat loop talks (LLM::Adapters): openai (Chat
916
- # Completions against <host>/v1) or anthropic (Messages).
917
- VALID_APIS_FOR_CONFIG = (VALID_TRANSPORTS_FOR_CONFIG + %w[openai anthropic]).freeze
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/transport.
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/transport.
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
- transport = Samagotchi::Config.resolve_with_origin("server.transport", **opts).then do |value, origin|
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
 
@@ -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
- # Qwen's turn preamble): a level change builds it again. A build that
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, thinking: nil)
1648
- @prompt_builder.base(chat: chat, thinking: thinking)
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 non-llama.cpp transport.
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) && client.respond_to?(:transport) && client.transport.native?
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 transport;
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