samagotchi 0.2.0 → 0.3.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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -1
  3. data/README.md +40 -4
  4. data/bin/chi +86 -19
  5. data/docs/cli.md +108 -5
  6. data/docs/configuration.md +229 -45
  7. data/docs/desktop.md +6 -0
  8. data/docs/hooks.md +126 -5
  9. data/docs/plugins.md +68 -2
  10. data/docs/releasing.md +18 -8
  11. data/docs/sessions.md +30 -4
  12. data/lib/samagotchi/answer_display.rb +95 -0
  13. data/lib/samagotchi/archive_store.rb +90 -0
  14. data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
  15. data/lib/samagotchi/bootstrap/probe.rb +262 -0
  16. data/lib/samagotchi/bootstrap_command.rb +347 -0
  17. data/lib/samagotchi/bridge/pending_card.rb +89 -0
  18. data/lib/samagotchi/bridge/turn_accumulator.rb +14 -3
  19. data/lib/samagotchi/bridge.rb +9 -0
  20. data/lib/samagotchi/bridge_client.rb +6 -2
  21. data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
  22. data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
  23. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +358 -0
  24. data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
  25. data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
  26. data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
  27. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  29. data/lib/samagotchi/client.rb +72 -13
  30. data/lib/samagotchi/config.rb +196 -36
  31. data/lib/samagotchi/desktop/macos/ChiRunner.swift +13 -7
  32. data/lib/samagotchi/desktop/macos/Panel.swift +71 -19
  33. data/lib/samagotchi/empty_answer_retry.rb +43 -0
  34. data/lib/samagotchi/engine.rb +233 -36
  35. data/lib/samagotchi/guardrails/approval.rb +9 -0
  36. data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
  37. data/lib/samagotchi/guardrails.rb +1 -0
  38. data/lib/samagotchi/hooks/registry.rb +24 -5
  39. data/lib/samagotchi/host_registry.rb +4 -3
  40. data/lib/samagotchi/idle_recap.rb +5 -1
  41. data/lib/samagotchi/kernel_loop.rb +47 -15
  42. data/lib/samagotchi/llm/chat_loop.rb +59 -20
  43. data/lib/samagotchi/llm/errors.rb +21 -3
  44. data/lib/samagotchi/llm/http.rb +42 -13
  45. data/lib/samagotchi/llm/openai_chat.rb +12 -4
  46. data/lib/samagotchi/log_subscriber.rb +18 -3
  47. data/lib/samagotchi/model_profile.rb +1 -1
  48. data/lib/samagotchi/plugin/context.rb +22 -1
  49. data/lib/samagotchi/plugin/sessions.rb +3 -1
  50. data/lib/samagotchi/reply_wait.rb +126 -0
  51. data/lib/samagotchi/sampling_settings.rb +58 -0
  52. data/lib/samagotchi/self_report.rb +1 -0
  53. data/lib/samagotchi/send_command.rb +153 -7
  54. data/lib/samagotchi/session.rb +52 -11
  55. data/lib/samagotchi/session_archive_command.rb +107 -0
  56. data/lib/samagotchi/session_commands.rb +11 -2
  57. data/lib/samagotchi/session_manager.rb +114 -9
  58. data/lib/samagotchi/session_metrics.rb +222 -106
  59. data/lib/samagotchi/steer.rb +72 -0
  60. data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
  61. data/lib/samagotchi/terminal_ui/event_renderer.rb +13 -8
  62. data/lib/samagotchi/terminal_ui/formatting.rb +31 -8
  63. data/lib/samagotchi/terminal_ui/input_support.rb +3 -0
  64. data/lib/samagotchi/terminal_ui.rb +77 -4
  65. data/lib/samagotchi/tool_activity.rb +3 -1
  66. data/lib/samagotchi/tools/builtins.rb +15 -4
  67. data/lib/samagotchi/tools/delegate_wait.rb +26 -69
  68. data/lib/samagotchi/tools/execute.rb +52 -14
  69. data/lib/samagotchi/tools/task_runtime.rb +19 -0
  70. data/lib/samagotchi/tools/task_wait.rb +27 -3
  71. data/lib/samagotchi/turn_note.rb +60 -6
  72. data/lib/samagotchi/version.rb +1 -1
  73. data/lib/samagotchi/vision_support.rb +2 -6
  74. data/lib/samagotchi/web/app.rb +88 -4
  75. data/lib/samagotchi/web/public/activity.js +10 -1
  76. data/lib/samagotchi/web/public/annotate_presets.js +26 -0
  77. data/lib/samagotchi/web/public/annotations.js +13 -0
  78. data/lib/samagotchi/web/public/app.js +437 -88
  79. data/lib/samagotchi/web/public/card.js +5 -3
  80. data/lib/samagotchi/web/public/chat_view.js +10 -1
  81. data/lib/samagotchi/web/public/copy.js +20 -4
  82. data/lib/samagotchi/web/public/ctx.js +15 -0
  83. data/lib/samagotchi/web/public/data.js +21 -6
  84. data/lib/samagotchi/web/public/format.js +9 -0
  85. data/lib/samagotchi/web/public/index.html +38 -2
  86. data/lib/samagotchi/web/public/notify.js +175 -0
  87. data/lib/samagotchi/web/public/question_card.js +2 -1
  88. data/lib/samagotchi/web/public/sessions_list.js +7 -0
  89. data/lib/samagotchi/web/public/timing.js +39 -14
  90. data/lib/samagotchi/web/public/turn_events.js +46 -0
  91. data/lib/samagotchi/web/public/turn_view.js +47 -7
  92. data/lib/samagotchi/web/server.rb +8 -4
  93. data/lib/samagotchi/web/session_hub.rb +2 -1
  94. data/lib/samagotchi/web/session_summary.rb +24 -1
  95. data/lib/samagotchi/worker.rb +11 -0
  96. metadata +20 -1
