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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +116 -1
- data/README.md +40 -4
- data/bin/chi +86 -19
- data/docs/cli.md +108 -5
- data/docs/configuration.md +229 -45
- data/docs/desktop.md +6 -0
- data/docs/hooks.md +126 -5
- data/docs/plugins.md +68 -2
- data/docs/releasing.md +18 -8
- data/docs/sessions.md +30 -4
- data/lib/samagotchi/answer_display.rb +95 -0
- data/lib/samagotchi/archive_store.rb +90 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
- data/lib/samagotchi/bootstrap/probe.rb +262 -0
- data/lib/samagotchi/bootstrap_command.rb +347 -0
- data/lib/samagotchi/bridge/pending_card.rb +89 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +14 -3
- data/lib/samagotchi/bridge.rb +9 -0
- data/lib/samagotchi/bridge_client.rb +6 -2
- data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
- data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +358 -0
- data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
- data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +8 -2
- data/lib/samagotchi/client.rb +72 -13
- data/lib/samagotchi/config.rb +196 -36
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +13 -7
- data/lib/samagotchi/desktop/macos/Panel.swift +71 -19
- data/lib/samagotchi/empty_answer_retry.rb +43 -0
- data/lib/samagotchi/engine.rb +233 -36
- data/lib/samagotchi/guardrails/approval.rb +9 -0
- data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +24 -5
- data/lib/samagotchi/host_registry.rb +4 -3
- data/lib/samagotchi/idle_recap.rb +5 -1
- data/lib/samagotchi/kernel_loop.rb +47 -15
- data/lib/samagotchi/llm/chat_loop.rb +59 -20
- data/lib/samagotchi/llm/errors.rb +21 -3
- data/lib/samagotchi/llm/http.rb +42 -13
- data/lib/samagotchi/llm/openai_chat.rb +12 -4
- data/lib/samagotchi/log_subscriber.rb +18 -3
- data/lib/samagotchi/model_profile.rb +1 -1
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/sessions.rb +3 -1
- data/lib/samagotchi/reply_wait.rb +126 -0
- data/lib/samagotchi/sampling_settings.rb +58 -0
- data/lib/samagotchi/self_report.rb +1 -0
- data/lib/samagotchi/send_command.rb +153 -7
- data/lib/samagotchi/session.rb +52 -11
- data/lib/samagotchi/session_archive_command.rb +107 -0
- data/lib/samagotchi/session_commands.rb +11 -2
- data/lib/samagotchi/session_manager.rb +114 -9
- data/lib/samagotchi/session_metrics.rb +222 -106
- data/lib/samagotchi/steer.rb +72 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
- data/lib/samagotchi/terminal_ui/event_renderer.rb +13 -8
- data/lib/samagotchi/terminal_ui/formatting.rb +31 -8
- data/lib/samagotchi/terminal_ui/input_support.rb +3 -0
- data/lib/samagotchi/terminal_ui.rb +77 -4
- data/lib/samagotchi/tool_activity.rb +3 -1
- data/lib/samagotchi/tools/builtins.rb +15 -4
- data/lib/samagotchi/tools/delegate_wait.rb +26 -69
- data/lib/samagotchi/tools/execute.rb +52 -14
- data/lib/samagotchi/tools/task_runtime.rb +19 -0
- data/lib/samagotchi/tools/task_wait.rb +27 -3
- data/lib/samagotchi/turn_note.rb +60 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +2 -6
- data/lib/samagotchi/web/app.rb +88 -4
- data/lib/samagotchi/web/public/activity.js +10 -1
- data/lib/samagotchi/web/public/annotate_presets.js +26 -0
- data/lib/samagotchi/web/public/annotations.js +13 -0
- data/lib/samagotchi/web/public/app.js +437 -88
- data/lib/samagotchi/web/public/card.js +5 -3
- data/lib/samagotchi/web/public/chat_view.js +10 -1
- data/lib/samagotchi/web/public/copy.js +20 -4
- data/lib/samagotchi/web/public/ctx.js +15 -0
- data/lib/samagotchi/web/public/data.js +21 -6
- data/lib/samagotchi/web/public/format.js +9 -0
- data/lib/samagotchi/web/public/index.html +38 -2
- data/lib/samagotchi/web/public/notify.js +175 -0
- data/lib/samagotchi/web/public/question_card.js +2 -1
- data/lib/samagotchi/web/public/sessions_list.js +7 -0
- data/lib/samagotchi/web/public/timing.js +39 -14
- data/lib/samagotchi/web/public/turn_events.js +46 -0
- data/lib/samagotchi/web/public/turn_view.js +47 -7
- data/lib/samagotchi/web/server.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +24 -1
- data/lib/samagotchi/worker.rb +11 -0
- 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
|