samagotchi 0.64.0 → 0.65.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 +41 -1
- data/docs/architecture.md +20 -3
- data/docs/cli.md +25 -1
- data/docs/configuration.md +128 -3
- data/docs/internals/engine.md +15 -3
- data/docs/sessions.md +3 -2
- data/lib/samagotchi/bridge.rb +11 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/client.rb +4 -2
- data/lib/samagotchi/config.rb +5 -4
- data/lib/samagotchi/engine.rb +39 -7
- 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/llm/adapters.rb +41 -0
- data/lib/samagotchi/llm/anthropic_assembly.rb +210 -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/reasoning_history.rb +14 -4
- data/lib/samagotchi/recap_setup.rb +2 -1
- data/lib/samagotchi/self_report.rb +18 -28
- data/lib/samagotchi/session.rb +3 -2
- data/lib/samagotchi/session_metrics.rb +6 -11
- data/lib/samagotchi/steer_cut.rb +4 -0
- 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/turn_note.rb +22 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -1
- data/lib/samagotchi/web/public/activity.js +3 -0
- data/lib/samagotchi/web/public/app.js +90 -13
- data/lib/samagotchi/web/public/delivery_line.js +228 -0
- data/lib/samagotchi/web/public/index.html +29 -0
- data/lib/samagotchi/web/public/live_turn.js +8 -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 +8 -2
- 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
- metadata +8 -1
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "openai_chat"
|
|
4
|
+
require_relative "usage"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
module LLM
|
|
8
|
+
# A Messages response put together as ChatResponse, from a whole body
|
|
9
|
+
# (.from_message) or a stream's events (#add, by content-block index):
|
|
10
|
+
# the content blocks
|
|
11
|
+
# in order (kept as received, string keys: ChatResponse#provider_content,
|
|
12
|
+
# which ChatLoop saves and sends back), the text and reasoning they
|
|
13
|
+
# carry, the tool calls, the usage and the stop reason in the chat
|
|
14
|
+
# loop's terms.
|
|
15
|
+
#
|
|
16
|
+
# Usage: Messages counts input_tokens without the cache, so the prompt
|
|
17
|
+
# is input + cache reads + cache writes, in the OpenAI shape the rest of
|
|
18
|
+
# chi reads (TokenUsage: prompt_tokens_details.cached_tokens /
|
|
19
|
+
# cache_write_tokens). Every field is optional (Splash sends no cache
|
|
20
|
+
# writes), and a later value wins per field.
|
|
21
|
+
class AnthropicAssembly
|
|
22
|
+
# stop_reason → ChatResponse#finish_reason (the chat loop's words);
|
|
23
|
+
# pause_turn and anything else pass as they are.
|
|
24
|
+
FINISH_REASONS = {
|
|
25
|
+
"end_turn" => "stop", "stop_sequence" => "stop", "max_tokens" => "length",
|
|
26
|
+
"model_context_window_exceeded" => "length", "tool_use" => "tool_calls", "refusal" => "refusal"
|
|
27
|
+
}.freeze
|
|
28
|
+
USAGE_KEYS = %w[input_tokens output_tokens cache_read_input_tokens cache_creation_input_tokens].freeze
|
|
29
|
+
# Blocks whose text is the answer or the reasoning.
|
|
30
|
+
THINKING = "thinking"
|
|
31
|
+
TEXT = "text"
|
|
32
|
+
|
|
33
|
+
def self.finish_reason(stop_reason) = stop_reason && FINISH_REASONS.fetch(stop_reason.to_s, stop_reason.to_s)
|
|
34
|
+
|
|
35
|
+
# A non-streamed body (a `message` object).
|
|
36
|
+
def self.from_message(body) = new.tap { |assembly| assembly.add_message(body) }
|
|
37
|
+
|
|
38
|
+
def initialize
|
|
39
|
+
@blocks = []
|
|
40
|
+
# A streamed block by its index, and a tool_use's input JSON so far.
|
|
41
|
+
@by_index = {}
|
|
42
|
+
@json = {}
|
|
43
|
+
@usage = {}
|
|
44
|
+
@stop_reason = nil
|
|
45
|
+
@stop_details = nil
|
|
46
|
+
@model = nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A whole message: its model, usage, stop reason and blocks.
|
|
50
|
+
def add_message(message)
|
|
51
|
+
return unless message.is_a?(Hash)
|
|
52
|
+
|
|
53
|
+
@model = served(message["model"]) || @model
|
|
54
|
+
merge_usage(message["usage"])
|
|
55
|
+
@stop_reason = message["stop_reason"] if message["stop_reason"]
|
|
56
|
+
@stop_details = message["stop_details"] if message["stop_details"].is_a?(Hash)
|
|
57
|
+
Array(message["content"]).each { |block| @blocks << block.dup if block.is_a?(Hash) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# One streamed event: message_start (served model, usage),
|
|
61
|
+
# content_block_start (text / thinking / redacted_thinking / tool_use),
|
|
62
|
+
# content_block_delta (text_delta, thinking_delta, signature_delta,
|
|
63
|
+
# input_json_delta), content_block_stop, message_delta (stop reason,
|
|
64
|
+
# stop_details, usage); ping, message_stop and anything else change nothing.
|
|
65
|
+
# @return [Array(String, String)] the event's text and reasoning
|
|
66
|
+
def add(event)
|
|
67
|
+
case event["type"]
|
|
68
|
+
when "message_start" then start_message(event["message"])
|
|
69
|
+
when "content_block_start" then start_block(event["index"], event["content_block"])
|
|
70
|
+
when "content_block_delta" then add_delta(@by_index[event["index"]], event["index"], event["delta"])
|
|
71
|
+
when "message_delta"
|
|
72
|
+
if event["delta"].is_a?(Hash)
|
|
73
|
+
@stop_reason = event.dig("delta", "stop_reason") || @stop_reason
|
|
74
|
+
@stop_details = event.dig("delta", "stop_details") if event.dig("delta", "stop_details").is_a?(Hash)
|
|
75
|
+
end
|
|
76
|
+
merge_usage(event["usage"])
|
|
77
|
+
["", ""]
|
|
78
|
+
else ["", ""]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Whether a streamed +event+ shows something (a block started, a delta):
|
|
83
|
+
# what the first-token limit waits for.
|
|
84
|
+
def self.shows?(event) = %w[content_block_start content_block_delta].include?(event["type"])
|
|
85
|
+
|
|
86
|
+
# Whether a streamed +event+ carries part of a tool call.
|
|
87
|
+
def self.tool_call_delta?(event)
|
|
88
|
+
case event["type"]
|
|
89
|
+
when "content_block_start" then event.dig("content_block", "type") == "tool_use"
|
|
90
|
+
when "content_block_delta" then event.dig("delta", "type") == "input_json_delta"
|
|
91
|
+
else false
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# +event+ as on_delta passes it on: one that carries usage gets the
|
|
96
|
+
# usage so far in the OpenAI shape as its `usage` (SessionMetrics,
|
|
97
|
+
# ContextStatus and ctx.js read that).
|
|
98
|
+
def with_usage(event)
|
|
99
|
+
return event unless %w[message_start message_delta].include?(event["type"]) && (payload = usage_payload)
|
|
100
|
+
|
|
101
|
+
event.merge("usage" => payload)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The usage so far as an OpenAI-shaped `usage` object, nil when the
|
|
105
|
+
# server sent no counts.
|
|
106
|
+
def usage_payload
|
|
107
|
+
return nil if @usage.empty?
|
|
108
|
+
|
|
109
|
+
read = @usage["cache_read_input_tokens"].to_i
|
|
110
|
+
write = @usage["cache_creation_input_tokens"].to_i
|
|
111
|
+
{ "prompt_tokens" => @usage["input_tokens"].to_i + read + write, "completion_tokens" => @usage["output_tokens"].to_i,
|
|
112
|
+
"prompt_tokens_details" => { "cached_tokens" => read, "cache_write_tokens" => write } }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def response
|
|
116
|
+
blocks = content
|
|
117
|
+
ChatResponse.new(text: joined(blocks, TEXT, "text"), reasoning: joined(blocks, THINKING, "thinking"),
|
|
118
|
+
tool_calls: tool_calls, usage: usage, finish_reason: self.class.finish_reason(@stop_reason),
|
|
119
|
+
model: @model, provider_content: blocks, stop_details: @stop_details)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def usage
|
|
125
|
+
payload = usage_payload
|
|
126
|
+
(payload && Usage.from_payload({ "usage" => payload })) || Usage.none
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def start_message(message)
|
|
130
|
+
return ["", ""] unless message.is_a?(Hash)
|
|
131
|
+
|
|
132
|
+
@model = served(message["model"]) || @model
|
|
133
|
+
merge_usage(message["usage"])
|
|
134
|
+
["", ""]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def start_block(index, block)
|
|
138
|
+
return ["", ""] unless block.is_a?(Hash)
|
|
139
|
+
|
|
140
|
+
block = block.dup
|
|
141
|
+
@blocks << block
|
|
142
|
+
@by_index[index] = block
|
|
143
|
+
case block["type"]
|
|
144
|
+
when TEXT then [block["text"].to_s, ""]
|
|
145
|
+
when THINKING then ["", block["thinking"].to_s]
|
|
146
|
+
else ["", ""]
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def add_delta(block, index, delta)
|
|
151
|
+
return ["", ""] unless block && delta.is_a?(Hash)
|
|
152
|
+
|
|
153
|
+
case delta["type"]
|
|
154
|
+
when "text_delta"
|
|
155
|
+
block["text"] = block["text"].to_s + delta["text"].to_s
|
|
156
|
+
[delta["text"].to_s, ""]
|
|
157
|
+
when "thinking_delta"
|
|
158
|
+
block["thinking"] = block["thinking"].to_s + delta["thinking"].to_s
|
|
159
|
+
["", delta["thinking"].to_s]
|
|
160
|
+
when "signature_delta"
|
|
161
|
+
block["signature"] = block["signature"].to_s + delta["signature"].to_s
|
|
162
|
+
["", ""]
|
|
163
|
+
when "input_json_delta"
|
|
164
|
+
(@json[index] ||= +"") << delta["partial_json"].to_s
|
|
165
|
+
block["input"] = @json[index]
|
|
166
|
+
["", ""]
|
|
167
|
+
else ["", ""]
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def merge_usage(usage)
|
|
172
|
+
return unless usage.is_a?(Hash)
|
|
173
|
+
|
|
174
|
+
USAGE_KEYS.each { |key| @usage[key] = usage[key] if usage[key].is_a?(Integer) }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The blocks to keep and send back: an empty or blank text block is
|
|
178
|
+
# no text (Splash opens one after a thinking cut by max_tokens, and
|
|
179
|
+
# Messages refuses one sent back), a tool_use's input is an object,
|
|
180
|
+
# and a thinking block has a signature, "" when it came without one
|
|
181
|
+
# (a sent-back thinking block needs the key; Splash sends "", a
|
|
182
|
+
# provider may send none).
|
|
183
|
+
def content
|
|
184
|
+
@blocks.filter_map do |block|
|
|
185
|
+
type = block["type"].to_s
|
|
186
|
+
next if type == TEXT && block["text"].to_s.strip.empty?
|
|
187
|
+
next block.merge("signature" => "") if type == THINKING && !block.key?("signature")
|
|
188
|
+
next block unless type == "tool_use"
|
|
189
|
+
|
|
190
|
+
input = OpenAIChat.parse_arguments(block["input"])
|
|
191
|
+
block.merge("input" => input.is_a?(Hash) ? input : {})
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def joined(blocks, type, key)
|
|
196
|
+
blocks.select { |block| block["type"] == type }.map { |block| block[key].to_s }.join
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# The calls with their arguments as OpenAIChat.parse_arguments reads
|
|
200
|
+
# them: invalid JSON stays a String, so the tool reports it.
|
|
201
|
+
def tool_calls
|
|
202
|
+
@blocks.select { |block| block["type"] == "tool_use" }.map do |block|
|
|
203
|
+
ToolCall.new(id: block["id"], name: block["name"].to_s, arguments: OpenAIChat.parse_arguments(block["input"]))
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def served(model) = model.is_a?(String) && !model.strip.empty? ? model : nil
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
@@ -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
|