@@ -0,0 +1,342 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+ require "fileutils"
6
+ require "ipaddr"
7
+ require_relative "../config"
8
+
9
+ module Samagotchi
10
+ module Bootstrap
11
+ # Writes the host `chi bootstrap` found into config.yml. With no file it
12
+ # writes a small commented one; an existing file is the edge case and
13
+ # gets a hosts: entry inserted as text (YAML.dump would drop the user's
14
+ # comments), after a backup, and is checked by parsing it again: every
15
+ # other key must be unchanged, or the backup goes back.
16
+ class ConfigWriter
17
+ Error = Class.new(StandardError)
18
+
19
+ # kind: :new (a fresh file), :appended, :exists (the host is already
20
+ # there as +existing+; nothing written), :snippet (a file chi can't
21
+ # edit safely: paste +text+ yourself), :failed (written, but the check
22
+ # failed and the backup is back), :dry_run (+text+ is what would be
23
+ # written or inserted, +where+ says where)
24
+ # model_hint: a line for the user to add when default: exists without model
25
+ Outcome = Data.define(:kind, :path, :backup, :text, :where, :name, :existing, :default_model, :model_hint) do
26
+ def self.of(kind, path:, backup: nil, text: nil, where: nil, name: nil, existing: nil, default_model: nil,
27
+ model_hint: nil)
28
+ new(kind: kind, path: path, backup: backup, text: text, where: where, name: name, existing: existing,
29
+ default_model: default_model, model_hint: model_hint)
30
+ end
31
+ end
32
+
33
+ HOSTS_LINE_RE = /\Ahosts:[ \t]*(#.*)?\z/
34
+ DEFAULT_INDENT = 2
35
+ SERVER_TRANSPORTS = ConfigFile::VALID_TRANSPORTS_FOR_CONFIG
36
+
37
+ # The host part of a name: localhost/127.x → local, another IP → lan,
38
+ # a domain → its second-level label (api.openai.com → openai).
39
+ def self.derived_name(host)
40
+ text = host.to_s.downcase.delete_prefix("[").delete_suffix("]")
41
+ address = begin
42
+ IPAddr.new(text)
43
+ rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
44
+ nil
45
+ end
46
+ return "local" if text == "localhost" || address&.loopback?
47
+ return "lan" if address
48
+
49
+ labels = text.split(".").reject(&:empty?)
50
+ name = (labels.length >= 2 ? labels[-2] : labels.first).to_s.gsub(/[^a-z0-9._-]/, "-")
51
+ name.match?(ConfigFile::HOST_NAME_RE) ? name : "host"
52
+ end
53
+
54
+ attr_reader :path
55
+
56
+ # @param path [String] config.yml (a symlink is written through)
57
+ # @param env [Hash] the environment for the check (SAMAGOTCHI_HOSTS_JSON
58
+ # is dropped: it would replace the file's hosts)
59
+ # @param now [Proc] the time for the backup's name and the header
60
+ def initialize(path:, env: ENV, now: -> { Time.now })
61
+ @path = path
62
+ @env = env.to_h.except("SAMAGOTCHI_HOSTS_JSON")
63
+ @now = now
64
+ end
65
+
66
+ def exists? = File.file?(@path)
67
+
68
+ # The file's data: {} for an empty file, nil when there is none or it
69
+ # doesn't parse.
70
+ def data
71
+ return @data if defined?(@data)
72
+
73
+ @data = exists? ? (parse(File.read(@path)) || {}) : nil
74
+ rescue StandardError
75
+ @data = nil
76
+ end
77
+
78
+ # The hosts entries the file routes to today, name → fields: its
79
+ # hosts:, or with none the `default` chi makes from server.*.
80
+ def current_hosts
81
+ raw = data.is_a?(Hash) ? data["hosts"] : nil
82
+ return raw.to_h { |name, fields| [name.to_s.downcase, fields.is_a?(Hash) ? fields : {}] } if raw.is_a?(Hash)
83
+ return {} if data.is_a?(Hash) && data.key?("hosts")
84
+
85
+ { "default" => server_entry }
86
+ end
87
+
88
+ # The name of an existing entry that points at the same server, or nil.
89
+ def duplicate_of(fields)
90
+ current_hosts.find { |_, existing| same_server?(existing, fields) }&.first
91
+ end
92
+
93
+ # The entry's name: +requested+ as given (it must be free), else
94
+ # +base+, with -2, -3… when the name is taken or is the prefix of a
95
+ # model id (chi would read "openai/gpt-x" as host openai, model gpt-x).
96
+ def host_name(base, requested: nil, model_ids: [])
97
+ taken = current_hosts.keys
98
+ if requested
99
+ name = requested.to_s.strip
100
+ raise Error, "--name must match #{ConfigFile::HOST_NAME_RE.source}" unless name.match?(ConfigFile::HOST_NAME_RE)
101
+ raise Error, "config.yml already has a host named '#{name}'; pick another --name" if taken.include?(name.downcase)
102
+
103
+ return name
104
+ end
105
+
106
+ prefixes = [*model_ids, configured_default_model].compact.map { |id| id.to_s.split(%r{[:/]}, 2).first.downcase }
107
+ name = base
108
+ n = 1
109
+ name = "#{base}-#{n += 1}" while taken.include?(name) || prefixes.include?(name)
110
+ name
111
+ end
112
+
113
+ # Write the entry. @param fields [Hash] the hosts entry (string keys,
114
+ # in the order written) @param model [String] the model id
115
+ # @return [Outcome]
116
+ def write(name:, fields:, model:, dry_run: false)
117
+ default_model = "#{name}:#{model}"
118
+ return write_new(name, fields, default_model, dry_run) unless exists?
119
+
120
+ existing = duplicate_of(fields)
121
+ return Outcome.of(:exists, path: @path, name: name, existing: existing) if existing
122
+
123
+ append(name, fields, default_model, dry_run)
124
+ end
125
+
126
+ private
127
+
128
+ def write_new(name, fields, default_model, dry_run)
129
+ text = <<~YAML
130
+ # Written by chi bootstrap on #{@now.call.strftime("%Y-%m-%d")}; see docs/configuration.md
131
+ default:
132
+ model: #{scalar(default_model)}
133
+ hosts:
134
+ #{entry_lines(name, fields, DEFAULT_INDENT, DEFAULT_INDENT).join}# More settings: docs/configuration.md#all-settings
135
+ YAML
136
+ return Outcome.of(:dry_run, path: @path, text: text, where: "a new file", name: name, default_model: default_model) if dry_run
137
+
138
+ FileUtils.mkdir_p(File.dirname(@path))
139
+ atomic_write(@path, text)
140
+ Outcome.of(:new, path: @path, name: name, default_model: default_model)
141
+ end
142
+
143
+ def append(name, fields, default_model, dry_run)
144
+ original = File.binread(@path).force_encoding(Encoding::UTF_8)
145
+ return snippet(name, fields) unless data.is_a?(Hash) && !anchors?(original)
146
+
147
+ eol = original.include?("\r\n") ? "\r\n" : "\n"
148
+ lines = original.split(/(?<=\n)/)
149
+ lines[-1] = "#{lines[-1]}#{eol}" if lines.any? && !lines[-1].end_with?("\n")
150
+ hosts_at = lines.index { |line| line.chomp.match?(HOSTS_LINE_RE) }
151
+ return snippet(name, fields) if hosts_at.nil? && data.key?("hosts")
152
+
153
+ default_entry = hosts_at || !routed? ? nil : server_entry
154
+ add_model, model_hint = default_model_change(default_model)
155
+
156
+ if hosts_at
157
+ at, child, step = block_end(lines, hosts_at)
158
+ inserted = entry_lines(name, fields, child, step)
159
+ where = "in hosts:, after line #{at}"
160
+ lines.insert(at, *inserted.map { |line| line.sub(/\n\z/, eol) })
161
+ else
162
+ inserted = ["hosts:\n", *(default_entry ? entry_lines("default", default_entry, DEFAULT_INDENT, DEFAULT_INDENT) : []),
163
+ *entry_lines(name, fields, DEFAULT_INDENT, DEFAULT_INDENT)]
164
+ where = "a new hosts: section at the end"
165
+ lines.concat(inserted.map { |line| line.sub(/\n\z/, eol) })
166
+ end
167
+ if add_model
168
+ model_lines = ["default:\n", " model: #{scalar(default_model)}\n"]
169
+ inserted += model_lines
170
+ lines.concat(model_lines.map { |line| line.sub(/\n\z/, eol) })
171
+ end
172
+ text = lines.join
173
+ if dry_run
174
+ return Outcome.of(:dry_run, path: @path, text: inserted.join, where: where, name: name,
175
+ default_model: add_model ? default_model : nil, model_hint: model_hint)
176
+ end
177
+
178
+ real = File.realpath(@path)
179
+ backup = backup_path(real)
180
+ FileUtils.cp(real, backup, preserve: true)
181
+ atomic_write(real, text)
182
+ expected = expected_data(name, fields, default_entry, add_model ? default_model : nil)
183
+ unless written_ok?(real, expected, name)
184
+ atomic_write(real, File.binread(backup))
185
+ return Outcome.of(:failed, path: @path, backup: backup, text: snippet_text(name, fields), name: name)
186
+ end
187
+
188
+ Outcome.of(:appended, path: @path, backup: backup, name: name, default_model: add_model ? default_model : nil,
189
+ model_hint: model_hint)
190
+ end
191
+
192
+ # Where a new entry goes in the hosts: block starting at +hosts_at+:
193
+ # its end (the first entry is the default host, so never first),
194
+ # before trailing blank lines and comments shallower than an entry.
195
+ # @return [index, entry indent, field indent step]
196
+ def block_end(lines, hosts_at)
197
+ stop = hosts_at + 1
198
+ stop += 1 while stop < lines.length && inside_block?(lines[stop])
199
+ children = lines[(hosts_at + 1)...stop].reject { |line| blank_or_comment?(line) }
200
+ child = children.first ? indent(children.first) : DEFAULT_INDENT
201
+ field = children.find { |line| indent(line) > child }
202
+ step = field ? indent(field) - child : child
203
+ at = stop
204
+ at -= 1 while at > hosts_at + 1 && (lines[at - 1].strip.empty? || (comment?(lines[at - 1]) && indent(lines[at - 1]) < child))
205
+ [at, child, step]
206
+ end
207
+
208
+ def inside_block?(line)
209
+ return true if line.strip.empty?
210
+ return false if line.start_with?("---", "...")
211
+
212
+ line.start_with?(" ", "\t") || comment?(line)
213
+ end
214
+
215
+ def blank_or_comment?(line) = line.strip.empty? || comment?(line)
216
+ def comment?(line) = line.lstrip.start_with?("#")
217
+ def indent(line) = line[/\A */].length
218
+
219
+ def entry_lines(name, fields, child, step)
220
+ ["#{" " * child}#{name}:\n", *fields.map { |key, value| "#{" " * (child + step)}#{key}: #{scalar(value)}\n" }]
221
+ end
222
+
223
+ # Strings double-quoted (JSON's quoting is valid YAML): model ids hold
224
+ # "/" and ":".
225
+ def scalar(value) = value.is_a?(Integer) ? value.to_s : JSON.generate(value.to_s)
226
+
227
+ def snippet(name, fields)
228
+ Outcome.of(:snippet, path: @path, text: snippet_text(name, fields), name: name)
229
+ end
230
+
231
+ def snippet_text(name, fields) = "hosts:\n#{entry_lines(name, fields, DEFAULT_INDENT, DEFAULT_INDENT).join}"
232
+
233
+ # [add default.model?, a line to print instead]
234
+ def default_model_change(default_model)
235
+ return [false, nil] if configured_default_model
236
+ return [false, " model: #{scalar(default_model)} # under default:"] if data.key?("default")
237
+
238
+ [true, nil]
239
+ end
240
+
241
+ def configured_default_model
242
+ return nil unless data.is_a?(Hash)
243
+
244
+ section = data["default"]
245
+ value = section.is_a?(Hash) ? section["model"] : nil
246
+ value ||= data[ConfigFile::DEFAULT_MODEL_KEY]
247
+ value.to_s.strip.empty? ? nil : value.to_s.strip
248
+ end
249
+
250
+ # Whether bare model names go somewhere today: a default.model or a
251
+ # server: section. Then a new hosts: block keeps that route as `default`.
252
+ def routed?
253
+ configured_default_model || data.key?("server") || data.keys.any? { |key| key.to_s.start_with?("SAMAGOTCHI_SERVER_") }
254
+ end
255
+
256
+ # The `default` hosts entry chi derives from server.* when the file has
257
+ # no hosts:, written out so a new hosts: block doesn't reroute bare models.
258
+ def server_entry
259
+ file = data.is_a?(Hash) ? data : {}
260
+ host = Config.resolve("server.host", file_data: file, env: {}, cli_overrides: {}).to_s.strip
261
+ port = Config.resolve("server.port", file_data: file, env: {}, cli_overrides: {}).to_i
262
+ transport = Config.resolve("server.transport", file_data: file, env: {}, cli_overrides: {}).to_s.strip.downcase
263
+ entry = { "host" => host.empty? ? "localhost" : host, "port" => port.positive? ? port : 8080 }
264
+ entry["transport"] = transport if SERVER_TRANSPORTS.include?(transport) && transport != "llama_cpp"
265
+ entry
266
+ end
267
+
268
+ def same_server?(existing, fields)
269
+ a = location(existing)
270
+ b = location(fields)
271
+ a && b && a == b
272
+ end
273
+
274
+ # [scheme-less host, port, path] of an entry.
275
+ def location(fields)
276
+ url = fields["url"].to_s.strip
277
+ unless url.empty?
278
+ uri = URI.parse(url)
279
+ return [uri.host.to_s.downcase, uri.port, uri.path.to_s.chomp("/")]
280
+ end
281
+ host = fields["host"].to_s.strip.downcase
282
+ return nil if host.empty?
283
+
284
+ port = fields["port"].to_s.strip.empty? ? 8080 : fields["port"].to_i
285
+ [host, port, fields["api"].to_s == "openai" ? "/v1" : ""]
286
+ rescue URI::InvalidURIError
287
+ nil
288
+ end
289
+
290
+ def expected_data(name, fields, default_entry, default_model)
291
+ expected = Marshal.load(Marshal.dump(data))
292
+ hosts = expected["hosts"].is_a?(Hash) ? expected["hosts"] : {}
293
+ hosts["default"] = default_entry if default_entry
294
+ hosts[name] = fields
295
+ expected["hosts"] = hosts
296
+ expected["default"] = { "model" => default_model } if default_model
297
+ expected
298
+ end
299
+
300
+ def written_ok?(real, expected, name)
301
+ parse(File.read(real)) == expected &&
302
+ ConfigFile.hosts_config(env: @env, path: real).key?(name.downcase)
303
+ rescue StandardError
304
+ false
305
+ end
306
+
307
+ def parse(text) = YAML.safe_load(text, permitted_classes: [], aliases: false)
308
+
309
+ # Anchors and aliases: chi's reader refuses aliases, and an insert
310
+ # can't know what an anchor shares.
311
+ def anchors?(text)
312
+ stack = [Psych.parse_stream(text)]
313
+ until stack.empty?
314
+ node = stack.pop
315
+ return true if node.is_a?(Psych::Nodes::Alias) || (node.respond_to?(:anchor) && node.anchor)
316
+
317
+ stack.concat(Array(node.children)) if node.respond_to?(:children)
318
+ end
319
+ false
320
+ rescue Psych::Exception
321
+ true
322
+ end
323
+
324
+ def backup_path(real)
325
+ stamp = @now.call.utc.strftime("%Y%m%dT%H%M%SZ")
326
+ candidate = "#{real}.bak-#{stamp}"
327
+ n = 1
328
+ candidate = "#{real}.bak-#{stamp}-#{n += 1}" while File.exist?(candidate)
329
+ candidate
330
+ end
331
+
332
+ def atomic_write(target, text)
333
+ tmp = "#{target}.tmp-#{Process.pid}"
334
+ File.binwrite(tmp, text)
335
+ File.chmod(File.stat(target).mode & 0o7777, tmp) if File.exist?(target)
336
+ File.rename(tmp, target)
337
+ ensure
338
+ FileUtils.rm_f(tmp) if tmp && File.exist?(tmp)
339
+ end
340
+ end
341
+ end
342
+ end
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "json"
6
+ require "uri"
7
+ require "ipaddr"
8
+ require_relative "../version"
9
+ require_relative "../llm/openai_chat"
10
+
11
+ module Samagotchi
12
+ module Bootstrap
13
+ # What `chi bootstrap TARGET` finds at a model server: llama.cpp's native
14
+ # API (/props), an OpenAI-compatible one (/v1/models), one that wants an
15
+ # API key, or nothing. One attempt per request, no retries: a refused
16
+ # port answers at once instead of after chi's usual backoff.
17
+ class Probe
18
+ # The ports `chi bootstrap` with no target tries on localhost.
19
+ LOCAL_PORTS = { 8080 => "llama.cpp", 11434 => "Ollama", 1234 => "LM Studio", 8000 => "vLLM" }.freeze
20
+ DEFAULT_PORT = 8080
21
+ # Connect and read seconds for /props and /v1/models: a remote https
22
+ # handshake needs more than Client#server_props' 1 s / 2 s.
23
+ TIMEOUT = 5
24
+ # Seconds a local-port scan waits per port.
25
+ SCAN_TIMEOUT = 1
26
+ TEST_TIMEOUT = 60
27
+ TEST_PROMPT = "Reply with the word ok."
28
+
29
+ # One place a server may be: root is scheme://host:port (llama.cpp's
30
+ # /props), base the OpenAI API base (<root>/v1, or a URL's own path).
31
+ # url is true when the user gave a URL with a path, which the config
32
+ # then keeps as written.
33
+ Candidate = Data.define(:root, :base, :url) do
34
+ def uri = URI(root)
35
+ def host = uri.host
36
+ def port = uri.port
37
+ def scheme = uri.scheme
38
+ def label = "#{host}:#{port}"
39
+ end
40
+
41
+ # kind: :native, :openai, :needs_key, :unreachable or :unknown.
42
+ # props: the /props JSON (native); models: [LLM::ModelInfo];
43
+ # status: the HTTP status that decided (:unknown, :needs_key);
44
+ # reason: why it's unreachable.
45
+ Result = Data.define(:kind, :candidate, :props, :models, :status, :reason) do
46
+ def self.of(kind, candidate, props: nil, models: [], status: nil, reason: nil)
47
+ new(kind: kind, candidate: candidate, props: props, models: models, status: status, reason: reason)
48
+ end
49
+
50
+ def native? = kind == :native
51
+ def reached? = %i[native openai].include?(kind)
52
+ end
53
+
54
+ # The target the user typed, as the candidates to try in order.
55
+ # host, host:port, an IP → http://host:port (port 8080 when none)
56
+ # a domain without a scheme → https first, then http
57
+ # a URL → as given; a path is the OpenAI base
58
+ # Raises ArgumentError for something that isn't a host or URL.
59
+ def self.candidates(target)
60
+ text = target.to_s.strip
61
+ raise ArgumentError, "no target" if text.empty?
62
+
63
+ if text.match?(%r{\A[a-z][a-z0-9+.-]*://}i)
64
+ uri = parse_uri(text)
65
+ raise ArgumentError, "not an http(s) URL: #{text}" unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
66
+
67
+ root = root_url(uri.scheme, uri.host, uri.port)
68
+ path = uri.path.to_s.chomp("/")
69
+ return [Candidate.new(root: root, base: path.empty? ? "#{root}/v1" : "#{root}#{path}", url: !path.empty?)]
70
+ end
71
+
72
+ uri = parse_uri("http://#{text}")
73
+ raise ArgumentError, "not a host or URL: #{text}" unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
74
+
75
+ path = uri.path.to_s.chomp("/")
76
+ port = text.match?(%r{:\d+(/|\z)}) ? uri.port : nil
77
+ schemes = domain?(uri.host) ? { "https" => 443, "http" => 80 } : { "http" => DEFAULT_PORT }
78
+ schemes.map do |scheme, default_port|
79
+ root = root_url(scheme, uri.host, port || default_port)
80
+ Candidate.new(root: root, base: path.empty? ? "#{root}/v1" : "#{root}#{path}", url: !path.empty?)
81
+ end
82
+ end
83
+
84
+ # scheme://host:port, without the port when it is the scheme's own
85
+ # (https://openrouter.ai, not https://openrouter.ai:443): it is written
86
+ # into config.yml as typed.
87
+ def self.root_url(scheme, host, port)
88
+ default = { "http" => 80, "https" => 443 }[scheme]
89
+ port == default ? "#{scheme}://#{host}" : "#{scheme}://#{host}:#{port}"
90
+ end
91
+
92
+ # A name with a dot that isn't an IP or localhost: reached over https
93
+ # on 443 first, like a browser would.
94
+ def self.domain?(host)
95
+ return false if host.nil? || host.casecmp?("localhost") || ip?(host)
96
+
97
+ host.include?(".")
98
+ end
99
+
100
+ def self.ip?(host)
101
+ IPAddr.new(host.to_s.delete_prefix("[").delete_suffix("]"))
102
+ true
103
+ rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
104
+ false
105
+ end
106
+
107
+ def self.parse_uri(text)
108
+ URI.parse(text)
109
+ rescue URI::InvalidURIError
110
+ nil
111
+ end
112
+ private_class_method :parse_uri
113
+
114
+ # @param env [Hash] where the API key variable is read
115
+ # @param timeout [Numeric] connect and read seconds per request
116
+ def initialize(env: ENV, timeout: TIMEOUT)
117
+ @env = env
118
+ @timeout = timeout
119
+ end
120
+
121
+ # Probe the target's candidates in order; the next one is tried only
122
+ # when this one refused the connection or failed its TLS handshake
123
+ # (https → http), not on a timeout.
124
+ # @param key_env [String, nil] the API key's environment variable
125
+ # @return [Result]
126
+ def classify_target(candidates, key_env: nil)
127
+ result = nil
128
+ candidates.each do |candidate|
129
+ result = classify(candidate, key_env: key_env)
130
+ break unless result.kind == :unreachable && result.reason.to_s.match?(/refused|SSL|TLS|certificate/i)
131
+ end
132
+ result
133
+ end
134
+
135
+ # @return [Result]
136
+ def classify(candidate, key_env: nil)
137
+ props = begin
138
+ status, body = get(URI("#{candidate.root}/props"), key_env)
139
+ status == 200 ? parse_object(body) : nil
140
+ rescue *network_errors => e
141
+ return Result.of(:unreachable, candidate, reason: reason(e))
142
+ end
143
+ return Result.of(:native, candidate, props: props, models: native_models(candidate, props, key_env)) if native_props?(props)
144
+
145
+ begin
146
+ Result.of(:openai, candidate, models: list_models(candidate.base, key_env))
147
+ rescue LLM::ProviderError => e
148
+ kind = [401, 403].include?(e.status) ? :needs_key : :unknown
149
+ Result.of(kind, candidate, status: e.status, reason: e.status ? nil : e.message)
150
+ rescue *network_errors => e
151
+ Result.of(:unreachable, candidate, reason: reason(e))
152
+ end
153
+ end
154
+
155
+ # The ports in LOCAL_PORTS that answer as a model server, probed in
156
+ # parallel. @return [Array<Result>] the reached ones, in LOCAL_PORTS order
157
+ def scan_local(ports: LOCAL_PORTS.keys)
158
+ scanner = self.class.new(env: @env, timeout: SCAN_TIMEOUT)
159
+ ports.map do |port|
160
+ Thread.new { scanner.classify(Candidate.new(root: "http://localhost:#{port}", base: "http://localhost:#{port}/v1", url: false)) }
161
+ end.map(&:value).select(&:reached?)
162
+ end
163
+
164
+ # /props for one model (a llama.cpp router loads it on demand), or nil
165
+ # when the server doesn't answer it.
166
+ def model_props(candidate, model, key_env: nil)
167
+ status, body = get(URI("#{candidate.root}/props?#{URI.encode_www_form(model: model)}"), key_env)
168
+ status == 200 ? parse_object(body) : nil
169
+ rescue StandardError
170
+ nil
171
+ end
172
+
173
+ # One short chat request: does the server generate for +model+? Any 200
174
+ # with a choice counts (a thinking model may spend its 16 tokens
175
+ # reasoning). @return [Float] seconds taken
176
+ # @raise [RuntimeError] with what went wrong
177
+ def test_turn(candidate, model, key_env: nil, read_timeout: TEST_TIMEOUT)
178
+ uri = URI("#{candidate.base}/chat/completions")
179
+ request = Net::HTTP::Post.new(uri)
180
+ request["Content-Type"] = "application/json"
181
+ request.body = JSON.generate(model: model, messages: [{ role: "user", content: TEST_PROMPT }], max_tokens: 16,
182
+ stream: false)
183
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
184
+ response = send_request(uri, request, key_env, read_timeout: read_timeout)
185
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
186
+ body = parse_object(response.body)
187
+ unless response.code.to_i == 200 && body && body["choices"].is_a?(Array) && !body["choices"].empty?
188
+ raise "HTTP #{response.code}: #{error_text(body, response.body)}"
189
+ end
190
+
191
+ elapsed
192
+ rescue *network_errors => e
193
+ raise "#{reason(e)} (#{candidate.label})"
194
+ end
195
+
196
+ private
197
+
198
+ def native_props?(props)
199
+ props.is_a?(Hash) && (props.key?("chat_template") || props.key?("build_info"))
200
+ end
201
+
202
+ # llama.cpp serves /v1/models too; a server that doesn't gets its
203
+ # model_alias.
204
+ def native_models(candidate, props, key_env)
205
+ models = begin
206
+ list_models(candidate.base, key_env)
207
+ rescue StandardError
208
+ []
209
+ end
210
+ return models unless models.empty?
211
+
212
+ name = props["model_alias"].to_s.strip
213
+ name.empty? ? [] : [LLM::ModelInfo.new(id: name, context_window: nil, supports_tools: nil, raw: {})]
214
+ end
215
+
216
+ def list_models(base, key_env)
217
+ LLM::OpenAIChat.new(base_url: base, host_name: URI(base).host, api_key_env: key_env, retries: false,
218
+ timeout: @timeout, env: @env).list_models
219
+ end
220
+
221
+ def get(uri, key_env)
222
+ response = send_request(uri, Net::HTTP::Get.new(uri), key_env, read_timeout: @timeout)
223
+ [response.code.to_i, response.body]
224
+ end
225
+
226
+ def send_request(uri, request, key_env, read_timeout:)
227
+ request["User-Agent"] = Samagotchi::USER_AGENT
228
+ key = key_env && @env[key_env].to_s
229
+ request["Authorization"] = "Bearer #{key}" if key && !key.strip.empty?
230
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: @timeout,
231
+ read_timeout: read_timeout) { |http| http.request(request) }
232
+ end
233
+
234
+ def network_errors
235
+ [*LLM::HTTP::NETWORK_ERRORS, OpenSSL::SSL::SSLError, Errno::EADDRNOTAVAIL, Errno::ENETDOWN, Errno::EPIPE]
236
+ end
237
+
238
+ def reason(error)
239
+ case error
240
+ when Errno::ECONNREFUSED then "connection refused"
241
+ when Net::OpenTimeout then "no answer in #{@timeout} s"
242
+ when Timeout::Error, IO::TimeoutError then "timed out"
243
+ when SocketError then "unknown host"
244
+ when OpenSSL::SSL::SSLError then "TLS: #{error.message}"
245
+ else error.message
246
+ end
247
+ end
248
+
249
+ def parse_object(text)
250
+ parsed = JSON.parse(text.to_s)
251
+ parsed.is_a?(Hash) ? parsed : nil
252
+ rescue JSON::ParserError
253
+ nil
254
+ end
255
+
256
+ def error_text(body, raw)
257
+ message = body.is_a?(Hash) && (body.dig("error", "message") || body["error"] || body["message"])
258
+ (message || raw.to_s)[0, 200].to_s.strip
259
+ end
260
+ end
261
+ end
262
+ end