samagotchi 0.64.0 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -1
  3. data/README.md +2 -1
  4. data/bin/chi +51 -2
  5. data/docs/architecture.md +20 -3
  6. data/docs/cli.md +36 -2
  7. data/docs/configuration.md +177 -8
  8. data/docs/context.md +4 -1
  9. data/docs/internals/engine.md +19 -4
  10. data/docs/internals/prompt-caching.md +10 -2
  11. data/docs/memory.md +1 -1
  12. data/docs/plugins.md +163 -2
  13. data/docs/releasing.md +1 -1
  14. data/docs/sessions.md +13 -2
  15. data/docs/sub-agent.md +3 -1
  16. data/lib/samagotchi/bridge.rb +11 -1
  17. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/claude-peer/plugin.rb +18 -1
  19. data/lib/samagotchi/bundles/codex-peer/manifest.yml +21 -0
  20. data/lib/samagotchi/bundles/codex-peer/plugin.rb +464 -0
  21. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +131 -0
  22. data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
  23. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +68 -0
  24. data/lib/samagotchi/bundles/coordinator/manifest.yml +5 -5
  25. data/lib/samagotchi/bundles/coordinator/plugin.rb +24 -2
  26. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  28. data/lib/samagotchi/bundles/system/delegated.md +2 -1
  29. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  31. data/lib/samagotchi/cache_confirm.rb +9 -6
  32. data/lib/samagotchi/client.rb +4 -2
  33. data/lib/samagotchi/commands/registry.rb +25 -8
  34. data/lib/samagotchi/config.rb +13 -5
  35. data/lib/samagotchi/engine.rb +151 -11
  36. data/lib/samagotchi/generation_phase.rb +17 -0
  37. data/lib/samagotchi/host_config.rb +27 -6
  38. data/lib/samagotchi/host_registry.rb +34 -11
  39. data/lib/samagotchi/idle_client.rb +62 -25
  40. data/lib/samagotchi/idle_recap.rb +5 -2
  41. data/lib/samagotchi/idle_target.rb +30 -7
  42. data/lib/samagotchi/kernel_loop.rb +9 -1
  43. data/lib/samagotchi/llm/adapters.rb +41 -0
  44. data/lib/samagotchi/llm/anthropic_assembly.rb +212 -0
  45. data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
  46. data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
  47. data/lib/samagotchi/llm/api_key.rb +26 -9
  48. data/lib/samagotchi/llm/chat_loop.rb +113 -14
  49. data/lib/samagotchi/llm/dialect.rb +57 -0
  50. data/lib/samagotchi/llm/errors.rb +81 -12
  51. data/lib/samagotchi/llm/model_listing.rb +111 -0
  52. data/lib/samagotchi/llm/model_result.rb +9 -2
  53. data/lib/samagotchi/llm/openai_chat.rb +46 -93
  54. data/lib/samagotchi/model_profile.rb +1 -1
  55. data/lib/samagotchi/plugin/api.rb +80 -7
  56. data/lib/samagotchi/plugin/context.rb +22 -1
  57. data/lib/samagotchi/plugin/loader.rb +3 -2
  58. data/lib/samagotchi/reasoning_history.rb +14 -4
  59. data/lib/samagotchi/recap_setup.rb +2 -1
  60. data/lib/samagotchi/self_report.rb +18 -28
  61. data/lib/samagotchi/send_command.rb +23 -7
  62. data/lib/samagotchi/session.rb +21 -3
  63. data/lib/samagotchi/session_commands.rb +16 -1
  64. data/lib/samagotchi/session_manager.rb +4 -1
  65. data/lib/samagotchi/session_metrics.rb +6 -11
  66. data/lib/samagotchi/session_setup.rb +14 -7
  67. data/lib/samagotchi/steer_cut.rb +4 -0
  68. data/lib/samagotchi/system_prompt.rb +16 -1
  69. data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
  70. data/lib/samagotchi/terminal_ui/attached_loop.rb +8 -2
  71. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  72. data/lib/samagotchi/terminal_ui/formatting.rb +4 -3
  73. data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
  74. data/lib/samagotchi/terminal_ui.rb +18 -2
  75. data/lib/samagotchi/tool_runner.rb +13 -1
  76. data/lib/samagotchi/toolkit.rb +246 -0
  77. data/lib/samagotchi/toolkit_command.rb +117 -0
  78. data/lib/samagotchi/tools/registry.rb +43 -6
  79. data/lib/samagotchi/turn_note.rb +22 -5
  80. data/lib/samagotchi/version.rb +1 -1
  81. data/lib/samagotchi/web/app.rb +14 -1
  82. data/lib/samagotchi/web/public/activity.js +3 -0
  83. data/lib/samagotchi/web/public/annotate_actions.js +109 -0
  84. data/lib/samagotchi/web/public/annotate_bar.js +84 -2
  85. data/lib/samagotchi/web/public/app.js +111 -20
  86. data/lib/samagotchi/web/public/commands.js +4 -2
  87. data/lib/samagotchi/web/public/delivery_line.js +228 -0
  88. data/lib/samagotchi/web/public/index.html +38 -0
  89. data/lib/samagotchi/web/public/info_bar.js +5 -3
  90. data/lib/samagotchi/web/public/live_turn.js +8 -0
  91. data/lib/samagotchi/web/public/session_state.js +3 -0
  92. data/lib/samagotchi/web/public/stage_model.js +27 -7
  93. data/lib/samagotchi/web/public/stage_view.js +32 -10
  94. data/lib/samagotchi/web/public/stream_handlers.js +21 -2
  95. data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
  96. data/lib/samagotchi/web/public/turn_events.js +22 -8
  97. data/lib/samagotchi/web/public/turn_html.js +20 -4
  98. data/lib/samagotchi/web/public/turn_model.js +16 -9
  99. data/lib/samagotchi/web/public/turn_view.js +6 -4
  100. data/lib/samagotchi/worker.rb +2 -0
  101. metadata +17 -1
