samagotchi 0.79.0 → 0.80.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 +30 -1
- data/README.md +7 -5
- data/docs/cli.md +24 -7
- data/docs/configuration.md +7 -4
- data/docs/internals/anthropic.md +3 -1
- data/docs/internals/bundles.md +4 -0
- data/docs/internals/engine.md +13 -3
- data/docs/plugins.md +144 -3
- data/docs/releasing.md +2 -2
- data/docs/sessions.md +1 -1
- data/docs/testing.md +32 -2
- data/lib/samagotchi/bootstrap/config_writer.rb +13 -3
- data/lib/samagotchi/bootstrap/probe.rb +153 -31
- data/lib/samagotchi/bootstrap_command.rb +92 -15
- data/lib/samagotchi/bridge.rb +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/continue_offer.rb +5 -5
- data/lib/samagotchi/engine.rb +40 -37
- data/lib/samagotchi/guardrails/approval.rb +6 -4
- data/lib/samagotchi/guardrails/gate.rb +7 -3
- data/lib/samagotchi/hooks/bundle_loader.rb +31 -16
- data/lib/samagotchi/hooks/registry.rb +44 -3
- data/lib/samagotchi/host_registry.rb +12 -4
- data/lib/samagotchi/llm/adapter_common.rb +4 -2
- data/lib/samagotchi/llm/anthropic_binding_recovery.rb +3 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +2 -1
- data/lib/samagotchi/llm/http.rb +12 -7
- data/lib/samagotchi/plugin/card.rb +44 -0
- data/lib/samagotchi/plugin/context.rb +53 -8
- data/lib/samagotchi/plugin/http.rb +154 -0
- data/lib/samagotchi/plugin/http_transport.rb +77 -0
- data/lib/samagotchi/plugin/loader.rb +29 -10
- data/lib/samagotchi/question_answer.rb +29 -0
- data/lib/samagotchi/question_desk.rb +20 -19
- data/lib/samagotchi/relay_verifier.rb +1 -1
- data/lib/samagotchi/testing/boundary.rb +156 -0
- data/lib/samagotchi/testing/bundle.rb +349 -0
- data/lib/samagotchi/testing/fake_executable.rb +42 -0
- data/lib/samagotchi/testing/fake_http.rb +137 -0
- data/lib/samagotchi/testing/helpers.rb +56 -0
- data/lib/samagotchi/testing/host.rb +76 -0
- data/lib/samagotchi/testing/minitest.rb +31 -0
- data/lib/samagotchi/testing/recorder.rb +138 -0
- data/lib/samagotchi/testing/recording_api.rb +27 -0
- data/lib/samagotchi/testing/rspec.rb +31 -0
- data/lib/samagotchi/testing/sandbox.rb +163 -0
- data/lib/samagotchi/testing/sessions.rb +77 -0
- data/lib/samagotchi/testing/verdict.rb +28 -0
- data/lib/samagotchi/testing/waiting.rb +36 -0
- data/lib/samagotchi/testing.rb +26 -0
- data/lib/samagotchi/tools/delegate_relay.rb +7 -6
- data/lib/samagotchi/tools/tool_index.rb +3 -0
- data/lib/samagotchi/turn_state.rb +28 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +47 -23
- data/lib/samagotchi/web/public/images.js +33 -0
- data/lib/samagotchi/web/public/index.html +71 -38
- data/lib/samagotchi/web/public/level_chip.js +11 -11
- data/lib/samagotchi/web/public/lists_view.js +19 -14
- data/lib/samagotchi/web/public/llm_context_chip.js +15 -15
- data/lib/samagotchi/web/public/model_picker.js +1 -1
- data/lib/samagotchi/web/public/scope.js +3 -3
- data/lib/samagotchi/web/public/strip.js +35 -9
- data/lib/samagotchi/web/public/strip_grip.js +76 -0
- data/lib/samagotchi/web/public/thinking_chip.js +5 -5
- metadata +21 -1
data/docs/testing.md
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# Testing
|
|
2
2
|
|
|
3
3
|
```sh
|
|
4
|
-
bundle exec parallel_rspec -n 8 # Ruby specs, in parallel
|
|
4
|
+
bundle exec parallel_rspec -n 8 spec lib/samagotchi/bundles/*/spec # Ruby specs, both roots, in parallel
|
|
5
|
+
bundle exec rspec spec lib/samagotchi/bundles/*/spec # the same, plain
|
|
6
|
+
bundle exec rake bundles:specs # the shipped bundles' own specs, on the bundle test kit alone
|
|
5
7
|
npm test # web frontend specs
|
|
6
8
|
npm run e2e # web UI happy paths in Chromium, fake model (once: npx playwright install chromium)
|
|
7
9
|
npx playwright test spec/e2e/stage.spec.js # one e2e file (the root playwright.config.js is the suite's)
|
|
@@ -53,6 +55,34 @@ Every example also runs under a 120 s limit (`spec_helper`'s `around`; `integrat
|
|
|
53
55
|
`recap` examples are exempt), so a hang fails its example instead of the run. `SPEC_EXAMPLE_TIMEOUT=N` changes the
|
|
54
56
|
limit, `0` turns it off (a debugger session).
|
|
55
57
|
|
|
58
|
+
## Bundle specs: the test kit
|
|
59
|
+
|
|
60
|
+
A shipped bundle's plugin and hook files are tested through the bundle test kit (`samagotchi/testing`,
|
|
61
|
+
[plugins.md](plugins.md#testing-a-bundle-experimental)): `shipped_bundle("check-in", settings: …)` loads it through
|
|
62
|
+
chi's own loaders, registries, `ctx` and guardrail Gate, with the session, the hook helpers and `ctx.sessions`
|
|
63
|
+
recorded. No fake `chi` or `ctx`: what the kit can't express is a kit change (with a kit spec), not a local fake.
|
|
64
|
+
|
|
65
|
+
These specs live **next to the bundle**, in `lib/samagotchi/bundles/<name>/spec/` (their fixtures in its
|
|
66
|
+
`spec/fixtures/`), and require only `samagotchi/testing/rspec` (tagged `:bundle` by their path): no `spec_helper`, no
|
|
67
|
+
`spec/support`, as an external bundle's specs would run. `rake bundles:specs` runs them that way
|
|
68
|
+
(`rspec --options /dev/null -r samagotchi/testing/rspec`), in CI's bundles job; the full suite runs them too, under
|
|
69
|
+
`spec_helper`, given both roots (plain `rspec` runs `spec/` only: `.rspec` holds no `--pattern`, which would make
|
|
70
|
+
`rspec FILE` run everything). They are never shipped: the gem and an install leave `spec/` out, and the release
|
|
71
|
+
tools' version check ignores a bundle's `spec/` (a spec-only change needs no bump). A spec that runs real turns
|
|
72
|
+
(install → Engine → `run_turn`) stays in `spec/samagotchi/plugin/<name>_bundle_spec.rb`.
|
|
73
|
+
|
|
74
|
+
The kit's own specs, in `spec/samagotchi/testing/`, keep it and the bundles honest:
|
|
75
|
+
|
|
76
|
+
- `spec/samagotchi/testing/contract_spec.rb`: the kit sets every `Plugin::Host` and `Hooks::Runtime` member,
|
|
77
|
+
`Testing::Sessions` has `Plugin::Sessions`' methods and parameters, and each `ctx` helper lands in its recorder. A
|
|
78
|
+
member added to the Host fails it until the kit has it.
|
|
79
|
+
- `spec/samagotchi/testing/shipped_bundles_spec.rb`: each shipped bundle with a plugin or hooks registers the same
|
|
80
|
+
commands, tools, hooks, inits, toolkits and sources loaded by the kit as installed and loaded by chi.
|
|
81
|
+
- `spec/samagotchi/testing/boundary_spec.rb`, the boundary lint: each bundle's Ruby (plugin, hooks, scripts, specs)
|
|
82
|
+
reaches chi only through `Samagotchi::Testing::Boundary::ALLOWED` (the "Stable for bundles" list) and that
|
|
83
|
+
bundle's `Boundary::EXCEPTIONS` entry, each with why. A new internal reference fails it, and so does an exception
|
|
84
|
+
no longer used. Fix the reference (the kit or a stable API), or add the exception with its why.
|
|
85
|
+
|
|
56
86
|
## While you work: affected specs
|
|
57
87
|
|
|
58
88
|
```sh
|
|
@@ -73,7 +103,7 @@ The Ruby specs go to stdout; the node tests to run (`npm test` targets), "web ch
|
|
|
73
103
|
hint for a hub class (named in too many specs to list) go to stderr. Nothing printed means nothing matched.
|
|
74
104
|
|
|
75
105
|
It finds a change's own specs, not the regressions it causes elsewhere. So before committing, run the full suite
|
|
76
|
-
(`bundle exec parallel_rspec -n 8`), unless your task says the coordinator runs it; the coordinator runs it at merge
|
|
106
|
+
(`bundle exec parallel_rspec -n 8 spec lib/samagotchi/bundles/*/spec`), unless your task says the coordinator runs it; the coordinator runs it at merge
|
|
77
107
|
either way.
|
|
78
108
|
|
|
79
109
|
## Worktrees
|
|
@@ -6,6 +6,7 @@ require "fileutils"
|
|
|
6
6
|
require "ipaddr"
|
|
7
7
|
require_relative "../atomic_file"
|
|
8
8
|
require_relative "../config"
|
|
9
|
+
require_relative "../host_config"
|
|
9
10
|
require_relative "../yaml_lines"
|
|
10
11
|
require_relative "../redacted_inspect"
|
|
11
12
|
|
|
@@ -91,6 +92,8 @@ module Samagotchi
|
|
|
91
92
|
end
|
|
92
93
|
|
|
93
94
|
# The name of an existing entry that points at the same server, or nil.
|
|
95
|
+
# An api: anthropic entry and any other on one server are different
|
|
96
|
+
# hosts (Fireworks serves both protocols).
|
|
94
97
|
def duplicate_of(fields)
|
|
95
98
|
current_hosts.find { |_, existing| same_server?(existing, fields) }&.first
|
|
96
99
|
end
|
|
@@ -267,15 +270,22 @@ module Samagotchi
|
|
|
267
270
|
def same_server?(existing, fields)
|
|
268
271
|
a = location(existing)
|
|
269
272
|
b = location(fields)
|
|
270
|
-
a && b && a == b
|
|
273
|
+
a && b && a == b && anthropic?(existing) == anthropic?(fields)
|
|
271
274
|
end
|
|
272
275
|
|
|
273
|
-
|
|
276
|
+
def anthropic?(fields) = fields["api"].to_s.strip.downcase == "anthropic"
|
|
277
|
+
|
|
278
|
+
# [scheme-less host, port, path] of an entry. An api: anthropic url's
|
|
279
|
+
# path loses a trailing /v1 (the adapter adds it), and one with
|
|
280
|
+
# neither url: nor host: is Anthropic's own API (HostConfig's rule).
|
|
274
281
|
def location(fields)
|
|
275
282
|
url = fields["url"].to_s.strip
|
|
283
|
+
url = ANTHROPIC_URL if url.empty? && anthropic?(fields) && fields["host"].to_s.strip.empty? && fields["port"].to_s.strip.empty?
|
|
276
284
|
unless url.empty?
|
|
277
285
|
uri = URI.parse(url)
|
|
278
|
-
|
|
286
|
+
path = uri.path.to_s.chomp("/")
|
|
287
|
+
path = path.delete_suffix("/v1") if anthropic?(fields)
|
|
288
|
+
return [uri.host.to_s.downcase, uri.port, path]
|
|
279
289
|
end
|
|
280
290
|
host = fields["host"].to_s.strip.downcase
|
|
281
291
|
return nil if host.empty?
|
|
@@ -7,6 +7,8 @@ require "uri"
|
|
|
7
7
|
require "ipaddr"
|
|
8
8
|
require_relative "../version"
|
|
9
9
|
require_relative "../llm/openai_chat"
|
|
10
|
+
require_relative "../llm/anthropic_messages"
|
|
11
|
+
require_relative "../llm/dialect"
|
|
10
12
|
require_relative "../llm/http"
|
|
11
13
|
require_relative "../llm/api_key"
|
|
12
14
|
require_relative "../host_keys"
|
|
@@ -15,9 +17,12 @@ require_relative "../redacted_inspect"
|
|
|
15
17
|
module Samagotchi
|
|
16
18
|
module Bootstrap
|
|
17
19
|
# What `chi bootstrap TARGET` finds at a model server: llama.cpp's native
|
|
18
|
-
# API (/props), an OpenAI-compatible one (/v1/models),
|
|
19
|
-
# API
|
|
20
|
-
#
|
|
20
|
+
# API (/props), an OpenAI-compatible one (/v1/models), Anthropic's
|
|
21
|
+
# Messages API (its /v1/models, asked only after the OpenAI list was
|
|
22
|
+
# refused with 400/401/403: the Anthropic list reads an OpenAI-shaped
|
|
23
|
+
# one too), one that wants an API key, or nothing. One attempt per
|
|
24
|
+
# request, no retries: a refused port answers at once instead of after
|
|
25
|
+
# chi's usual backoff.
|
|
21
26
|
class Probe
|
|
22
27
|
include RedactedInspect
|
|
23
28
|
|
|
@@ -31,6 +36,14 @@ module Samagotchi
|
|
|
31
36
|
SCAN_TIMEOUT = 1
|
|
32
37
|
TEST_TIMEOUT = 60
|
|
33
38
|
TEST_PROMPT = "Reply with the word ok."
|
|
39
|
+
# The OpenAI list's statuses after which the Anthropic list is asked:
|
|
40
|
+
# Anthropic answers a Bearer request 400 (no anthropic-version) or
|
|
41
|
+
# 401/403. Not 404/405: a typed URL path sends the two lists to
|
|
42
|
+
# different paths, so a 404 there says nothing about Anthropic.
|
|
43
|
+
ANTHROPIC_AFTER = [400, 401, 403].freeze
|
|
44
|
+
AUTH_STATUSES = [401, 403].freeze
|
|
45
|
+
FIREWORKS_HOST = "fireworks.ai"
|
|
46
|
+
FIREWORKS_MODEL_PREFIX = "accounts/fireworks/"
|
|
34
47
|
|
|
35
48
|
# One place a server may be: root is scheme://host:port (llama.cpp's
|
|
36
49
|
# /props), base the OpenAI API base (<root>/v1, or a URL's own path).
|
|
@@ -44,17 +57,34 @@ module Samagotchi
|
|
|
44
57
|
def label = "#{host}:#{port}"
|
|
45
58
|
end
|
|
46
59
|
|
|
47
|
-
# kind: :native, :openai, :needs_key, :unreachable or
|
|
48
|
-
# props: the /props JSON (native); models: [LLM::ModelInfo];
|
|
60
|
+
# kind: :native, :openai, :anthropic, :needs_key, :unreachable or
|
|
61
|
+
# :unknown. props: the /props JSON (native); models: [LLM::ModelInfo];
|
|
49
62
|
# status: the HTTP status that decided (:unknown, :needs_key);
|
|
50
|
-
# reason: why it's unreachable
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
63
|
+
# reason: why it's unreachable; dialect: an :anthropic result's
|
|
64
|
+
# LLM::Dialect name when not the standard one ("fireworks"), else nil;
|
|
65
|
+
# auth: :bearer when an :anthropic list took the key only as Bearer
|
|
66
|
+
# (a Symbol, LLM::ApiKey::SCHEMES), else nil.
|
|
67
|
+
Result = Data.define(:kind, :candidate, :props, :models, :status, :reason, :dialect, :auth) do
|
|
68
|
+
def self.of(kind, candidate, props: nil, models: [], status: nil, reason: nil, dialect: nil, auth: nil)
|
|
69
|
+
new(kind: kind, candidate: candidate, props: props, models: models, status: status, reason: reason,
|
|
70
|
+
dialect: dialect, auth: auth)
|
|
54
71
|
end
|
|
55
72
|
|
|
56
73
|
def native? = kind == :native
|
|
57
|
-
def
|
|
74
|
+
def anthropic? = kind == :anthropic
|
|
75
|
+
def reached? = %i[native openai anthropic].include?(kind)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A failed test turn: the message, and the HTTP status and request path
|
|
79
|
+
# when the server answered (BootstrapCommand's --api anthropic hint).
|
|
80
|
+
class TestFailed < RuntimeError
|
|
81
|
+
attr_reader :status, :path
|
|
82
|
+
|
|
83
|
+
def initialize(message = nil, status: nil, path: nil)
|
|
84
|
+
@status = status
|
|
85
|
+
@path = path
|
|
86
|
+
super(message)
|
|
87
|
+
end
|
|
58
88
|
end
|
|
59
89
|
|
|
60
90
|
# The target the user typed, as the candidates to try in order.
|
|
@@ -110,6 +140,21 @@ module Samagotchi
|
|
|
110
140
|
false
|
|
111
141
|
end
|
|
112
142
|
|
|
143
|
+
# The Messages API root: the OpenAI base without its trailing /v1
|
|
144
|
+
# (HostEntry#api_base_url's rule), e.g. https://api.anthropic.com.
|
|
145
|
+
def self.api_root(candidate) = candidate.base.chomp("/").delete_suffix("/v1")
|
|
146
|
+
|
|
147
|
+
# Fireworks by its host (fireworks.ai or a subdomain), or by a model id
|
|
148
|
+
# in its accounts/fireworks/ namespace.
|
|
149
|
+
def self.fireworks?(candidate, models = [])
|
|
150
|
+
fireworks_host?(candidate.host) || models.any? { |m| m.id.to_s.start_with?(FIREWORKS_MODEL_PREFIX) }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def self.fireworks_host?(host)
|
|
154
|
+
name = host.to_s.downcase
|
|
155
|
+
name == FIREWORKS_HOST || name.end_with?(".#{FIREWORKS_HOST}")
|
|
156
|
+
end
|
|
157
|
+
|
|
113
158
|
def self.parse_uri(text)
|
|
114
159
|
URI.parse(text)
|
|
115
160
|
rescue URI::InvalidURIError
|
|
@@ -128,18 +173,27 @@ module Samagotchi
|
|
|
128
173
|
# when this one refused the connection or failed its TLS handshake
|
|
129
174
|
# (https → http), not on a timeout.
|
|
130
175
|
# @param key_env [String, nil] the API key's environment variable
|
|
176
|
+
# @param api [Symbol, nil] :openai or :anthropic to skip the detection
|
|
177
|
+
# (--api); nil detects
|
|
178
|
+
# @param dialect [String, nil] the Messages dialect (--dialect); nil
|
|
179
|
+
# detects Fireworks
|
|
131
180
|
# @return [Result]
|
|
132
|
-
def classify_target(candidates, key_env: nil)
|
|
181
|
+
def classify_target(candidates, key_env: nil, api: nil, dialect: nil)
|
|
133
182
|
result = nil
|
|
134
183
|
candidates.each do |candidate|
|
|
135
|
-
result = classify(candidate, key_env: key_env)
|
|
184
|
+
result = classify(candidate, key_env: key_env, api: api, dialect: dialect)
|
|
136
185
|
break unless result.kind == :unreachable && result.reason.to_s.match?(/refused|SSL|TLS|certificate/i)
|
|
137
186
|
end
|
|
138
187
|
result
|
|
139
188
|
end
|
|
140
189
|
|
|
190
|
+
# /props (llama.cpp), then the OpenAI list, then (only after the OpenAI
|
|
191
|
+
# list's 400/401/403) the Anthropic one. api: :anthropic asks only the
|
|
192
|
+
# Anthropic list; api: :openai never asks it.
|
|
141
193
|
# @return [Result]
|
|
142
|
-
def classify(candidate, key_env: nil)
|
|
194
|
+
def classify(candidate, key_env: nil, api: nil, dialect: nil)
|
|
195
|
+
return classify_anthropic(candidate, key_env, dialect, nil) if api&.to_sym == :anthropic
|
|
196
|
+
|
|
143
197
|
props = begin
|
|
144
198
|
status, body = get(URI("#{candidate.root}/props"), key_env)
|
|
145
199
|
status == 200 ? parse_object(body) : nil
|
|
@@ -151,8 +205,9 @@ module Samagotchi
|
|
|
151
205
|
begin
|
|
152
206
|
Result.of(:openai, candidate, models: list_models(candidate.base, key_env))
|
|
153
207
|
rescue LLM::ProviderError => e
|
|
154
|
-
|
|
155
|
-
|
|
208
|
+
return failure(candidate, e) if api || !ANTHROPIC_AFTER.include?(e.status)
|
|
209
|
+
|
|
210
|
+
classify_anthropic(candidate, key_env, dialect, e)
|
|
156
211
|
rescue *network_errors => e
|
|
157
212
|
Result.of(:unreachable, candidate, reason: reason(e))
|
|
158
213
|
end
|
|
@@ -178,25 +233,27 @@ module Samagotchi
|
|
|
178
233
|
|
|
179
234
|
# One short chat request: does the server generate for +model+? Any 200
|
|
180
235
|
# with a choice counts (a thinking model may spend its 16 tokens
|
|
181
|
-
# reasoning).
|
|
182
|
-
#
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
236
|
+
# reasoning). api: :anthropic posts {root}/v1/messages instead, and any
|
|
237
|
+
# 200 with a content array counts (stop_reason max_tokens too).
|
|
238
|
+
# @param auth [Symbol, nil] the key's header on :anthropic (nil: the
|
|
239
|
+
# dialect's)
|
|
240
|
+
# @return [Float] seconds taken
|
|
241
|
+
# @raise [TestFailed] with what went wrong
|
|
242
|
+
def test_turn(candidate, model, key_env: nil, api: :openai, dialect: nil, auth: nil, read_timeout: TEST_TIMEOUT)
|
|
243
|
+
anthropic = api&.to_sym == :anthropic
|
|
244
|
+
uri, request, scheme = anthropic ? messages_request(candidate, model, dialect, auth) : chat_request(candidate, model)
|
|
189
245
|
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
190
|
-
response = send_request(uri, request, key_env, read_timeout: read_timeout)
|
|
246
|
+
response = send_request(uri, request, key_env, read_timeout: read_timeout, scheme: scheme)
|
|
191
247
|
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
192
248
|
body = parse_object(response.body)
|
|
193
|
-
unless response.code.to_i == 200 && body
|
|
194
|
-
raise "HTTP #{response.code}: #{error_text(body, response.body)}"
|
|
249
|
+
unless response.code.to_i == 200 && answered?(body, anthropic ? "content" : "choices")
|
|
250
|
+
raise TestFailed.new("HTTP #{response.code}: #{error_text(body, response.body)}", status: response.code.to_i,
|
|
251
|
+
path: uri.path)
|
|
195
252
|
end
|
|
196
253
|
|
|
197
254
|
elapsed
|
|
198
255
|
rescue *network_errors => e
|
|
199
|
-
raise "#{reason(e)} (#{candidate.label})"
|
|
256
|
+
raise TestFailed, "#{reason(e)} (#{candidate.label})"
|
|
200
257
|
end
|
|
201
258
|
|
|
202
259
|
private
|
|
@@ -224,16 +281,81 @@ module Samagotchi
|
|
|
224
281
|
timeout: @timeout, env: @env).list_models
|
|
225
282
|
end
|
|
226
283
|
|
|
284
|
+
# The Anthropic list at the candidate's API root, the key in the
|
|
285
|
+
# dialect's header (Bearer on Fireworks, detected by its host before
|
|
286
|
+
# listing); a 401/403 with the standard dialect's x-api-key is asked
|
|
287
|
+
# once more with Bearer (a gateway that wants it). Ids in Fireworks'
|
|
288
|
+
# namespace make the result's dialect fireworks. +openai_error+ is the
|
|
289
|
+
# OpenAI list's refusal, the result again when the Anthropic list fails
|
|
290
|
+
# other than 401/403 (nil when --api anthropic skipped it).
|
|
291
|
+
def classify_anthropic(candidate, key_env, dialect, openai_error)
|
|
292
|
+
forced = dialect
|
|
293
|
+
dialect ||= LLM::Dialect::FIREWORKS.name if self.class.fireworks_host?(candidate.host)
|
|
294
|
+
auth = nil
|
|
295
|
+
models = begin
|
|
296
|
+
anthropic_models(candidate, key_env, dialect: dialect, auth: nil)
|
|
297
|
+
rescue LLM::ProviderError => e
|
|
298
|
+
raise unless AUTH_STATUSES.include?(e.status) && key_env && LLM::Dialect.for(dialect).auth != :bearer
|
|
299
|
+
|
|
300
|
+
auth = :bearer
|
|
301
|
+
anthropic_models(candidate, key_env, dialect: dialect, auth: auth)
|
|
302
|
+
end
|
|
303
|
+
dialect ||= LLM::Dialect::FIREWORKS.name if forced.nil? && self.class.fireworks?(candidate, models)
|
|
304
|
+
dialect = nil if dialect == LLM::Dialect::STANDARD.name
|
|
305
|
+
Result.of(:anthropic, candidate, models: models, dialect: dialect, auth: auth)
|
|
306
|
+
rescue LLM::ProviderError => e
|
|
307
|
+
return Result.of(:needs_key, candidate, status: e.status) if AUTH_STATUSES.include?(e.status)
|
|
308
|
+
|
|
309
|
+
failure(candidate, openai_error || e)
|
|
310
|
+
rescue *network_errors => e
|
|
311
|
+
Result.of(:unreachable, candidate, reason: reason(e))
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def anthropic_models(candidate, key_env, dialect:, auth:)
|
|
315
|
+
LLM::AnthropicMessages.new(base_url: self.class.api_root(candidate), host_name: candidate.host, api_key_env: key_env,
|
|
316
|
+
auth: auth, dialect: dialect, stream: false, retries: false, timeout: @timeout,
|
|
317
|
+
env: @env).list_models
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# A list's refusal as a Result: :needs_key on 401/403, else :unknown.
|
|
321
|
+
def failure(candidate, error)
|
|
322
|
+
kind = AUTH_STATUSES.include?(error.status) ? :needs_key : :unknown
|
|
323
|
+
Result.of(kind, candidate, status: error.status, reason: error.status ? nil : error.message)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def chat_request(candidate, model)
|
|
327
|
+
uri = URI("#{candidate.base}/chat/completions")
|
|
328
|
+
request = json_post(uri, model: model, messages: [{ role: "user", content: TEST_PROMPT }], max_tokens: 16, stream: false)
|
|
329
|
+
[uri, request, :bearer]
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# No stream and no thinking field: the model's own defaults.
|
|
333
|
+
def messages_request(candidate, model, dialect, auth)
|
|
334
|
+
uri = URI("#{self.class.api_root(candidate)}/v1/messages")
|
|
335
|
+
request = json_post(uri, model: model, max_tokens: 16, messages: [{ role: "user", content: TEST_PROMPT }])
|
|
336
|
+
request["anthropic-version"] = LLM::AnthropicMessages::API_VERSION
|
|
337
|
+
[uri, request, auth || LLM::Dialect.for(dialect).auth]
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def json_post(uri, body)
|
|
341
|
+
request = Net::HTTP::Post.new(uri)
|
|
342
|
+
request["Content-Type"] = "application/json"
|
|
343
|
+
request.body = JSON.generate(body)
|
|
344
|
+
request
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def answered?(body, key) = body.is_a?(Hash) && body[key].is_a?(Array) && !body[key].empty?
|
|
348
|
+
|
|
227
349
|
def get(uri, key_env)
|
|
228
350
|
response = send_request(uri, Net::HTTP::Get.new(uri), key_env, read_timeout: @timeout)
|
|
229
351
|
[response.code.to_i, response.body]
|
|
230
352
|
end
|
|
231
353
|
|
|
232
|
-
# One attempt through LLM::HTTP (User-Agent, the key's
|
|
233
|
-
# any status returned as is. A key neither its variable nor the
|
|
354
|
+
# One attempt through LLM::HTTP (User-Agent, the key in +scheme+'s
|
|
355
|
+
# header), any status returned as is. A key neither its variable nor the
|
|
234
356
|
# Keychain holds sends no header: the server's 401 then says one is needed.
|
|
235
|
-
def send_request(uri, request, key_env, read_timeout:)
|
|
236
|
-
key = key_env && HostKeys.fetch(key_env, env: @env) ? LLM::ApiKey.for(key_env, host: uri.host, env: @env) : nil
|
|
357
|
+
def send_request(uri, request, key_env, read_timeout:, scheme: :bearer)
|
|
358
|
+
key = key_env && HostKeys.fetch(key_env, env: @env) ? LLM::ApiKey.for(key_env, host: uri.host, env: @env, scheme: scheme) : nil
|
|
237
359
|
LLM::HTTP.new(label: uri.host, open_timeout: @timeout, read_timeout: read_timeout, api_key: key,
|
|
238
360
|
retry_policy: LLM::HTTP::RetryPolicy.none)
|
|
239
361
|
.fetch(uri, request, retries: false, check_status: false, log_fields: { purpose: "probe" })
|
|
@@ -14,9 +14,10 @@ require_relative "redacted_inspect"
|
|
|
14
14
|
|
|
15
15
|
module Samagotchi
|
|
16
16
|
# `chi bootstrap [TARGET]`: the first setup. It names a model server, works
|
|
17
|
-
# out what it is (llama.cpp's native API
|
|
18
|
-
# picks the model, sends one
|
|
19
|
-
# file, or a hosts: entry
|
|
17
|
+
# out what it is (llama.cpp's native API, an OpenAI-compatible one or
|
|
18
|
+
# Anthropic's Messages API, Bootstrap::Probe), picks the model, sends one
|
|
19
|
+
# test request and writes config.yml: a fresh file, or a hosts: entry
|
|
20
|
+
# added to an existing one. Then it installs the
|
|
20
21
|
# system bundle and the core profile, and on a terminal asks about dev.
|
|
21
22
|
# Prompts only on a terminal; a script gets the list and exit 2 instead.
|
|
22
23
|
class BootstrapCommand
|
|
@@ -24,11 +25,14 @@ module Samagotchi
|
|
|
24
25
|
include RedactedInspect
|
|
25
26
|
|
|
26
27
|
USAGE = <<~TEXT
|
|
27
|
-
Usage: chi bootstrap [TARGET] [--name NAME] [--model ID] [--key-env VAR] [--
|
|
28
|
+
Usage: chi bootstrap [TARGET] [--name NAME] [--model ID] [--key-env VAR] [--api API] [--dialect NAME]
|
|
29
|
+
[--no-test] [--dry-run]
|
|
28
30
|
Finds the model server at TARGET and writes config.yml for it.
|
|
29
31
|
chi bootstrap 192.168.1.29:8081 llama.cpp on the LAN
|
|
30
32
|
chi bootstrap localhost:11434 Ollama
|
|
31
33
|
chi bootstrap https://openrouter.ai/api/v1 --key-env OPENROUTER_API_KEY
|
|
34
|
+
chi bootstrap https://api.anthropic.com --key-env ANTHROPIC_API_KEY
|
|
35
|
+
chi bootstrap https://api.fireworks.ai/inference/v1 --key-env FIREWORKS_API_KEY --api anthropic
|
|
32
36
|
chi bootstrap look on this machine's usual ports
|
|
33
37
|
TARGET host[:port] (port 8080 when none), or a URL; a URL's
|
|
34
38
|
path is the OpenAI API base
|
|
@@ -37,6 +41,12 @@ module Samagotchi
|
|
|
37
41
|
--model ID the model, when the server has several
|
|
38
42
|
--key-env VAR the environment variable holding the API key (the
|
|
39
43
|
key itself is never written)
|
|
44
|
+
--api API openai or anthropic (Anthropic's Messages API) instead
|
|
45
|
+
of detecting it; detection takes anthropic only when
|
|
46
|
+
the OpenAI model list is refused
|
|
47
|
+
--dialect NAME the Messages API's dialect: standard or fireworks
|
|
48
|
+
(implies --api anthropic; default: fireworks when the
|
|
49
|
+
host or the model ids say so)
|
|
40
50
|
--no-test skip the test request
|
|
41
51
|
--dry-run show what would be written; write nothing
|
|
42
52
|
With no config.yml it writes one; an existing one gets a hosts:
|
|
@@ -50,10 +60,13 @@ module Samagotchi
|
|
|
50
60
|
f.value "--name"
|
|
51
61
|
f.value "--model"
|
|
52
62
|
f.value "--key-env"
|
|
63
|
+
f.value "--api"
|
|
64
|
+
f.value "--dialect"
|
|
53
65
|
f.switch "--no-test"
|
|
54
66
|
f.switch "--dry-run"
|
|
55
67
|
end
|
|
56
68
|
|
|
69
|
+
APIS = %i[openai anthropic].freeze
|
|
57
70
|
PICK_SHOWN = 20
|
|
58
71
|
# A test request slower than this says it is waiting (a model loading).
|
|
59
72
|
LOADING_AFTER = 3
|
|
@@ -92,6 +105,8 @@ module Samagotchi
|
|
|
92
105
|
return @exit
|
|
93
106
|
end
|
|
94
107
|
|
|
108
|
+
@api = options[:api]
|
|
109
|
+
@dialect = options[:dialect]
|
|
95
110
|
result = find(options, key_env) or return @exit
|
|
96
111
|
unlocked = with_key(result, key_env) or return @exit
|
|
97
112
|
result, key_env = unlocked
|
|
@@ -124,7 +139,25 @@ module Samagotchi
|
|
|
124
139
|
return usage_error("--key-env takes the variable's name (e.g. OPENROUTER_API_KEY), not the key")
|
|
125
140
|
end
|
|
126
141
|
|
|
127
|
-
options
|
|
142
|
+
parse_api(options)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# --api as a Symbol, --dialect downcased (implying --api anthropic).
|
|
146
|
+
def parse_api(options)
|
|
147
|
+
if options[:api]
|
|
148
|
+
api = options[:api].strip.downcase.to_sym
|
|
149
|
+
return usage_error("--api must be #{APIS.join(" or ")} (got #{options[:api]})") unless APIS.include?(api)
|
|
150
|
+
|
|
151
|
+
options[:api] = api
|
|
152
|
+
end
|
|
153
|
+
return options unless options[:dialect]
|
|
154
|
+
|
|
155
|
+
dialect = options[:dialect].strip.downcase
|
|
156
|
+
names = LLM::Dialect::BY_NAME.keys
|
|
157
|
+
return usage_error("--dialect must be #{names.join(" or ")} (got #{options[:dialect]})") unless names.include?(dialect)
|
|
158
|
+
return usage_error("--dialect is for --api anthropic, not --api openai") if options[:api] == :openai
|
|
159
|
+
|
|
160
|
+
options.merge(dialect: dialect, api: :anthropic)
|
|
128
161
|
end
|
|
129
162
|
|
|
130
163
|
# The server's Probe::Result, or nil with @exit set.
|
|
@@ -137,17 +170,26 @@ module Samagotchi
|
|
|
137
170
|
@exit = usage_error(e.message)
|
|
138
171
|
return nil
|
|
139
172
|
end
|
|
140
|
-
result = @probe.classify_target(candidates, key_env: key_env)
|
|
173
|
+
result = @probe.classify_target(candidates, key_env: key_env, api: @api, dialect: @dialect)
|
|
141
174
|
case result.kind
|
|
142
175
|
when :unreachable
|
|
143
176
|
fail!("can't reach #{result.candidate.label} (#{result.reason})")
|
|
144
177
|
when :unknown
|
|
145
178
|
detail = result.status ? "HTTP #{result.status}" : result.reason
|
|
146
|
-
fail!("reached #{result.candidate.label} but it answers
|
|
179
|
+
fail!("reached #{result.candidate.label} but it answers #{unknown_asked} (#{detail})")
|
|
147
180
|
else result
|
|
148
181
|
end
|
|
149
182
|
end
|
|
150
183
|
|
|
184
|
+
# What an :unknown server didn't answer, by what --api let the probe ask.
|
|
185
|
+
def unknown_asked
|
|
186
|
+
case @api
|
|
187
|
+
when :anthropic then "no Anthropic /v1/models"
|
|
188
|
+
when :openai then "neither llama.cpp /props nor /v1/models"
|
|
189
|
+
else "neither llama.cpp /props, /v1/models nor the Anthropic /v1/models"
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
151
193
|
def scan
|
|
152
194
|
found = @probe.scan_local
|
|
153
195
|
ports = Bootstrap::Probe::LOCAL_PORTS.keys.join(", ")
|
|
@@ -190,7 +232,7 @@ module Samagotchi
|
|
|
190
232
|
return fail!("#{key_env} isn't a variable name") unless key_env.match?(ConfigFile::ENV_NAME_RE)
|
|
191
233
|
return fail!("#{key_env} is not set; #{export_hint(key_env, "and run chi bootstrap again")}") if unset?(key_env)
|
|
192
234
|
|
|
193
|
-
result = @probe.classify(result.candidate, key_env: key_env)
|
|
235
|
+
result = @probe.classify(result.candidate, key_env: key_env, api: @api, dialect: @dialect)
|
|
194
236
|
return fail!("#{host} refused the key in #{key_env} (HTTP #{result.status})") if result.kind == :needs_key
|
|
195
237
|
return fail!("can't reach #{host} (#{result.reason || "HTTP #{result.status}"})") unless result.reached?
|
|
196
238
|
|
|
@@ -201,8 +243,15 @@ module Samagotchi
|
|
|
201
243
|
if result.native?
|
|
202
244
|
build = result.props["build_info"].to_s
|
|
203
245
|
@stdout.puts("found: llama.cpp at #{result.candidate.root}#{" (build #{build})" unless build.empty?}")
|
|
246
|
+
elsif result.anthropic?
|
|
247
|
+
dialect = " (dialect: #{result.dialect})" if result.dialect
|
|
248
|
+
@stdout.puts("found: an Anthropic Messages API at #{Bootstrap::Probe.api_root(result.candidate)}#{dialect}")
|
|
204
249
|
else
|
|
205
250
|
@stdout.puts("found: an OpenAI-compatible API at #{result.candidate.base}")
|
|
251
|
+
# By the host only: a proxy listing accounts/fireworks/ ids may not serve /v1/messages.
|
|
252
|
+
if @api.nil? && Bootstrap::Probe.fireworks_host?(result.candidate.host)
|
|
253
|
+
@stdout.puts("Fireworks also serves the Anthropic Messages API: --api anthropic")
|
|
254
|
+
end
|
|
206
255
|
end
|
|
207
256
|
end
|
|
208
257
|
|
|
@@ -248,17 +297,28 @@ module Samagotchi
|
|
|
248
297
|
@stdout.puts("test: waiting for an answer (loading the model?)…") if done.pop(timeout: LOADING_AFTER).nil?
|
|
249
298
|
end
|
|
250
299
|
end
|
|
251
|
-
seconds = @probe.test_turn(result.candidate, model, key_env: key_env
|
|
300
|
+
seconds = @probe.test_turn(result.candidate, model, key_env: key_env, api: result.anthropic? ? :anthropic : :openai,
|
|
301
|
+
dialect: result.dialect, auth: result.auth)
|
|
252
302
|
@stdout.puts(format("test: answered in %.1f s", seconds))
|
|
253
303
|
true
|
|
254
304
|
rescue StandardError => e
|
|
255
|
-
@stdout.puts("test: failed: #{e.message}")
|
|
305
|
+
@stdout.puts("test: failed: #{e.message}#{messages_hint(result, e)}")
|
|
256
306
|
false
|
|
257
307
|
ensure
|
|
258
308
|
done&.push(true)
|
|
259
309
|
waiting&.join
|
|
260
310
|
end
|
|
261
311
|
|
|
312
|
+
# A detected OpenAI server whose model list worked but whose
|
|
313
|
+
# /chat/completions is 404: maybe a Messages-only url (a gateway's
|
|
314
|
+
# Anthropic route that also lists the OpenAI way).
|
|
315
|
+
def messages_hint(result, error)
|
|
316
|
+
return "" unless @api.nil? && result.kind == :openai && error.is_a?(Bootstrap::Probe::TestFailed)
|
|
317
|
+
return "" unless error.status == 404 && error.path.to_s.end_with?("/chat/completions")
|
|
318
|
+
|
|
319
|
+
" (an Anthropic Messages API? try --api anthropic)"
|
|
320
|
+
end
|
|
321
|
+
|
|
262
322
|
# The "loading the model?" hint fits a local server (a llama.cpp loading
|
|
263
323
|
# weights); a remote provider's slow answer is the network, not a load.
|
|
264
324
|
def loading_hint?(result)
|
|
@@ -268,7 +328,7 @@ module Samagotchi
|
|
|
268
328
|
# 0 written, 1 written but not as planned, nil with @exit on a refusal.
|
|
269
329
|
def write(result, model, key_env, options)
|
|
270
330
|
writer = Bootstrap::ConfigWriter.new(path: @config_path, env: @env)
|
|
271
|
-
fields = host_fields(result
|
|
331
|
+
fields = host_fields(result, key_env)
|
|
272
332
|
ids = result.models.map(&:id)
|
|
273
333
|
base = Bootstrap::ConfigWriter.derived_name(result.candidate.host)
|
|
274
334
|
name, reason = begin
|
|
@@ -350,18 +410,35 @@ module Samagotchi
|
|
|
350
410
|
end
|
|
351
411
|
|
|
352
412
|
# The hosts entry: host/port for a plain http server (api: openai when it
|
|
353
|
-
# isn't llama.cpp), url for https or a URL with its own path
|
|
354
|
-
|
|
413
|
+
# isn't llama.cpp), url for https or a URL with its own path (an
|
|
414
|
+
# Anthropic one without /v1: the API root, as the hand-written claude
|
|
415
|
+
# host has it). Then api, dialect, auth (Bearer only when the dialect's
|
|
416
|
+
# own header isn't), api_key_env.
|
|
417
|
+
def host_fields(result, key_env)
|
|
418
|
+
candidate = result.candidate
|
|
355
419
|
fields = if candidate.url || candidate.scheme == "https"
|
|
356
|
-
{ "url" =>
|
|
420
|
+
{ "url" => url_field(result) }
|
|
357
421
|
else
|
|
358
422
|
{ "host" => candidate.host, "port" => candidate.port }
|
|
359
423
|
end
|
|
360
|
-
|
|
424
|
+
if result.anthropic?
|
|
425
|
+
fields["api"] = "anthropic"
|
|
426
|
+
fields["dialect"] = result.dialect if result.dialect
|
|
427
|
+
fields["auth"] = result.auth.to_s if result.auth && result.auth != LLM::Dialect.for(result.dialect).auth
|
|
428
|
+
elsif !result.native?
|
|
429
|
+
fields["api"] = "openai"
|
|
430
|
+
end
|
|
361
431
|
fields["api_key_env"] = key_env if key_env
|
|
362
432
|
fields
|
|
363
433
|
end
|
|
364
434
|
|
|
435
|
+
def url_field(result)
|
|
436
|
+
return result.candidate.root if result.native?
|
|
437
|
+
return Bootstrap::Probe.api_root(result.candidate) if result.anthropic?
|
|
438
|
+
|
|
439
|
+
result.candidate.base
|
|
440
|
+
end
|
|
441
|
+
|
|
365
442
|
def report(outcome, model)
|
|
366
443
|
path = outcome.path
|
|
367
444
|
case outcome.kind
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -658,7 +658,7 @@ module Samagotchi
|
|
|
658
658
|
|
|
659
659
|
begin
|
|
660
660
|
result = @engine.answer_question(id: qid, selected: selected, freeform: freeform, client_id: client_id)
|
|
661
|
-
[{}, 200, { status: "answered", session_id: session_id, answer: result }]
|
|
661
|
+
[{}, 200, { status: "answered", session_id: session_id, answer: result.to_h }]
|
|
662
662
|
rescue Engine::QuestionNotPending => e
|
|
663
663
|
# Another client answered first, or the question was cancelled.
|
|
664
664
|
[{}, 409, { error: "question_not_pending", detail: e.message }]
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: samagotchi-system
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.80.0
|
|
4
4
|
scope: system
|
|
5
5
|
description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
|
|
6
6
|
files:
|
|
7
7
|
identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
|
|
8
|
-
self_map.md: sha256:
|
|
8
|
+
self_map.md: sha256:4ff5708e51f55089de47b107643ae0b74aee04cb96111226823a8d44fb4bc33d
|
|
9
9
|
config_modification_protocol.md: sha256:66792f788cdbb8ee0252f2c387c96bd75266817bb75f721aaa181b602db97137
|
|
10
10
|
memory_guide.md: sha256:c48eea744db69e4f3e9bb415e9a03a8c78bbcaa1205ad3b1fb157a079f92d22e
|
|
11
11
|
delegated.md: sha256:a0d6ddcd4a8be06526d964898611775fd251cfd9d145a9d16afaccaa503597bd
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
approvals (I see only how each was answered); see `docs/sessions.md` "Delegating".
|
|
49
49
|
- Bundles extend me: memories, `hooks/*.rb`, `guardrails/*.yml`, and a `plugin.rb` that adds slash
|
|
50
50
|
commands (`anytime:` ones run mid-turn), model tools (they can return images I see), hooks,
|
|
51
|
-
cards, side answers (`ask_model`),
|
|
51
|
+
cards, side answers (`ask_model`), HTTP calls (`ctx.http`),
|
|
52
52
|
child sessions and services (e.g. MCP server processes). Shipped: `btw` (`/btw` side question),
|
|
53
53
|
`mcp` (MCP server tools, searched with find_tools and called with call_tool; a screenshot comes as a picture; a toolkit's undeclared built-ins too, as chi/<tool>), `guardrails` (rules), `known-names` (typo guard), `source-links`
|
|
54
54
|
(turns source refs like JIRA-123 in an answer into links in the web, and a one-line note), `loop-guard`
|
|
@@ -211,13 +211,13 @@ module Samagotchi
|
|
|
211
211
|
def answered(answer, client_id:)
|
|
212
212
|
# The /continue line takes the text on one line; the steer keeps its
|
|
213
213
|
# line breaks.
|
|
214
|
-
text = answer
|
|
214
|
+
text = answer.freeform.to_s.gsub(/\s+/, " ").strip
|
|
215
215
|
@continue_steer = nil
|
|
216
|
-
if
|
|
217
|
-
@continue_steer = { text: answer
|
|
218
|
-
source: answer
|
|
216
|
+
if answer.selected.include?(CONTINUE) && !text.empty?
|
|
217
|
+
@continue_steer = { text: answer.freeform.to_s.strip,
|
|
218
|
+
source: answer.parent_agent? ? "parent_agent" : "user" }
|
|
219
219
|
end
|
|
220
|
-
line = if
|
|
220
|
+
line = if answer.selected.include?(CONTINUE) then "/continue yes"
|
|
221
221
|
elsif text.empty? then "/continue no"
|
|
222
222
|
else "/continue no, #{text}"
|
|
223
223
|
end
|