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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +88 -1
- data/README.md +2 -1
- data/bin/chi +51 -2
- data/docs/architecture.md +20 -3
- data/docs/cli.md +36 -2
- data/docs/configuration.md +177 -8
- data/docs/context.md +4 -1
- data/docs/internals/engine.md +19 -4
- data/docs/internals/prompt-caching.md +10 -2
- data/docs/memory.md +1 -1
- data/docs/plugins.md +163 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +13 -2
- data/docs/sub-agent.md +3 -1
- data/lib/samagotchi/bridge.rb +11 -1
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +18 -1
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +21 -0
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +464 -0
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +131 -0
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
- data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +68 -0
- data/lib/samagotchi/bundles/coordinator/manifest.yml +5 -5
- data/lib/samagotchi/bundles/coordinator/plugin.rb +24 -2
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/delegated.md +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/cache_confirm.rb +9 -6
- data/lib/samagotchi/client.rb +4 -2
- data/lib/samagotchi/commands/registry.rb +25 -8
- data/lib/samagotchi/config.rb +13 -5
- data/lib/samagotchi/engine.rb +151 -11
- data/lib/samagotchi/generation_phase.rb +17 -0
- data/lib/samagotchi/host_config.rb +27 -6
- data/lib/samagotchi/host_registry.rb +34 -11
- data/lib/samagotchi/idle_client.rb +62 -25
- data/lib/samagotchi/idle_recap.rb +5 -2
- data/lib/samagotchi/idle_target.rb +30 -7
- data/lib/samagotchi/kernel_loop.rb +9 -1
- data/lib/samagotchi/llm/adapters.rb +41 -0
- data/lib/samagotchi/llm/anthropic_assembly.rb +212 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
- data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
- data/lib/samagotchi/llm/api_key.rb +26 -9
- data/lib/samagotchi/llm/chat_loop.rb +113 -14
- data/lib/samagotchi/llm/dialect.rb +57 -0
- data/lib/samagotchi/llm/errors.rb +81 -12
- data/lib/samagotchi/llm/model_listing.rb +111 -0
- data/lib/samagotchi/llm/model_result.rb +9 -2
- data/lib/samagotchi/llm/openai_chat.rb +46 -93
- data/lib/samagotchi/model_profile.rb +1 -1
- data/lib/samagotchi/plugin/api.rb +80 -7
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/loader.rb +3 -2
- data/lib/samagotchi/reasoning_history.rb +14 -4
- data/lib/samagotchi/recap_setup.rb +2 -1
- data/lib/samagotchi/self_report.rb +18 -28
- data/lib/samagotchi/send_command.rb +23 -7
- data/lib/samagotchi/session.rb +21 -3
- data/lib/samagotchi/session_commands.rb +16 -1
- data/lib/samagotchi/session_manager.rb +4 -1
- data/lib/samagotchi/session_metrics.rb +6 -11
- data/lib/samagotchi/session_setup.rb +14 -7
- data/lib/samagotchi/steer_cut.rb +4 -0
- data/lib/samagotchi/system_prompt.rb +16 -1
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
- data/lib/samagotchi/terminal_ui/attached_loop.rb +8 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +4 -3
- data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
- data/lib/samagotchi/terminal_ui.rb +18 -2
- data/lib/samagotchi/tool_runner.rb +13 -1
- data/lib/samagotchi/toolkit.rb +246 -0
- data/lib/samagotchi/toolkit_command.rb +117 -0
- data/lib/samagotchi/tools/registry.rb +43 -6
- data/lib/samagotchi/turn_note.rb +22 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +14 -1
- data/lib/samagotchi/web/public/activity.js +3 -0
- data/lib/samagotchi/web/public/annotate_actions.js +109 -0
- data/lib/samagotchi/web/public/annotate_bar.js +84 -2
- data/lib/samagotchi/web/public/app.js +111 -20
- data/lib/samagotchi/web/public/commands.js +4 -2
- data/lib/samagotchi/web/public/delivery_line.js +228 -0
- data/lib/samagotchi/web/public/index.html +38 -0
- data/lib/samagotchi/web/public/info_bar.js +5 -3
- data/lib/samagotchi/web/public/live_turn.js +8 -0
- data/lib/samagotchi/web/public/session_state.js +3 -0
- data/lib/samagotchi/web/public/stage_model.js +27 -7
- data/lib/samagotchi/web/public/stage_view.js +32 -10
- data/lib/samagotchi/web/public/stream_handlers.js +21 -2
- data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
- data/lib/samagotchi/web/public/turn_events.js +22 -8
- data/lib/samagotchi/web/public/turn_html.js +20 -4
- data/lib/samagotchi/web/public/turn_model.js +16 -9
- data/lib/samagotchi/web/public/turn_view.js +6 -4
- data/lib/samagotchi/worker.rb +2 -0
- 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
|