samagotchi 0.66.0 → 0.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +60 -1
- data/docs/architecture.md +50 -28
- data/docs/cli.md +3 -1
- data/docs/configuration.md +50 -9
- data/docs/context.md +35 -1
- data/docs/desktop.md +90 -22
- data/docs/guardrails.md +8 -5
- data/docs/hooks.md +2 -2
- data/docs/internals/anthropic.md +156 -0
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +2 -0
- data/docs/internals/engine.md +71 -29
- data/docs/internals/gemma4-contract.md +1 -1
- data/docs/internals/llm-context-bench.md +5 -4
- data/docs/internals/prompt-caching.md +26 -11
- data/docs/plugins.md +126 -23
- data/docs/sessions.md +6 -6
- data/docs/sub-agent.md +4 -1
- data/docs/testing.md +17 -3
- data/lib/samagotchi/bootstrap/probe.rb +4 -3
- data/lib/samagotchi/bootstrap_command.rb +13 -3
- data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
- data/lib/samagotchi/bridge.rb +6 -3
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +34 -313
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
- data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +7 -6
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +37 -312
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +8 -3
- data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +14 -6
- data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
- data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/config.rb +15 -7
- data/lib/samagotchi/context_absorber.rb +66 -13
- data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
- data/lib/samagotchi/desktop/macos/App.swift +3 -3
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
- data/lib/samagotchi/desktop/macos/Images.swift +2 -2
- data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
- data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
- data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
- data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
- data/lib/samagotchi/desktop/macos.rb +84 -13
- data/lib/samagotchi/desktop_command.rb +14 -3
- data/lib/samagotchi/engine.rb +96 -191
- data/lib/samagotchi/engine_peers.rb +3 -0
- data/lib/samagotchi/guardrail_wiring.rb +1 -1
- data/lib/samagotchi/guardrails/approval.rb +1 -1
- data/lib/samagotchi/guardrails/approvals.rb +1 -1
- data/lib/samagotchi/guardrails/rules.rb +1 -1
- data/lib/samagotchi/guardrails/targets.rb +1 -1
- data/lib/samagotchi/hook_routes.rb +151 -0
- data/lib/samagotchi/hooks/registry.rb +2 -2
- data/lib/samagotchi/host_keys.rb +147 -0
- data/lib/samagotchi/llm/adapter_common.rb +138 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
- data/lib/samagotchi/llm/api_key.rb +23 -7
- data/lib/samagotchi/llm/chat_loop.rb +10 -6
- data/lib/samagotchi/llm/openai_chat.rb +9 -92
- data/lib/samagotchi/llm/refusal_memo.rb +29 -0
- data/lib/samagotchi/llm_context_override.rb +1 -0
- data/lib/samagotchi/peer_sources.rb +58 -0
- data/lib/samagotchi/plugin/api.rb +64 -155
- data/lib/samagotchi/plugin/loader.rb +4 -2
- data/lib/samagotchi/plugin/peer.rb +394 -0
- data/lib/samagotchi/plugin/tool_spec.rb +166 -0
- data/lib/samagotchi/plugin_tasks.rb +2 -2
- data/lib/samagotchi/row_refs.rb +1 -1
- data/lib/samagotchi/self_report.rb +5 -3
- data/lib/samagotchi/send_command.rb +21 -11
- data/lib/samagotchi/session_continue.rb +58 -15
- data/lib/samagotchi/session_inbox.rb +0 -13
- data/lib/samagotchi/tool_call_parser.rb +1 -1
- data/lib/samagotchi/toolkit_binding.rb +138 -0
- data/lib/samagotchi/tools/context_read.rb +14 -0
- data/lib/samagotchi/tools/execute.rb +12 -12
- data/lib/samagotchi/tools/registry.rb +1 -1
- data/lib/samagotchi/tools/task_create.rb +6 -6
- data/lib/samagotchi/tools/task_get.rb +13 -13
- data/lib/samagotchi/tools/task_list.rb +6 -6
- data/lib/samagotchi/tools/task_record.rb +52 -0
- data/lib/samagotchi/tools/task_runtime.rb +55 -91
- data/lib/samagotchi/tools/task_stop.rb +6 -6
- data/lib/samagotchi/tools/task_wait.rb +12 -13
- data/lib/samagotchi/tools/tool_index.rb +454 -0
- data/lib/samagotchi/update_command.rb +9 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +16 -9
- data/lib/samagotchi/web/capabilities.rb +1 -1
- data/lib/samagotchi/web/public/activity.js +2 -2
- data/lib/samagotchi/web/public/annotate_actions.js +1 -1
- data/lib/samagotchi/web/public/app.js +31 -36
- data/lib/samagotchi/web/public/chains.js +1 -1
- data/lib/samagotchi/web/public/commands.js +2 -2
- data/lib/samagotchi/web/public/composer_size.js +2 -2
- data/lib/samagotchi/web/public/copy.js +1 -1
- data/lib/samagotchi/web/public/ctx.js +1 -1
- data/lib/samagotchi/web/public/delivery_line.js +4 -4
- data/lib/samagotchi/web/public/families_view.js +1 -1
- data/lib/samagotchi/web/public/format.js +11 -7
- data/lib/samagotchi/web/public/images.js +25 -2
- data/lib/samagotchi/web/public/llm_context_chip.js +2 -2
- data/lib/samagotchi/web/public/notify.js +6 -4
- data/lib/samagotchi/web/public/question_card.js +1 -1
- data/lib/samagotchi/web/public/ref_scan.js +1 -1
- data/lib/samagotchi/web/public/session_state.js +14 -0
- data/lib/samagotchi/web/public/session_title.js +2 -3
- data/lib/samagotchi/web/public/sessions_list.js +0 -8
- data/lib/samagotchi/web/public/stage_model.js +2 -2
- data/lib/samagotchi/web/public/strip.js +3 -3
- data/lib/samagotchi/web/public/task_stop.js +3 -2
- data/lib/samagotchi/web/public/turn_events.js +1 -1
- data/lib/samagotchi/web/public/turn_html.js +2 -2
- data/lib/samagotchi/web/public/turn_model.js +2 -2
- data/lib/samagotchi/web/public/turn_view.js +1 -1
- data/lib/samagotchi/web/server.rb +0 -4
- data/lib/samagotchi/worker.rb +11 -8
- data/lib/samagotchi/worker_inbound.rb +36 -4
- data/lib/samagotchi/worker_wakes.rb +67 -7
- metadata +14 -1
|
@@ -5,11 +5,13 @@ require "uri"
|
|
|
5
5
|
require_relative "../config"
|
|
6
6
|
require_relative "../sampling_settings"
|
|
7
7
|
require_relative "../thinking"
|
|
8
|
+
require_relative "adapter_common"
|
|
8
9
|
require_relative "errors"
|
|
9
10
|
require_relative "http"
|
|
10
11
|
require_relative "model_listing"
|
|
11
12
|
require_relative "openai_chat"
|
|
12
13
|
require_relative "prompt_cache"
|
|
14
|
+
require_relative "refusal_memo"
|
|
13
15
|
require_relative "anthropic_assembly"
|
|
14
16
|
require_relative "anthropic_wire"
|
|
15
17
|
require_relative "dialect"
|
|
@@ -43,10 +45,9 @@ module Samagotchi
|
|
|
43
45
|
# names, per model.
|
|
44
46
|
class AnthropicMessages
|
|
45
47
|
include ModelListing
|
|
48
|
+
include AdapterCommon
|
|
46
49
|
|
|
47
50
|
API_VERSION = "2023-06-01"
|
|
48
|
-
DEFAULT_MODELS_TTL = 60
|
|
49
|
-
MAX_MODEL_PAGES = 20
|
|
50
51
|
# The budget form's budget_tokens per level (a model that takes no
|
|
51
52
|
# adaptive thinking), and Messages' smallest budget.
|
|
52
53
|
BUDGETS = { low: 2048, medium: 8192, high: 24_576 }.freeze
|
|
@@ -63,8 +64,6 @@ module Samagotchi
|
|
|
63
64
|
\bdisplay\W{0,4}(?:extra\s+inputs\s+are\s+not\s+permitted|is\s+not\s+(?:permitted|allowed|supported))/xi
|
|
64
65
|
# "max_tokens: 200000 > 128000, which is the maximum allowed number of output tokens for …"
|
|
65
66
|
OUTPUT_CAP_RE = /max_tokens`?:?\s*\d+\s*>\s*(\d+)/i
|
|
66
|
-
# How much of a 200's non-stream body is read as a possible JSON error.
|
|
67
|
-
PLAIN_ERROR_LIMIT = 64 * 1024
|
|
68
67
|
# max_tokens when default.max_tokens is unset and the model list names
|
|
69
68
|
# none (a listed one is capped by it too).
|
|
70
69
|
DEFAULT_MAX_TOKENS = 64_000
|
|
@@ -98,15 +97,12 @@ module Samagotchi
|
|
|
98
97
|
@api_key_env = api_key_env
|
|
99
98
|
@stream = stream
|
|
100
99
|
init_model_listing(models_ttl)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
@
|
|
104
|
-
@
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
@http = HTTP.new(label: @host_name, open_timeout: open_timeout, read_timeout: read_timeout,
|
|
108
|
-
retry_policy: policy, sleeper: sleeper, first_token_timeout: first_token_timeout,
|
|
109
|
-
api_key: ApiKey.for(api_key_env, host: @host_name, env: env, scheme: auth || @dialect.auth))
|
|
100
|
+
# The 400 fixes (#refusal_fix): :thinking_form and :output_cap per
|
|
101
|
+
# model, :thinking_display and :cache_control for the host.
|
|
102
|
+
@refusals = RefusalMemo.new
|
|
103
|
+
@http = build_http(timeout: timeout, open_timeout: open_timeout, retries: retries, retry_policy: retry_policy,
|
|
104
|
+
sleeper: sleeper, first_token_timeout: first_token_timeout,
|
|
105
|
+
api_key: ApiKey.for(api_key_env, host: @host_name, env: env, scheme: auth || @dialect.auth))
|
|
110
106
|
end
|
|
111
107
|
|
|
112
108
|
# The same contract as OpenAIChat#chat; +messages+ are ChatLoop's
|
|
@@ -170,22 +166,11 @@ module Samagotchi
|
|
|
170
166
|
# (max_input_tokens, max_tokens, capabilities) and the OpenAI shape a
|
|
171
167
|
# gateway or Splash serves here are both read (ModelListing).
|
|
172
168
|
# Whether +model+ gets the budget form (it refused adaptive thinking).
|
|
173
|
-
def budget_form?(model) = @
|
|
169
|
+
def budget_form?(model) = @refusals.refused?(:thinking_form, model.to_s)
|
|
174
170
|
|
|
175
171
|
# @return [Array<ModelInfo>]
|
|
176
172
|
def list_models
|
|
177
|
-
models
|
|
178
|
-
after = nil
|
|
179
|
-
MAX_MODEL_PAGES.times do
|
|
180
|
-
query = URI.encode_www_form({ limit: 1000, after_id: after }.compact)
|
|
181
|
-
uri = URI("#{@base_url}/v1/models?#{query}")
|
|
182
|
-
body = parse_json(@http.fetch(uri, Net::HTTP::Get.new(uri), log_fields: { purpose: "models" }).body, "model list")
|
|
183
|
-
models.concat(model_entries(body).map { |raw| model_info(raw) })
|
|
184
|
-
break unless body.is_a?(Hash) && body["has_more"] && body["last_id"]
|
|
185
|
-
|
|
186
|
-
after = body["last_id"].to_s
|
|
187
|
-
end
|
|
188
|
-
models
|
|
173
|
+
list_model_pages { |after| URI("#{@base_url}/v1/models?#{URI.encode_www_form({ limit: 1000, after_id: after }.compact)}") }
|
|
189
174
|
end
|
|
190
175
|
|
|
191
176
|
private
|
|
@@ -197,15 +182,6 @@ module Samagotchi
|
|
|
197
182
|
window.is_a?(Integer) && window.positive? ? window : super
|
|
198
183
|
end
|
|
199
184
|
|
|
200
|
-
def timeouts(timeout, open_limit = nil)
|
|
201
|
-
open_timeout = Samagotchi::Config.positive_seconds("server.open_timeout")
|
|
202
|
-
read_timeout = Samagotchi::Config.positive_seconds("server.read_timeout")
|
|
203
|
-
open_timeout = [open_timeout, open_limit].min if open_limit
|
|
204
|
-
return [open_timeout, read_timeout] unless timeout
|
|
205
|
-
|
|
206
|
-
[[open_timeout, timeout].min, timeout]
|
|
207
|
-
end
|
|
208
|
-
|
|
209
185
|
# The Messages body: model, max_tokens, the system blocks, the
|
|
210
186
|
# messages, the stream flag, the tools (tool_choice auto with them),
|
|
211
187
|
# then the options as Messages takes them (chi's OpenAI-only fields
|
|
@@ -213,11 +189,12 @@ module Samagotchi
|
|
|
213
189
|
def request_body(messages, tools, model, options)
|
|
214
190
|
options = (options || {}).to_h.transform_keys(&:to_sym).except(*DROPPED_OPTIONS)
|
|
215
191
|
options = budget_form(options) if budget_form?(model)
|
|
216
|
-
options = without_display(options) if @
|
|
217
|
-
cache = PromptCache.control if @purpose == "chat" && @dialect.cache_breakpoints && !@
|
|
192
|
+
options = without_display(options) if @refusals.refused?(:thinking_display)
|
|
193
|
+
cache = PromptCache.control if @purpose == "chat" && @dialect.cache_breakpoints && !@refusals.refused?(:cache_control)
|
|
218
194
|
translated = AnthropicWire.translate(messages, cache: cache)
|
|
219
195
|
max_tokens = options.delete(:max_tokens) || default_max_tokens(model)
|
|
220
|
-
|
|
196
|
+
output_cap = @refusals[:output_cap, model.to_s]
|
|
197
|
+
max_tokens = [max_tokens, output_cap].min if output_cap
|
|
221
198
|
body = { model: model, max_tokens: max_tokens }
|
|
222
199
|
body[:system] = translated.system unless translated.system.empty?
|
|
223
200
|
body[:messages] = translated.messages
|
|
@@ -283,21 +260,21 @@ module Samagotchi
|
|
|
283
260
|
detail = error.detail.to_s
|
|
284
261
|
# Before the adaptive check: the refused field's path
|
|
285
262
|
# ("thinking.adaptive.display") names adaptive too.
|
|
286
|
-
if !@
|
|
263
|
+
if !@refusals.refused?(:thinking_display) && body[:thinking].is_a?(Hash) && body[:thinking].key?(:display) &&
|
|
287
264
|
DISPLAY_RE.match?(detail)
|
|
288
|
-
@
|
|
265
|
+
@refusals.remember(:thinking_display)
|
|
289
266
|
Log.info(:http, "thinking_display_refused", host: @host_name, detail: detail[0, 200])
|
|
290
267
|
:thinking_display
|
|
291
268
|
elsif body.dig(:thinking, :type) == "adaptive" && ADAPTIVE_REFUSED_RE.match?(detail)
|
|
292
|
-
@
|
|
269
|
+
@refusals.remember(:thinking_form, model.to_s)
|
|
293
270
|
Log.info(:http, "thinking_form_switched", host: @host_name, model: model.to_s, to: "budget", detail: detail[0, 200])
|
|
294
271
|
:thinking_form
|
|
295
|
-
elsif !@
|
|
296
|
-
@
|
|
272
|
+
elsif !@refusals.refused?(:cache_control) && CACHE_CONTROL_RE.match?(detail) && cache_summary(body)
|
|
273
|
+
@refusals.remember(:cache_control)
|
|
297
274
|
Log.info(:http, "cache_control_refused", host: @host_name, detail: detail[0, 200])
|
|
298
275
|
:cache_control
|
|
299
276
|
elsif (cap = detail[OUTPUT_CAP_RE, 1]&.to_i) && cap.positive? && body[:max_tokens].to_i > cap
|
|
300
|
-
@
|
|
277
|
+
@refusals.remember(:output_cap, model.to_s, cap)
|
|
301
278
|
Log.info(:http, "output_cap", host: @host_name, model: model.to_s, max_tokens: cap)
|
|
302
279
|
:max_tokens
|
|
303
280
|
end
|
|
@@ -341,75 +318,21 @@ module Samagotchi
|
|
|
341
318
|
# `event:` lines are not needed, so a gateway that drops them works);
|
|
342
319
|
# on_delta gets every event but pings, with its text and reasoning.
|
|
343
320
|
def chat_streamed(request, cancel_controller, on_delta, on_retry, log_fields)
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
other = +""
|
|
347
|
-
plain = +""
|
|
348
|
-
# A retry streams the answer from the start again.
|
|
349
|
-
restart = lambda do |**event|
|
|
350
|
-
assembly = AnthropicAssembly.new
|
|
351
|
-
plain = +""
|
|
352
|
-
on_retry&.call(**event)
|
|
353
|
-
end
|
|
354
|
-
@http.stream_lines(URI(request.uri.to_s), request, cancel_controller: cancel_controller, on_retry: restart,
|
|
355
|
-
log_fields: log_fields) do |line, shown|
|
|
356
|
-
events += 1 if line.start_with?("data:")
|
|
357
|
-
other << line[0, 200] if !line.start_with?("data:") && other.length < 200
|
|
358
|
-
raise_plain_error(plain, line) if events.zero?
|
|
359
|
-
event = parse_line(line)
|
|
360
|
-
next unless event
|
|
361
|
-
|
|
321
|
+
stream_assembled(request, cancel_controller, on_retry, log_fields, assembly_class: AnthropicAssembly,
|
|
322
|
+
what: "stream event") do |assembly, event, shown|
|
|
362
323
|
content, reasoning = assembly.add(event)
|
|
363
324
|
shown.call if AnthropicAssembly.shows?(event)
|
|
364
325
|
on_delta&.call(content: content, reasoning: reasoning, payload: assembly.with_usage(event)) unless event["type"] == "ping"
|
|
365
326
|
end
|
|
366
|
-
raise ProtocolError.new("#{@host_name}: the response had no stream events: #{other}", host: @host_name) if events.zero?
|
|
367
|
-
|
|
368
|
-
assembly.response
|
|
369
|
-
end
|
|
370
|
-
|
|
371
|
-
# A 200 whose body is a plain JSON error instead of a stream: raised
|
|
372
|
-
# from inside the stream (so it is retried by its kind) once the lines
|
|
373
|
-
# so far parse as an error object (OpenAIChat's rule).
|
|
374
|
-
def raise_plain_error(plain, line)
|
|
375
|
-
return if line.empty? || line.start_with?(":") || (plain.empty? && !line.start_with?("{"))
|
|
376
|
-
return if plain.bytesize > PLAIN_ERROR_LIMIT
|
|
377
|
-
|
|
378
|
-
plain << line << "\n"
|
|
379
|
-
error = ProviderErrors.from_error_body(plain, host: @host_name)
|
|
380
|
-
raise error if error
|
|
381
|
-
end
|
|
382
|
-
|
|
383
|
-
# The parsed event of a `data:` line; nil for `event:` lines, blank
|
|
384
|
-
# lines and comments. An error event raises its ProviderError.
|
|
385
|
-
def parse_line(line)
|
|
386
|
-
error = HTTP.sse_error(line, host: @host_name)
|
|
387
|
-
raise error if error
|
|
388
|
-
return nil unless line.start_with?("data:")
|
|
389
|
-
|
|
390
|
-
data = line.delete_prefix("data:").strip
|
|
391
|
-
return nil if data.empty? || data == "[DONE]"
|
|
392
|
-
|
|
393
|
-
event = parse_json(data, "stream event")
|
|
394
|
-
event.is_a?(Hash) ? event : nil
|
|
395
|
-
end
|
|
396
|
-
|
|
397
|
-
def parse_json(text, what)
|
|
398
|
-
JSON.parse(text.to_s)
|
|
399
|
-
rescue JSON::ParserError => e
|
|
400
|
-
raise ProtocolError.new("#{@host_name}: malformed #{what}: #{e.message[0, 200]}", host: @host_name)
|
|
401
327
|
end
|
|
402
328
|
|
|
403
329
|
def post_request(url, body, session_id: nil)
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
request["Session-Id"] = session_id.to_s
|
|
409
|
-
request[@dialect.session_header] = session_id.to_s if @dialect.session_header
|
|
410
|
-
end
|
|
411
|
-
request.body = JSON.generate(body)
|
|
330
|
+
headers = { "anthropic-version" => API_VERSION }
|
|
331
|
+
unless session_id.to_s.empty?
|
|
332
|
+
headers["Session-Id"] = session_id.to_s
|
|
333
|
+
headers[@dialect.session_header] = session_id.to_s if @dialect.session_header
|
|
412
334
|
end
|
|
335
|
+
json_post(url, body, headers)
|
|
413
336
|
end
|
|
414
337
|
end
|
|
415
338
|
end
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "errors"
|
|
4
|
+
require_relative "../host_keys"
|
|
4
5
|
|
|
5
6
|
module Samagotchi
|
|
6
7
|
module LLM
|
|
7
|
-
# A host's API key: the environment variable its api_key_env: names
|
|
8
|
+
# A host's API key: the environment variable its api_key_env: names
|
|
9
|
+
# (else, on macOS, the login Keychain's item for that name: HostKeys),
|
|
8
10
|
# sent on every request LLM::HTTP makes for the host (llama.cpp started
|
|
9
11
|
# with --api-key, or a provider) in the header its +scheme+ names:
|
|
10
12
|
# :bearer `Authorization: Bearer <key>`, :x_api_key `x-api-key: <key>`
|
|
@@ -26,14 +28,15 @@ module Samagotchi
|
|
|
26
28
|
|
|
27
29
|
# What to try after a 401/403 from a host that has no api_key_env.
|
|
28
30
|
def self.missing_hint(host)
|
|
29
|
-
|
|
31
|
+
where = HostKeys.available? ? "an environment variable (or the Keychain)" : "an environment variable"
|
|
32
|
+
"the server may want an API key: put it in #{where} and name it with api_key_env: on host #{host}"
|
|
30
33
|
end
|
|
31
34
|
|
|
32
35
|
# Sets the scheme's header (exactly one of the two), or raises
|
|
33
|
-
# AuthError when the variable
|
|
36
|
+
# AuthError when neither the variable nor the Keychain has the key.
|
|
34
37
|
def authorize(request)
|
|
35
|
-
key = env
|
|
36
|
-
raise AuthError.new(
|
|
38
|
+
key = HostKeys.fetch(env_name, env: env)
|
|
39
|
+
raise AuthError.new(unset_message, host: host) unless key
|
|
37
40
|
|
|
38
41
|
if scheme == :x_api_key
|
|
39
42
|
request.delete("Authorization")
|
|
@@ -44,14 +47,27 @@ module Samagotchi
|
|
|
44
47
|
end
|
|
45
48
|
end
|
|
46
49
|
|
|
47
|
-
# What to try after a 401/403 with the key sent
|
|
48
|
-
|
|
50
|
+
# What to try after a 401/403 with the key sent, and where it came
|
|
51
|
+
# from (a Keychain key is a cache hit by then).
|
|
52
|
+
def hint
|
|
53
|
+
from = HostKeys.source(env_name, env: env) == :keychain ? " (from the Keychain)" : ""
|
|
54
|
+
"check #{env_name}#{from} (the API key for host #{host})"
|
|
55
|
+
end
|
|
49
56
|
|
|
50
57
|
# Without the environment: it holds this key and every other secret.
|
|
51
58
|
def inspect = "#<#{self.class.name} #{env_name} host=#{host}#{" scheme=#{scheme}" unless scheme == :bearer}>"
|
|
52
59
|
alias_method :to_s, :inspect
|
|
53
60
|
|
|
54
61
|
def pretty_print(printer) = printer.text(inspect)
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def unset_message
|
|
66
|
+
message = "#{host}: set #{env_name} (the API key for host #{host})"
|
|
67
|
+
return message unless HostKeys.available?
|
|
68
|
+
|
|
69
|
+
"#{message} or store it in the Keychain: #{HostKeys.store_command(env_name)}"
|
|
70
|
+
end
|
|
55
71
|
end
|
|
56
72
|
ApiKey::SCHEMES = %i[bearer x_api_key].freeze
|
|
57
73
|
end
|
|
@@ -8,6 +8,7 @@ require_relative "errors"
|
|
|
8
8
|
require_relative "usage"
|
|
9
9
|
require_relative "openai_chat"
|
|
10
10
|
require_relative "native_tool_normalizer"
|
|
11
|
+
require_relative "refusal_memo"
|
|
11
12
|
require_relative "stream_drops"
|
|
12
13
|
require_relative "../kernel_loop"
|
|
13
14
|
require_relative "../answer_display"
|
|
@@ -70,6 +71,9 @@ module Samagotchi
|
|
|
70
71
|
@adapter = adapter
|
|
71
72
|
@stable_length = stable_length
|
|
72
73
|
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
74
|
+
# What a host refused per model, for this loop's (the worker's)
|
|
75
|
+
# life: :reasoning_history, :thinking, :sampling.
|
|
76
|
+
@refusals = RefusalMemo.new
|
|
73
77
|
end
|
|
74
78
|
|
|
75
79
|
def provider = :chat
|
|
@@ -242,7 +246,7 @@ module Samagotchi
|
|
|
242
246
|
# blocks always go back (a tool loop with thinking needs the last
|
|
243
247
|
# assistant turn's), whatever the level says.
|
|
244
248
|
def reasoning_history(model = nil)
|
|
245
|
-
level = model && @
|
|
249
|
+
level = model && @refusals.refused?(:reasoning_history, model) ? :drop : @kernel.turn_settings&.reasoning_history
|
|
246
250
|
level ||= :drop
|
|
247
251
|
level == :drop && replays_content? ? :turn : level
|
|
248
252
|
end
|
|
@@ -254,7 +258,7 @@ module Samagotchi
|
|
|
254
258
|
# The host refused +model+'s reasoning sent back: send none from now on
|
|
255
259
|
# (this worker's lifetime, as #thinking_refused!).
|
|
256
260
|
def reasoning_history_refused!(model)
|
|
257
|
-
|
|
261
|
+
@refusals.remember(:reasoning_history, model)
|
|
258
262
|
end
|
|
259
263
|
|
|
260
264
|
# The turn's VisionContext (the Engine sets the kernel's
|
|
@@ -274,14 +278,14 @@ module Samagotchi
|
|
|
274
278
|
# takes them (OpenAIChat#thinking_fields: Thinking.chat_fields); none
|
|
275
279
|
# for a model whose host refused them (#thinking_refused!).
|
|
276
280
|
def thinking_fields(model = nil)
|
|
277
|
-
return {} if model &&
|
|
281
|
+
return {} if model && @refusals.refused?(:thinking, model)
|
|
278
282
|
|
|
279
283
|
adapter.thinking_fields(thinking)
|
|
280
284
|
end
|
|
281
285
|
|
|
282
286
|
# The host refused +model+'s thinking fields: leave them out from now on.
|
|
283
287
|
def thinking_refused!(model)
|
|
284
|
-
|
|
288
|
+
@refusals.remember(:thinking, model)
|
|
285
289
|
end
|
|
286
290
|
|
|
287
291
|
# One generation's options: default.max_tokens as max_tokens (the
|
|
@@ -296,7 +300,7 @@ module Samagotchi
|
|
|
296
300
|
options = deep_merge(thinking_fields(model), sampling)
|
|
297
301
|
options = { max_tokens: max_tokens }.merge(options) if max_tokens
|
|
298
302
|
options = EmptyAnswerRetry.sampling(options) if retry_generation
|
|
299
|
-
options = options.except(*SAMPLING_KEYS) if model && @
|
|
303
|
+
options = options.except(*SAMPLING_KEYS) if model && @refusals.refused?(:sampling, model)
|
|
300
304
|
deep_compact(options)
|
|
301
305
|
end
|
|
302
306
|
|
|
@@ -306,7 +310,7 @@ module Samagotchi
|
|
|
306
310
|
# The host refused a sampling field for +model+: send none of
|
|
307
311
|
# SAMPLING_KEYS from now on (this worker's lifetime, as #thinking_refused!).
|
|
308
312
|
def sampling_refused!(model)
|
|
309
|
-
|
|
313
|
+
@refusals.remember(:sampling, model)
|
|
310
314
|
end
|
|
311
315
|
|
|
312
316
|
def strip_model_thought(text) = @kernel.strip_model_thought(text)
|
|
@@ -5,6 +5,7 @@ require "uri"
|
|
|
5
5
|
require_relative "../config"
|
|
6
6
|
require_relative "../sampling_settings"
|
|
7
7
|
require_relative "../thinking"
|
|
8
|
+
require_relative "adapter_common"
|
|
8
9
|
require_relative "errors"
|
|
9
10
|
require_relative "http"
|
|
10
11
|
require_relative "model_listing"
|
|
@@ -70,11 +71,8 @@ module Samagotchi
|
|
|
70
71
|
# only; the chat loop owns the conversation and the tools.
|
|
71
72
|
class OpenAIChat
|
|
72
73
|
include ModelListing
|
|
74
|
+
include AdapterCommon
|
|
73
75
|
|
|
74
|
-
MAX_MODEL_PAGES = 20
|
|
75
|
-
# How much of a 200's non-stream body is read as a possible JSON error.
|
|
76
|
-
PLAIN_ERROR_LIMIT = 64 * 1024
|
|
77
|
-
DEFAULT_MODELS_TTL = 60
|
|
78
76
|
# OpenRouter holds a paid request's estimated cost against the balance
|
|
79
77
|
# while it runs: the input plus the output max_tokens allows (a fixed
|
|
80
78
|
# per-request cap when it is unset), and refuses a request with a 402
|
|
@@ -154,11 +152,9 @@ module Samagotchi
|
|
|
154
152
|
@api_key_env = api_key_env
|
|
155
153
|
@stream = stream
|
|
156
154
|
init_model_listing(models_ttl)
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
retry_policy: policy, sleeper: sleeper, first_token_timeout: first_token_timeout,
|
|
161
|
-
api_key: ApiKey.for(api_key_env, host: @host_name, env: env, scheme: auth))
|
|
155
|
+
@http = build_http(timeout: timeout, open_timeout: open_timeout, retries: retries, retry_policy: retry_policy,
|
|
156
|
+
sleeper: sleeper, first_token_timeout: first_token_timeout,
|
|
157
|
+
api_key: ApiKey.for(api_key_env, host: @host_name, env: env, scheme: auth))
|
|
162
158
|
end
|
|
163
159
|
|
|
164
160
|
# @param messages [Array<Hash>] wire messages (role, content, tool_calls,
|
|
@@ -180,33 +176,12 @@ module Samagotchi
|
|
|
180
176
|
cache: cache_summary(body[:messages]), cache_by: ("session" if body.key?(:prompt_cache_key)) }
|
|
181
177
|
return chat_once(request, cancel_controller, log_fields) unless @stream
|
|
182
178
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
other = +""
|
|
186
|
-
plain = +""
|
|
187
|
-
# A retry streams the answer from the start again.
|
|
188
|
-
restart = lambda do |**event|
|
|
189
|
-
assembly = Assembly.new
|
|
190
|
-
plain = +""
|
|
191
|
-
on_retry&.call(**event)
|
|
192
|
-
end
|
|
193
|
-
@http.stream_lines(URI(request.uri.to_s), request, cancel_controller: cancel_controller, on_retry: restart,
|
|
194
|
-
log_fields: log_fields) do |line, shown|
|
|
195
|
-
events += 1 if line.start_with?("data:")
|
|
196
|
-
other << line[0, 200] if !line.start_with?("data:") && other.length < 200
|
|
197
|
-
raise_plain_error(plain, line) if events.zero?
|
|
198
|
-
payload = parse_line(line)
|
|
199
|
-
next unless payload
|
|
200
|
-
|
|
179
|
+
stream_assembled(request, cancel_controller, on_retry, log_fields, assembly_class: Assembly,
|
|
180
|
+
what: "stream chunk") do |assembly, payload, shown|
|
|
201
181
|
content, reasoning = assembly.add(payload)
|
|
202
182
|
shown.call if !content.empty? || !reasoning.empty? || Assembly.tool_call_delta?(payload)
|
|
203
183
|
on_delta&.call(content: content, reasoning: reasoning, payload: payload)
|
|
204
184
|
end
|
|
205
|
-
# A server that ignores stream: true, or answers with something else
|
|
206
|
-
# entirely, would otherwise end the turn with no answer and no error.
|
|
207
|
-
raise ProtocolError.new("#{@host_name}: the response had no stream events: #{other}", host: @host_name) if events.zero?
|
|
208
|
-
|
|
209
|
-
assembly.response
|
|
210
185
|
end
|
|
211
186
|
|
|
212
187
|
# A provider on the network: no llama.cpp /props to ask for the window.
|
|
@@ -233,17 +208,7 @@ module Samagotchi
|
|
|
233
208
|
|
|
234
209
|
# @return [Array<ModelInfo>] the host's models (every page)
|
|
235
210
|
def list_models
|
|
236
|
-
models
|
|
237
|
-
after = nil
|
|
238
|
-
MAX_MODEL_PAGES.times do
|
|
239
|
-
uri = URI("#{@base_url}/models#{"?after=#{URI.encode_www_form_component(after)}" if after}")
|
|
240
|
-
body = parse_json(@http.fetch(uri, get_request(uri), log_fields: { purpose: "models" }).body, "model list")
|
|
241
|
-
models.concat(model_entries(body).map { |raw| model_info(raw) })
|
|
242
|
-
break unless body.is_a?(Hash) && body["has_more"] && body["last_id"]
|
|
243
|
-
|
|
244
|
-
after = body["last_id"].to_s
|
|
245
|
-
end
|
|
246
|
-
models
|
|
211
|
+
list_model_pages { |after| URI("#{@base_url}/models#{"?after=#{URI.encode_www_form_component(after)}" if after}") }
|
|
247
212
|
end
|
|
248
213
|
|
|
249
214
|
def self.served_model(payload)
|
|
@@ -260,15 +225,6 @@ module Samagotchi
|
|
|
260
225
|
|
|
261
226
|
private
|
|
262
227
|
|
|
263
|
-
def timeouts(timeout, open_limit = nil)
|
|
264
|
-
open_timeout = Samagotchi::Config.positive_seconds("server.open_timeout")
|
|
265
|
-
read_timeout = Samagotchi::Config.positive_seconds("server.read_timeout")
|
|
266
|
-
open_timeout = [open_timeout, open_limit].min if open_limit
|
|
267
|
-
return [open_timeout, read_timeout] unless timeout
|
|
268
|
-
|
|
269
|
-
[[open_timeout, timeout].min, timeout]
|
|
270
|
-
end
|
|
271
|
-
|
|
272
228
|
def request_body(messages, tools, model, options, session_id)
|
|
273
229
|
body = {
|
|
274
230
|
model: model,
|
|
@@ -364,49 +320,10 @@ module Samagotchi
|
|
|
364
320
|
|
|
365
321
|
def served_provider(payload) = self.class.served_provider(payload)
|
|
366
322
|
|
|
367
|
-
# The parsed payload of a `data:` line; nil for blank lines, comments
|
|
368
|
-
# and [DONE]. Error events raise their ProviderError.
|
|
369
|
-
# A 200 whose body is a plain JSON error instead of a stream (OpenRouter
|
|
370
|
-
# sends an upstream 503 so): raised, from inside the stream so it is
|
|
371
|
-
# retried by its kind, once the lines so far parse as an error object.
|
|
372
|
-
def raise_plain_error(plain, line)
|
|
373
|
-
return if line.empty? || line.start_with?(":") || (plain.empty? && !line.start_with?("{"))
|
|
374
|
-
return if plain.bytesize > PLAIN_ERROR_LIMIT
|
|
375
|
-
|
|
376
|
-
plain << line << "\n"
|
|
377
|
-
error = ProviderErrors.from_error_body(plain, host: @host_name)
|
|
378
|
-
raise error if error
|
|
379
|
-
end
|
|
380
|
-
|
|
381
|
-
def parse_line(line)
|
|
382
|
-
error = HTTP.sse_error(line, host: @host_name)
|
|
383
|
-
raise error if error
|
|
384
|
-
return nil unless line.start_with?("data:")
|
|
385
|
-
|
|
386
|
-
data = line.delete_prefix("data:").strip
|
|
387
|
-
return nil if data.empty? || data == "[DONE]"
|
|
388
|
-
|
|
389
|
-
payload = parse_json(data, "stream chunk")
|
|
390
|
-
payload.is_a?(Hash) ? payload : nil
|
|
391
|
-
end
|
|
392
|
-
|
|
393
|
-
def parse_json(text, what)
|
|
394
|
-
JSON.parse(text.to_s)
|
|
395
|
-
rescue JSON::ParserError => e
|
|
396
|
-
raise ProtocolError.new("#{@host_name}: malformed #{what}: #{e.message[0, 200]}", host: @host_name)
|
|
397
|
-
end
|
|
398
|
-
|
|
399
323
|
def post_request(url, body, session_id: nil)
|
|
400
|
-
|
|
401
|
-
Net::HTTP::Post.new(uri).tap do |request|
|
|
402
|
-
request["Content-Type"] = "application/json"
|
|
403
|
-
request["Session-Id"] = session_id.to_s unless session_id.to_s.empty?
|
|
404
|
-
request.body = JSON.generate(body)
|
|
405
|
-
end
|
|
324
|
+
json_post(url, body, session_id.to_s.empty? ? {} : { "Session-Id" => session_id.to_s })
|
|
406
325
|
end
|
|
407
326
|
|
|
408
|
-
def get_request(uri) = Net::HTTP::Get.new(uri)
|
|
409
|
-
|
|
410
327
|
# Streamed deltas put together: text, reasoning, tool calls by index,
|
|
411
328
|
# the finish reason, the usage chunk, the served model and provider.
|
|
412
329
|
class Assembly
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module LLM
|
|
5
|
+
# What a host refused, remembered for its holder's life (an adapter: the
|
|
6
|
+
# host's, HostRegistry keeps one per worker; ChatLoop: the worker's): a
|
|
7
|
+
# +kind+ (a Symbol) for one model, or for the whole host when the model
|
|
8
|
+
# is nil, with a value (true, or what the refusal named: an output cap).
|
|
9
|
+
# The turn, side-ask and recap threads share a holder, so every read and
|
|
10
|
+
# write takes the lock.
|
|
11
|
+
class RefusalMemo
|
|
12
|
+
def initialize
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
@facts = {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Remember +kind+ for +model+ (nil: the host) as +value+.
|
|
18
|
+
def remember(kind, model = nil, value = true)
|
|
19
|
+
@mutex.synchronize { @facts[[kind, model]] = value }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Whether +kind+ is remembered for +model+ (nil: the host).
|
|
23
|
+
def refused?(kind, model = nil) = @mutex.synchronize { @facts.key?([kind, model]) }
|
|
24
|
+
|
|
25
|
+
# The value remembered for +kind+ and +model+, or nil.
|
|
26
|
+
def [](kind, model = nil) = @mutex.synchronize { @facts[[kind, model]] }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -153,6 +153,7 @@ module Samagotchi
|
|
|
153
153
|
end
|
|
154
154
|
|
|
155
155
|
# "64000", "64k" or "off" (0): LLMContextStrategy.budget_tokens.
|
|
156
|
+
# .update reaches it (and the other parse_<field>s) by its name.
|
|
156
157
|
# @return [Integer] 0 for off
|
|
157
158
|
def self.parse_budget_tokens(text) = LLMContextStrategy.budget_tokens(text)
|
|
158
159
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "context_sources"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# The attached context sources a loaded peer bundle declared as its
|
|
7
|
+
# peer's answers (chi.peer_source: claude-review, codex-review), with the
|
|
8
|
+
# peer's name ("Claude"). In-process only: a source is a peer's because
|
|
9
|
+
# the bundle's code says so, never because of what a source file holds,
|
|
10
|
+
# which a pushing process can write. Uninstalling the bundle ends it.
|
|
11
|
+
#
|
|
12
|
+
# A declared name counts only for a pushed source in the session's own
|
|
13
|
+
# location (ContextAbsorber#peer_label): a command or project source of
|
|
14
|
+
# that name is an ordinary one. See docs/context.md (Waking).
|
|
15
|
+
class PeerSources
|
|
16
|
+
# The peer's name, shown inside chi's own framing lines.
|
|
17
|
+
LABEL = /\A[A-Z][A-Za-z ]{0,23}\z/
|
|
18
|
+
|
|
19
|
+
def initialize
|
|
20
|
+
@labels = {}
|
|
21
|
+
@bundles = {}
|
|
22
|
+
@lock = Mutex.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Checks +name+ and +label+ before a plugin's registrations commit.
|
|
26
|
+
# @raise [ArgumentError]
|
|
27
|
+
def check!(name, label:, bundle:)
|
|
28
|
+
name = name.to_s
|
|
29
|
+
begin
|
|
30
|
+
ContextSources.check_name!(name)
|
|
31
|
+
rescue ContextSources::Invalid => e
|
|
32
|
+
raise ArgumentError, "peer source: #{e.message}"
|
|
33
|
+
end
|
|
34
|
+
raise ArgumentError, "peer source #{name}: label must match #{LABEL.inspect}" unless label.to_s.match?(LABEL)
|
|
35
|
+
|
|
36
|
+
taken = @lock.synchronize { @bundles[name] }
|
|
37
|
+
raise ArgumentError, "peer source #{name} is already declared (#{taken})" if taken && taken != bundle
|
|
38
|
+
|
|
39
|
+
name
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Declares +name+ +label+'s answers (+bundle+: who declared it).
|
|
43
|
+
# @raise [ArgumentError]
|
|
44
|
+
def declare(name, label:, bundle:)
|
|
45
|
+
name = check!(name, label: label, bundle: bundle)
|
|
46
|
+
@lock.synchronize do
|
|
47
|
+
@labels[name] = label.to_s
|
|
48
|
+
@bundles[name] = bundle
|
|
49
|
+
end
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [String, nil] the peer's name when +name+ is declared
|
|
54
|
+
def label_for(name) = @lock.synchronize { @labels[name.to_s] }
|
|
55
|
+
|
|
56
|
+
def names = @lock.synchronize { @labels.keys }
|
|
57
|
+
end
|
|
58
|
+
end
|