@@ -0,0 +1,416 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+ require_relative "../config"
6
+ require_relative "../sampling_settings"
7
+ require_relative "../thinking"
8
+ require_relative "errors"
9
+ require_relative "http"
10
+ require_relative "model_listing"
11
+ require_relative "openai_chat"
12
+ require_relative "prompt_cache"
13
+ require_relative "anthropic_assembly"
14
+ require_relative "anthropic_wire"
15
+ require_relative "dialect"
16
+
17
+ module Samagotchi
18
+ module LLM
19
+ # Anthropic's Messages API (`POST {base}/v1/messages`: Anthropic's own,
20
+ # a gateway, or a local server such as Splash) on our own HTTP layer, for
21
+ # the same chat loop as OpenAIChat: ChatLoop's OpenAI-shaped wire
22
+ # messages are translated here (AnthropicWire), and the answer comes back
23
+ # as a ChatResponse whose provider_content holds the content blocks to
24
+ # send back verbatim (#replays_content?).
25
+ #
26
+ # A host's dialect (LLM::Dialect, `dialect:`) overrides parts of the
27
+ # standard wire: its default auth header, the thinking `display`, the
28
+ # cache breakpoints and a session-affinity header.
29
+ #
30
+ # Nothing here keys on a model id: fields are sent explicitly, read
31
+ # leniently, and a host's 400 for a field it refuses is what changes a
32
+ # request. Those 400s are handled here, once per request, and
33
+ # remembered for the adapter's life (a host's worker), so ChatLoop's
34
+ # broader refusal paths never see them:
35
+ # - adaptive thinking (or output_config.effort) refused: the model gets
36
+ # the budget form instead (#budget_form), per model;
37
+ # - thinking.display refused (a provider whose thinking schema has no
38
+ # such field, e.g. Fireworks without its dialect): no display for the
39
+ # host, the thinking itself kept;
40
+ # - cache_control refused (Fireworks lists it unsupported): no
41
+ # breakpoints for the host;
42
+ # - max_tokens over the model's output limit: the limit the message
43
+ # names, per model.
44
+ class AnthropicMessages
45
+ include ModelListing
46
+
47
+ API_VERSION = "2023-06-01"
48
+ DEFAULT_MODELS_TTL = 60
49
+ MAX_MODEL_PAGES = 20
50
+ # The budget form's budget_tokens per level (a model that takes no
51
+ # adaptive thinking), and Messages' smallest budget.
52
+ BUDGETS = { low: 2048, medium: 8192, high: 24_576 }.freeze
53
+ MIN_BUDGET = 1024
54
+ # A 400 refusing adaptive thinking or its effort.
55
+ ADAPTIVE_REFUSED_RE = /adaptive|output_config|effort/i
56
+ CACHE_CONTROL_RE = /cache_control/i
57
+ # A 400 refusing the thinking display field: its path (pydantic:
58
+ # "thinking.adaptive.display"), or the field named as unknown or not
59
+ # permitted. Not an error that only echoes the input
60
+ # (input_value={'type': 'adaptive', 'display': ...}).
61
+ DISPLAY_RE = /thinking\.[\w.]*\bdisplay\b|
62
+ (?:unknown|unexpected|unrecognized|extra|invalid)\s+(?:field|parameter|key|input)s?\W{0,4}display\b|
63
+ \bdisplay\W{0,4}(?:extra\s+inputs\s+are\s+not\s+permitted|is\s+not\s+(?:permitted|allowed|supported))/xi
64
+ # "max_tokens: 200000 > 128000, which is the maximum allowed number of output tokens for …"
65
+ 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
+ # max_tokens when default.max_tokens is unset and the model list names
69
+ # none (a listed one is capped by it too).
70
+ DEFAULT_MAX_TOKENS = 64_000
71
+ # chi's OpenAI-only request fields: never sent to a Messages host
72
+ # (configured sampling passes as written).
73
+ DROPPED_OPTIONS = %i[reasoning_effort chat_template_kwargs stream_options prompt_cache_key logprobs top_logprobs
74
+ max_completion_tokens].freeze
75
+
76
+ attr_reader :base_url, :host_name, :api_key_env, :models_ttl, :first_token_timeout, :dialect
77
+
78
+ # @param entry [HostRegistry::HostEntry]
79
+ def self.for(entry, **)
80
+ new(base_url: entry.api_base_url, host_name: entry.name, api_key_env: entry.api_key_env, auth: entry.auth,
81
+ dialect: entry.dialect, remote: entry.remote?, **)
82
+ end
83
+
84
+ # @param base_url [String] the API root without /v1 (HostEntry#api_base_url)
85
+ # @param auth [Symbol, nil] the header the key goes in (ApiKey::SCHEMES);
86
+ # nil: the dialect's (x-api-key on the standard one)
87
+ # @param dialect [String, nil] the host's dialect: name (Dialect.for)
88
+ # The rest as OpenAIChat#initialize takes them.
89
+ def initialize(base_url:, host_name:, api_key_env: nil, auth: nil, dialect: nil, stream: true, retries: true,
90
+ timeout: nil, env: ENV, sleeper: nil, retry_policy: nil, models_ttl: DEFAULT_MODELS_TTL, remote: false,
91
+ first_token_timeout: nil, purpose: "chat", open_timeout: nil)
92
+ @dialect = Dialect.for(dialect)
93
+ @remote = remote
94
+ @purpose = purpose
95
+ @first_token_timeout = first_token_timeout
96
+ @base_url = base_url.to_s.chomp("/")
97
+ @host_name = host_name.to_s
98
+ @api_key_env = api_key_env
99
+ @stream = stream
100
+ init_model_listing(models_ttl)
101
+ @thinking_form_refused = Set.new
102
+ @output_caps = {}
103
+ @cache_control_refused = false
104
+ @thinking_display_refused = false
105
+ open_timeout, read_timeout = timeouts(timeout, open_timeout)
106
+ policy = retry_policy || (retries ? nil : HTTP::RetryPolicy.none)
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))
110
+ end
111
+
112
+ # The same contract as OpenAIChat#chat; +messages+ are ChatLoop's
113
+ # OpenAI-shaped wire messages.
114
+ # @return [ChatResponse]
115
+ def chat(messages:, model:, tools: [], cancel_controller: nil, on_delta: nil, on_retry: nil, options: {},
116
+ session_id: nil)
117
+ fixed = []
118
+ begin
119
+ body = request_body(messages, tools, model, options)
120
+ request = post_request("#{@base_url}/v1/messages", body, session_id: session_id)
121
+ log_fields = { model: model, purpose: @purpose, sampling: sampling_summary(body), cache: cache_summary(body) }
122
+ return chat_once(request, cancel_controller, log_fields) unless @stream
123
+
124
+ chat_streamed(request, cancel_controller, on_delta, on_retry, log_fields)
125
+ rescue BadRequest => e
126
+ fix = refusal_fix(e, body, model)
127
+ raise if fix.nil? || fixed.include?(fix)
128
+
129
+ fixed << fix
130
+ retry
131
+ end
132
+ end
133
+
134
+ # A provider on the network: no llama.cpp /props to ask for the window.
135
+ def remote? = @remote
136
+
137
+ # The response's blocks go back verbatim (ChatLoop#wire_messages
138
+ # copies a model entry's anthropic_content onto its wire message).
139
+ def replays_content? = true
140
+
141
+ # true when a streamed event (as #chat's on_delta gets it) carries
142
+ # part of a tool call.
143
+ def tool_call_delta?(payload) = AnthropicAssembly.tool_call_delta?(payload)
144
+
145
+ # The request fields a thinking +level+ (Thinking::LEVELS) adds on this
146
+ # wire: default nothing (the provider's own default), off
147
+ # `thinking: disabled`, low/medium/high adaptive thinking at that
148
+ # effort, shown the dialect's way (summarized; no display field on a
149
+ # dialect that has none).
150
+ # @return [Hash] frozen
151
+ def thinking_fields(level)
152
+ case level
153
+ when :off then { thinking: { type: "disabled" }.freeze }.freeze
154
+ when *Thinking::EFFORTS
155
+ thinking = { type: "adaptive", display: @dialect.thinking_display }.compact.freeze
156
+ { thinking: thinking, output_config: { effort: level.to_s }.freeze }.freeze
157
+ else {}.freeze
158
+ end
159
+ end
160
+
161
+ # The thinking fields for a short side answer (a recap, ctx.ask_model,
162
+ # a triage) from a model that refused thinking off: an always-thinking
163
+ # model can't skip it, so it thinks at low effort (IdleClient leaves
164
+ # room for the answer).
165
+ # @return [Hash] frozen
166
+ def thinking_off_refused_fields = thinking_fields(:low)
167
+
168
+ # The host's models, every page: GET {base}/v1/models with
169
+ # limit=1000 and after_id paging (has_more/last_id). Anthropic's shape
170
+ # (max_input_tokens, max_tokens, capabilities) and the OpenAI shape a
171
+ # gateway or Splash serves here are both read (ModelListing).
172
+ # Whether +model+ gets the budget form (it refused adaptive thinking).
173
+ def budget_form?(model) = @thinking_form_refused.include?(model.to_s)
174
+
175
+ # @return [Array<ModelInfo>]
176
+ 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
189
+ end
190
+
191
+ private
192
+
193
+ # Anthropic lists the window as max_input_tokens; else the fields an
194
+ # OpenAI-shaped list carries.
195
+ def window_of(raw)
196
+ window = raw["max_input_tokens"]
197
+ window.is_a?(Integer) && window.positive? ? window : super
198
+ end
199
+
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
+ # The Messages body: model, max_tokens, the system blocks, the
210
+ # messages, the stream flag, the tools (tool_choice auto with them),
211
+ # then the options as Messages takes them (chi's OpenAI-only fields
212
+ # dropped; a null drops a field).
213
+ def request_body(messages, tools, model, options)
214
+ options = (options || {}).to_h.transform_keys(&:to_sym).except(*DROPPED_OPTIONS)
215
+ options = budget_form(options) if budget_form?(model)
216
+ options = without_display(options) if @thinking_display_refused
217
+ cache = PromptCache.control if @purpose == "chat" && @dialect.cache_breakpoints && !@cache_control_refused
218
+ translated = AnthropicWire.translate(messages, cache: cache)
219
+ max_tokens = options.delete(:max_tokens) || default_max_tokens(model)
220
+ max_tokens = [max_tokens, @output_caps[model.to_s]].min if @output_caps[model.to_s]
221
+ body = { model: model, max_tokens: max_tokens }
222
+ body[:system] = translated.system unless translated.system.empty?
223
+ body[:messages] = translated.messages
224
+ body[:stream] = @stream
225
+ unless Array(tools).empty?
226
+ body[:tools] = AnthropicWire.tools(tools)
227
+ body[:tool_choice] = { type: "auto" }
228
+ end
229
+ with_budget_room(body.merge(options).compact)
230
+ end
231
+
232
+ # Adaptive thinking at a level as the budget form: thinking enabled
233
+ # with BUDGETS' budget (medium without an effort), shown as asked (no
234
+ # display when it asked none), and no effort (a model without
235
+ # adaptive thinking may take none).
236
+ def budget_form(options)
237
+ thinking = options[:thinking].is_a?(Hash) ? options[:thinking].transform_keys(&:to_sym) : nil
238
+ return options unless thinking && thinking[:type].to_s == "adaptive"
239
+
240
+ config = options[:output_config].is_a?(Hash) ? options[:output_config].transform_keys(&:to_sym) : {}
241
+ budget = BUDGETS.fetch(config[:effort].to_s.to_sym, BUDGETS[:medium])
242
+ config = config.except(:effort)
243
+ options.merge(thinking: { type: "enabled", budget_tokens: budget, display: thinking[:display] }.compact,
244
+ output_config: config.empty? ? nil : config)
245
+ end
246
+
247
+ # +options+ with no thinking.display (the host refused the field).
248
+ def without_display(options)
249
+ thinking = options[:thinking]
250
+ return options unless thinking.is_a?(Hash)
251
+
252
+ options.merge(thinking: thinking.transform_keys(&:to_sym).except(:display))
253
+ end
254
+
255
+ # Messages wants max_tokens > budget_tokens (and a budget of at least
256
+ # MIN_BUDGET): a budget without that room is lowered to leave
257
+ # MIN_BUDGET for the answer; below MIN_BUDGET itself, max_tokens is
258
+ # raised to 2 * MIN_BUDGET with the smallest budget.
259
+ def with_budget_room(body)
260
+ thinking = body[:thinking]
261
+ return body unless thinking.is_a?(Hash) && thinking[:budget_tokens].is_a?(Integer)
262
+
263
+ budget = thinking[:budget_tokens]
264
+ max_tokens = body[:max_tokens].to_i
265
+ return body if max_tokens > budget + MIN_BUDGET
266
+
267
+ budget = max_tokens - MIN_BUDGET
268
+ if budget < MIN_BUDGET
269
+ budget = MIN_BUDGET
270
+ max_tokens = 2 * MIN_BUDGET
271
+ end
272
+ body.merge(max_tokens: max_tokens, thinking: thinking.merge(budget_tokens: budget))
273
+ end
274
+
275
+ # What a 400 for +body+ changes (remembered), or nil: the error goes
276
+ # on as it is.
277
+ def refusal_fix(error, body, model)
278
+ return nil if error.context_overflow? || error.status != 400 || body.nil?
279
+ # Those are the loop's (a sampling field) or P3's (signatures,
280
+ # binding controls): a mention of "effort" in them is no form refusal.
281
+ return nil if error.sampling_refused? || error.thinking_signature? || error.thinking_binding_unsupported?
282
+
283
+ detail = error.detail.to_s
284
+ # Before the adaptive check: the refused field's path
285
+ # ("thinking.adaptive.display") names adaptive too.
286
+ if !@thinking_display_refused && body[:thinking].is_a?(Hash) && body[:thinking].key?(:display) &&
287
+ DISPLAY_RE.match?(detail)
288
+ @thinking_display_refused = true
289
+ Log.info(:http, "thinking_display_refused", host: @host_name, detail: detail[0, 200])
290
+ :thinking_display
291
+ elsif body.dig(:thinking, :type) == "adaptive" && ADAPTIVE_REFUSED_RE.match?(detail)
292
+ @thinking_form_refused << model.to_s
293
+ Log.info(:http, "thinking_form_switched", host: @host_name, model: model.to_s, to: "budget", detail: detail[0, 200])
294
+ :thinking_form
295
+ elsif !@cache_control_refused && CACHE_CONTROL_RE.match?(detail) && cache_summary(body)
296
+ @cache_control_refused = true
297
+ Log.info(:http, "cache_control_refused", host: @host_name, detail: detail[0, 200])
298
+ :cache_control
299
+ elsif (cap = detail[OUTPUT_CAP_RE, 1]&.to_i) && cap.positive? && body[:max_tokens].to_i > cap
300
+ @output_caps[model.to_s] = cap
301
+ Log.info(:http, "output_cap", host: @host_name, model: model.to_s, max_tokens: cap)
302
+ :max_tokens
303
+ end
304
+ end
305
+
306
+ # The model list's max_tokens for +model+ (capped), else the cap.
307
+ def default_max_tokens(model)
308
+ listed = listed_model(model)&.raw&.dig("max_tokens")
309
+ listed.is_a?(Integer) && listed.positive? ? [listed, DEFAULT_MAX_TOKENS].min : DEFAULT_MAX_TOKENS
310
+ rescue StandardError
311
+ DEFAULT_MAX_TOKENS
312
+ end
313
+
314
+ # The log line's cache= : on for breakpoints with the default TTL, 1h
315
+ # for cache.ttl 1h, nil for none.
316
+ def cache_summary(body)
317
+ controls = [*Array(body[:system]), *Array(body[:messages]).flat_map { |m| Array(m[:content]) }]
318
+ .filter_map { |block| block.is_a?(Hash) && (block[:cache_control] || block["cache_control"]) }
319
+ return nil if controls.empty?
320
+
321
+ controls.any? { |control| control[:ttl] == "1h" } ? "1h" : "on"
322
+ end
323
+
324
+ def sampling_summary(body)
325
+ SamplingSettings.log_text(body.except(:model, :system, :messages, :stream, :tools, :tool_choice))
326
+ end
327
+
328
+ def chat_once(request, cancel_controller, log_fields)
329
+ response = @http.fetch(URI(request.uri.to_s), request, cancel_controller: cancel_controller, log_fields: log_fields)
330
+ body = parse_json(response.body, "chat response")
331
+ error = ProviderErrors.from_error_body(response.body.to_s, host: @host_name)
332
+ raise error if error
333
+ raise ProtocolError.new("#{@host_name}: no content in the Messages response", host: @host_name) unless message?(body)
334
+
335
+ AnthropicAssembly.from_message(body).response
336
+ end
337
+
338
+ def message?(body) = body.is_a?(Hash) && body["content"].is_a?(Array)
339
+
340
+ # The SSE stream: each `data:` event goes through the assembly (the
341
+ # `event:` lines are not needed, so a gateway that drops them works);
342
+ # on_delta gets every event but pings, with its text and reasoning.
343
+ def chat_streamed(request, cancel_controller, on_delta, on_retry, log_fields)
344
+ assembly = AnthropicAssembly.new
345
+ events = 0
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
+
362
+ content, reasoning = assembly.add(event)
363
+ shown.call if AnthropicAssembly.shows?(event)
364
+ on_delta&.call(content: content, reasoning: reasoning, payload: assembly.with_usage(event)) unless event["type"] == "ping"
365
+ 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
+ end
402
+
403
+ def post_request(url, body, session_id: nil)
404
+ Net::HTTP::Post.new(URI(url)).tap do |request|
405
+ request["Content-Type"] = "application/json"
406
+ request["anthropic-version"] = API_VERSION
407
+ unless session_id.to_s.empty?
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)
412
+ end
413
+ end
414
+ end
415
+ end
416
+ end
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require_relative "../log"
6
+ require_relative "utf8_scrub"
7
+
8
+ module Samagotchi
9
+ module LLM
10
+ # ChatLoop's wire messages (the OpenAI shape #wire_messages builds) as a
11
+ # Messages request's top-level system blocks and messages
12
+ # (AnthropicMessages):
13
+ # - the head system message → the top-level system, as text blocks: with
14
+ # its cache_split, two blocks, the breakpoint on the first; a later
15
+ # system message → user text (a mid-conversation system role is
16
+ # model-gated);
17
+ # - an assistant message → its saved blocks verbatim (anthropic_content),
18
+ # else a text block and its tool_use blocks (arguments as the input
19
+ # object); one with neither is dropped;
20
+ # - tool messages → tool_result blocks in a user message;
21
+ # - user parts → text blocks and image blocks (a data URL as base64, an
22
+ # http(s) URL as a url source).
23
+ # Consecutive messages of one role go as one message (so a turn's tool
24
+ # results and the "[images from tool results]" message after them make
25
+ # one user message), its tool_result blocks first (Messages wants them
26
+ # before any other block). Empty or blank text blocks are never
27
+ # sent; a user message left with none says PLACEHOLDER. reasoning_content
28
+ # and cache_split never go out.
29
+ module AnthropicWire
30
+ PLACEHOLDER = "(empty)"
31
+ # What Messages takes as a tool_use id.
32
+ TOOL_ID_INVALID = /[^a-zA-Z0-9_-]/
33
+ # What Messages takes as a tool name.
34
+ TOOL_NAME = /\A[a-zA-Z0-9_-]{1,128}\z/
35
+ # Blocks that can't carry a cache_control breakpoint.
36
+ UNMARKABLE = %w[thinking redacted_thinking].freeze
37
+
38
+ # A request's system blocks and messages.
39
+ Translated = Data.define(:system, :messages)
40
+
41
+ module_function
42
+
43
+ # @param wire [Array<Hash>] ChatLoop's wire messages (symbol or string keys)
44
+ # @param cache [Hash, nil] the cache_control the breakpoints carry
45
+ # (PromptCache.control): on the head system's first block and on the
46
+ # last block that didn't come from a later system message (a tail
47
+ # note moves each request, as PromptCache's rule says); nil sets none
48
+ # @return [Translated]
49
+ def translate(wire, cache: nil)
50
+ system = []
51
+ messages = []
52
+ notes = {}.compare_by_identity
53
+ Array(wire).each_with_index do |raw, index|
54
+ message = raw.to_h.transform_keys(&:to_sym)
55
+ role = message[:role].to_s
56
+ if role == "system" && index.zero?
57
+ system = system_blocks(message[:content], message[:cache_split], cache)
58
+ next
59
+ end
60
+
61
+ case role
62
+ when "assistant" then append(messages, "assistant", assistant_blocks(message))
63
+ when "tool" then append(messages, "user", [tool_result(message)])
64
+ when "system"
65
+ blocks = content_blocks(message[:content])
66
+ blocks.each { |block| notes[block] = true }
67
+ append(messages, "user", blocks)
68
+ else append(messages, "user", content_blocks(message[:content]))
69
+ end
70
+ end
71
+ messages.each { |message| message[:content] << text_block(PLACEHOLDER) if message[:content].empty? }
72
+ mark_last(messages, cache, notes) if cache
73
+ Translated.new(system: system, messages: messages)
74
+ end
75
+
76
+ # ChatLoop's function definitions as Messages tools.
77
+ # @param tools [Array<Hash>] {type: "function", function: {name:, description:, parameters:}}
78
+ def tools(tools)
79
+ Array(tools).map do |tool|
80
+ function = (tool[:function] || tool["function"] || tool).to_h.transform_keys(&:to_sym)
81
+ { name: function[:name].to_s, description: function[:description],
82
+ input_schema: function[:parameters] || { type: "object", properties: {} } }.compact
83
+ end
84
+ end
85
+
86
+ # +id+ as Messages takes it (^[a-zA-Z0-9_-]+$), the same in tool_use
87
+ # and tool_result: a valid id as it is, another with its invalid
88
+ # characters replaced and a short hash of it appended (so "a.b" and
89
+ # "a_b" stay apart).
90
+ def tool_id(id)
91
+ text = id.to_s
92
+ return text if !text.empty? && !text.match?(TOOL_ID_INVALID)
93
+
94
+ "#{text.gsub(TOOL_ID_INVALID, "_")}_#{Digest::SHA256.hexdigest(text)[0, 8]}"
95
+ end
96
+
97
+ # A tool name as Messages takes it (TOOL_NAME): a model's malformed one
98
+ # ("get_weather\n</parameter", a streaming glitch) would 400 every
99
+ # later request once sent back.
100
+ def tool_name(name)
101
+ text = name.to_s
102
+ return text if text.match?(TOOL_NAME)
103
+
104
+ text = text.gsub(TOOL_ID_INVALID, "_")[0, 128]
105
+ text.empty? ? "tool" : text
106
+ end
107
+
108
+ # A message joins the one before it when the role is the same; an
109
+ # assistant message with no blocks is dropped.
110
+ def append(messages, role, blocks)
111
+ return if role == "assistant" && blocks.empty?
112
+
113
+ if messages.last && messages.last[:role] == role
114
+ content = messages.last[:content].concat(blocks)
115
+ results, rest = content.partition { |block| type_of(block) == "tool_result" }
116
+ messages.last[:content] = results + rest
117
+ else
118
+ messages << { role: role, content: blocks }
119
+ end
120
+ end
121
+
122
+ def system_blocks(content, split, cache)
123
+ text = scrub(content.to_s)
124
+ return [] if blank?(text)
125
+
126
+ head = split.is_a?(Integer) && split.positive? && split < text.length ? text[0, split] : text
127
+ tail = head.length < text.length ? text[head.length..] : ""
128
+ first = text_block(head)
129
+ first[:cache_control] = cache if cache
130
+ blank?(tail) ? [first] : [first, text_block(tail)]
131
+ end
132
+
133
+ # The saved blocks go back as they came (string keys); otherwise the
134
+ # text, then the calls.
135
+ def assistant_blocks(message)
136
+ saved = message[:anthropic_content]
137
+ return saved.map { |block| valid_tool_use(block) } if saved.is_a?(Array) && !saved.empty?
138
+
139
+ blocks = content_blocks(message[:content])
140
+ Array(message[:tool_calls]).each do |raw|
141
+ call = raw.to_h.transform_keys(&:to_sym)
142
+ function = (call[:function] || {}).to_h.transform_keys(&:to_sym)
143
+ name = function[:name].to_s
144
+ blocks << { type: "tool_use", id: tool_id(call[:id]), name: tool_name(name), input: input_of(function[:arguments], name) }
145
+ end
146
+ blocks
147
+ end
148
+
149
+ # A saved block as it came, but a tool_use's name made valid and its
150
+ # id mapped as its tool_result's is (#tool_id: a provider may answer
151
+ # an id such as "functions.get_weather:0", which Messages refuses
152
+ # sent back, and the result would name another id).
153
+ def valid_tool_use(block)
154
+ return block.dup unless block.is_a?(Hash) && type_of(block) == "tool_use"
155
+
156
+ name_key = block.key?("name") ? "name" : :name
157
+ id_key = block.key?("id") ? "id" : :id
158
+ block.merge(name_key => tool_name(block[name_key]), id_key => tool_id(block[id_key]))
159
+ end
160
+
161
+ # A tool call's arguments as the input object: ChatLoop sends a JSON
162
+ # String (raw when it was invalid, for a paired call): one that isn't
163
+ # an object goes as {}.
164
+ def input_of(arguments, name)
165
+ return arguments if arguments.is_a?(Hash)
166
+
167
+ text = arguments.to_s
168
+ return {} if text.strip.empty?
169
+
170
+ parsed = JSON.parse(text)
171
+ return parsed if parsed.is_a?(Hash)
172
+
173
+ warn_arguments(name)
174
+ rescue JSON::ParserError
175
+ warn_arguments(name)
176
+ end
177
+
178
+ def warn_arguments(name)
179
+ Log.warn(:model, "tool_arguments_invalid", tool: name, detail: "sent as {} to a Messages host")
180
+ {}
181
+ end
182
+
183
+ def tool_result(message)
184
+ block = { type: "tool_result", tool_use_id: tool_id(message[:tool_call_id]) }
185
+ text = scrub(message[:content].to_s)
186
+ block[:content] = text unless blank?(text)
187
+ block
188
+ end
189
+
190
+ # A String or a parts list as blocks.
191
+ def content_blocks(content)
192
+ return blank?(content.to_s) ? [] : [text_block(scrub(content.to_s))] unless content.is_a?(Array)
193
+
194
+ content.filter_map do |raw|
195
+ next unless raw.is_a?(Hash)
196
+
197
+ part = raw.transform_keys(&:to_sym)
198
+ case part[:type].to_s
199
+ when "text" then blank?(part[:text].to_s) ? nil : text_block(scrub(part[:text].to_s))
200
+ when "image_url" then image_block(part[:image_url])
201
+ when "image" then raw
202
+ end
203
+ end
204
+ end
205
+
206
+ def image_block(image_url)
207
+ url = image_url.is_a?(Hash) ? (image_url[:url] || image_url["url"]).to_s : image_url.to_s
208
+ if (match = url.match(/\Adata:([^;,]+);base64,(.*)\z/m))
209
+ { type: "image", source: { type: "base64", media_type: match[1], data: match[2] } }
210
+ elsif url.match?(%r{\Ahttps?://}i)
211
+ { type: "image", source: { type: "url", url: url } }
212
+ end
213
+ end
214
+
215
+ # Breakpoint #2: the last user block that can carry one and didn't
216
+ # come from a later system message (+notes+).
217
+ def mark_last(messages, cache, notes)
218
+ markable = ->(block) { !UNMARKABLE.include?(type_of(block)) && !notes.key?(block) }
219
+ message = messages.reverse_each.find { |m| m[:role] == "user" && m[:content].any?(&markable) } or return
220
+ index = message[:content].rindex(&markable)
221
+ block = message[:content][index]
222
+ key = block.key?("type") ? "cache_control" : :cache_control
223
+ message[:content][index] = block.merge(key => cache)
224
+ end
225
+
226
+ def type_of(block) = (block[:type] || block["type"]).to_s
227
+
228
+ def text_block(text) = { type: "text", text: text }
229
+
230
+ def blank?(text) = text.strip.empty?
231
+
232
+ def scrub(text) = Utf8Scrub.call(text)
233
+
234
+ private_class_method :append, :system_blocks, :assistant_blocks, :input_of, :warn_arguments, :tool_result,
235
+ :content_blocks, :image_block, :mark_last, :valid_tool_use, :type_of, :text_block, :blank?, :scrub
236
+ end
237
+ end
238
+ end