squishling 0.4.0 → 0.5.1
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 +149 -0
- data/README.md +26 -10
- data/docs/adapters.md +257 -0
- data/docs/configuration.md +54 -19
- data/docs/failures.md +170 -22
- data/docs/harnesses.md +39 -15
- data/docs/measuring-tokens.md +12 -6
- data/docs/observers.md +3 -3
- data/docs/routing.md +14 -3
- data/docs/schemas.md +24 -2
- data/docs/testing.md +187 -0
- data/docs/verifying.md +86 -0
- data/lib/squishling/adapter/error_body.rb +54 -0
- data/lib/squishling/adapter.rb +328 -0
- data/lib/squishling/adapters/openai_compatible.rb +311 -0
- data/lib/squishling/adapters/openai_options.rb +114 -0
- data/lib/squishling/adapters/openai_transport.rb +123 -0
- data/lib/squishling/adapters/ruby_llm.rb +229 -0
- data/lib/squishling/budget.rb +71 -0
- data/lib/squishling/class_methods.rb +43 -7
- data/lib/squishling/class_registry.rb +63 -0
- data/lib/squishling/configuration.rb +58 -6
- data/lib/squishling/definition.rb +90 -16
- data/lib/squishling/errors.rb +94 -7
- data/lib/squishling/harness/judged_squishsum.rb +2 -2
- data/lib/squishling/harness/option_types.rb +2 -1
- data/lib/squishling/harness/registry.rb +8 -36
- data/lib/squishling/invoker.rb +44 -14
- data/lib/squishling/judge.rb +16 -3
- data/lib/squishling/llm_client.rb +66 -132
- data/lib/squishling/model_path.rb +60 -13
- data/lib/squishling/named_registry.rb +64 -0
- data/lib/squishling/observer.rb +13 -4
- data/lib/squishling/output_check.rb +13 -9
- data/lib/squishling/params.rb +7 -24
- data/lib/squishling/railtie.rb +10 -0
- data/lib/squishling/result.rb +50 -16
- data/lib/squishling/rotation.rb +5 -0
- data/lib/squishling/router.rb +3 -2
- data/lib/squishling/rspec.rb +18 -0
- data/lib/squishling/run/messages.rb +40 -3
- data/lib/squishling/run/rejections.rb +43 -0
- data/lib/squishling/run/reports.rb +63 -0
- data/lib/squishling/run.rb +60 -53
- data/lib/squishling/schema.rb +50 -9
- data/lib/squishling/secret.rb +45 -0
- data/lib/squishling/squawk.rb +37 -4
- data/lib/squishling/tasks/squishling.rake +18 -0
- data/lib/squishling/testing/adapter.rb +194 -0
- data/lib/squishling/testing/checks.rb +39 -0
- data/lib/squishling/testing/direct.rb +41 -0
- data/lib/squishling/testing/errors.rb +18 -0
- data/lib/squishling/testing/fixtures.rb +206 -0
- data/lib/squishling/testing/helpers.rb +71 -0
- data/lib/squishling/testing/minitest.rb +32 -0
- data/lib/squishling/testing/observer.rb +31 -0
- data/lib/squishling/testing/reply.rb +60 -0
- data/lib/squishling/testing/request.rb +75 -0
- data/lib/squishling/testing/rspec.rb +30 -0
- data/lib/squishling/testing/script.rb +189 -0
- data/lib/squishling/testing/state.rb +74 -0
- data/lib/squishling/testing/stubs.rb +112 -0
- data/lib/squishling/testing.rb +126 -0
- data/lib/squishling/verification.rb +39 -0
- data/lib/squishling/verifier.rb +179 -0
- data/lib/squishling/version.rb +1 -1
- data/lib/squishling/wrapper.rb +19 -1
- data/lib/squishling.rb +60 -4
- metadata +43 -21
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
# How Squishling reaches a model. An adapter is the only code that talks to a provider: Squishling builds a
|
|
5
|
+
# Request (the system prompt, the conversation so far, the strict output schema, the resolved params), the adapter
|
|
6
|
+
# makes one completion and returns a Response. Squishling owns the conversation, the validation, and the retries.
|
|
7
|
+
# Two adapters ship (Adapters::RubyLLM, Adapters::OpenAICompatible); write your own by subclassing Adapter::Base
|
|
8
|
+
# and registering it (Squishling.register_adapter) or assigning an instance to config.adapter. See docs/adapters.md.
|
|
9
|
+
module Adapter
|
|
10
|
+
# One completion to make. messages holds the conversation so far as { role: :user | :assistant, content: String }
|
|
11
|
+
# Hashes, ending with the user message to answer; instructions is the system prompt. schema is the strict
|
|
12
|
+
# structured-output payload ({ "name", "description", "schema", "strict" => true }). params is the resolved
|
|
13
|
+
# generation params: the portable :temperature, :max_output_tokens, and :thinking, plus any provider-specific keys
|
|
14
|
+
# (see Params). label ("Class#method") prefixes the adapter's error messages. model and provider are the step's
|
|
15
|
+
# (nil when it names none).
|
|
16
|
+
Request = Data.define(:label, :model, :provider, :instructions, :messages, :schema, :params)
|
|
17
|
+
|
|
18
|
+
# Stands in for an empty reply when the conversation is replayed: some servers reject an empty assistant message.
|
|
19
|
+
EMPTY_REPLY = "(no output)"
|
|
20
|
+
|
|
21
|
+
# What a completion returns. content is the model's output: the raw String, or a Hash the adapter already parsed.
|
|
22
|
+
# model is the model id the provider reports. tokens is a Hash of counts (:input_tokens, :output_tokens, and
|
|
23
|
+
# whatever else the provider reports, such as :cache_read_tokens and :thinking_tokens), empty when unknown.
|
|
24
|
+
# finish_reason is why the provider stopped, as a Symbol: :stop, :max_tokens (the output cap cut the reply off),
|
|
25
|
+
# :content_filter, :tool_calls, or the provider's own spelling; nil when it reported none.
|
|
26
|
+
Response = Data.define(:content, :model, :tokens, :finish_reason) do
|
|
27
|
+
# model, tokens, and finish_reason may be left out; nil tokens count as none, and a String or blank
|
|
28
|
+
# finish_reason is made a Symbol or nil. Raises ConfigurationError for tokens that aren't a Hash.
|
|
29
|
+
def initialize(content:, model: nil, tokens: {}, finish_reason: nil)
|
|
30
|
+
tokens ||= {}
|
|
31
|
+
unless tokens.is_a?(Hash)
|
|
32
|
+
raise ConfigurationError, "Adapter::Response tokens must be a Hash, got #{tokens.class}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
finish_reason = finish_reason.to_s.strip.empty? ? nil : finish_reason.to_sym
|
|
36
|
+
super
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Whether the provider cut the reply off at the output token cap.
|
|
40
|
+
def truncated?
|
|
41
|
+
finish_reason == :max_tokens
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The token counts, or nil when the adapter reported none (what squawk's usage: carries).
|
|
45
|
+
def usage
|
|
46
|
+
tokens unless tokens.empty?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The output as the text the model will see again in a retry: a Hash an adapter already parsed goes back as
|
|
50
|
+
# JSON, and nothing (or a value JSON can't represent, which is rejected as invalid output anyway) as
|
|
51
|
+
# EMPTY_REPLY.
|
|
52
|
+
def text
|
|
53
|
+
text = content.is_a?(String) ? utf8(content) : generated
|
|
54
|
+
text.strip.empty? ? EMPTY_REPLY : text
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def generated
|
|
60
|
+
content.nil? ? "" : JSON.generate(content)
|
|
61
|
+
rescue JSON::JSONError
|
|
62
|
+
""
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Text that goes back out in a request must be valid UTF-8, or serializing the history fails. A reply that
|
|
66
|
+
# wasn't is already reported as invalid output; only its undecodable bytes are replaced here.
|
|
67
|
+
def utf8(text)
|
|
68
|
+
(text.encoding == Encoding::BINARY ? text.dup.force_encoding(Encoding::UTF_8) : text).scrub("?")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# What an adapter puts on a Squishling error's #cause in place of a provider's own exception (see
|
|
73
|
+
# Base#sanitized_cause): the original's class name and HTTP status in its message, and its backtrace, but none of
|
|
74
|
+
# its message, response, or other state. Not a Squishling::Error: it is never raised, only carried.
|
|
75
|
+
class ProviderFailure < StandardError; end
|
|
76
|
+
|
|
77
|
+
# The adapter contract. Subclass it, implement #complete, and raise Squishling's errors:
|
|
78
|
+
#
|
|
79
|
+
# class MyAdapter < Squishling::Adapter::Base
|
|
80
|
+
# def complete(request)
|
|
81
|
+
# reply = MyClient.chat(request.model, request.instructions, request.messages)
|
|
82
|
+
# Squishling::Adapter::Response.new(content: reply.text, model: reply.model, tokens: reply.usage)
|
|
83
|
+
# rescue MyClient::Unauthorized
|
|
84
|
+
# raise configuration_error(request, "MyClient rejected the credentials")
|
|
85
|
+
# rescue MyClient::Error => e
|
|
86
|
+
# raise llm_error(request, "MyClient failed (#{e.class})"), cause: sanitized_cause(e, status: e.status)
|
|
87
|
+
# end
|
|
88
|
+
# end
|
|
89
|
+
#
|
|
90
|
+
# ConfigurationError is for setup mistakes (bad credentials, an invalid request, an unknown model): it is never
|
|
91
|
+
# retried or escalated. LLMError is for failures worth another attempt (rate limits, 5xx, timeouts, connection
|
|
92
|
+
# errors, a prompt too long for the model): the next attempt, or the last one's caller, sees it. Keep the provider's
|
|
93
|
+
# own message out of both: it can echo request content or part of a key. The same goes for the exception it came
|
|
94
|
+
# from, which error trackers report as the error's #cause: pass `cause: sanitized_cause(e, status:)` rather than
|
|
95
|
+
# letting a raise inside a rescue set a provider exception that carries a response body. Any other exception
|
|
96
|
+
# propagates unwrapped.
|
|
97
|
+
# Which of the two a provider failure is, is the adapter's call (#http_failure maps HTTP statuses, and
|
|
98
|
+
# #context_length? is where an adapter recognizes an overlong prompt); what happens next is Squishling's.
|
|
99
|
+
# Adapters are called from worker threads when a harness takes samples, so they must not share unsynchronized state.
|
|
100
|
+
class Base
|
|
101
|
+
# One completion. Returns an Adapter::Response.
|
|
102
|
+
def complete(_request)
|
|
103
|
+
raise NotImplementedError, "#{self.class} must implement #complete(request)"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Whether #judge is supported (judges with type: :judgment).
|
|
107
|
+
def judgments?
|
|
108
|
+
false
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# A System One judgment: input (a Hash) and questions (a Hash of name => question), with the step's model and
|
|
112
|
+
# params. Returns an object answering [name] with an answer that responds to choice and probabilities. label
|
|
113
|
+
# ("Class#method") prefixes error messages. Called as judge(input, questions:, step:, label:).
|
|
114
|
+
def judge(_input, **_options)
|
|
115
|
+
raise ConfigurationError, "#{self.class} doesn't support judgments (type: :judgment judges)"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Called on the caller's thread before a harness's sample requests fan out to worker threads. Returns whatever
|
|
119
|
+
# #with_context needs to re-establish the caller's request context (tracing, say) on a worker.
|
|
120
|
+
def capture_context
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Runs the block on a worker thread inside the context #capture_context returned.
|
|
125
|
+
def with_context(_context)
|
|
126
|
+
yield
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def configuration_error(request, message)
|
|
132
|
+
ConfigurationError.new("#{request.label}: #{message}")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def llm_error(request, message)
|
|
136
|
+
LLMError.new("#{request.label}: #{message}")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# What to pass as `cause:` when raising a Squishling error for a provider's exception: a ProviderFailure naming
|
|
140
|
+
# the original's class and the HTTP status (when given), with the original's backtrace. A provider exception's
|
|
141
|
+
# message and response usually hold the provider's response body, which can echo request content, and error
|
|
142
|
+
# trackers (Sentry, say) report the whole cause chain. Built fresh, never a copy of the original, so none of
|
|
143
|
+
# its state (a Faraday response, say) comes along. Transport errors that carry no body can be passed as they are.
|
|
144
|
+
def sanitized_cause(error, status: nil)
|
|
145
|
+
ProviderFailure.new([error.class, ("HTTP #{status}" if status)].compact.join(", "))
|
|
146
|
+
.tap { |failure| failure.set_backtrace(error.backtrace) }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Classifies a provider's HTTP failure, so adapters sort the same status the same way: 408, 413, 429, and 5xx are
|
|
150
|
+
# LLMErrors; 401, 402, 403, 404, and a redirect are ConfigurationErrors (credentials, billing, model or endpoint:
|
|
151
|
+
# nothing a retry fixes); every other 4xx is a RequestRejectedError, unless #context_length? says the input
|
|
152
|
+
# outgrew the model, which is an LLMError too (a bigger model may take it). via names how it failed (the error
|
|
153
|
+
# class, the HTTP status) and is all of the failure that is repeated, with a rejection's status and code tokens.
|
|
154
|
+
# body is the provider's error body, as for #rejected_request_error. A nil status (no HTTP response) or a
|
|
155
|
+
# success status (a reply that was unusable) is an LLMError.
|
|
156
|
+
def http_failure(request, status:, body:, via:)
|
|
157
|
+
case status
|
|
158
|
+
when nil, 100..299, 408, 413, 429, 500.. then llm_error(request, via)
|
|
159
|
+
when 401, 403 then configuration_error(request, "the provider refused the credentials (#{via})")
|
|
160
|
+
when 402 then configuration_error(request, "#{via}: the provider reports a billing or quota problem")
|
|
161
|
+
when 404 then configuration_error(request, "#{via}: check the model name and the endpoint")
|
|
162
|
+
when 300..399 then configuration_error(request, "#{via}: redirects aren't followed; check the endpoint")
|
|
163
|
+
else rejected_or_overlong(request, status:, body:, via:)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Whether a rejected request (a 400, or another 4xx not otherwise classified) means the input was too long for the
|
|
168
|
+
# model, which makes it an LLMError rather than a RequestRejectedError. The adapter decides: only it knows how its
|
|
169
|
+
# provider says so. The default recognizes a 400 whose structured error code or type is "context_length_exceeded",
|
|
170
|
+
# never reading the message. Override it to recognize more (and call super); when an adapter can't tell, the
|
|
171
|
+
# failure stays a rejection. status and body are those given to #http_failure.
|
|
172
|
+
def context_length?(status:, body:)
|
|
173
|
+
status == 400 && ErrorBody.context_length?(body)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def rejected_or_overlong(request, status:, body:, via:)
|
|
177
|
+
return llm_error(request, via) if context_length?(status:, body:)
|
|
178
|
+
|
|
179
|
+
rejected_request_error(request, via:, body:, http_status: status)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# A RequestRejectedError for a request the provider rejected (a 400, or another 4xx). via names how it failed
|
|
183
|
+
# (the error class, the HTTP status); body is the provider's error body (a JSON String, or a Hash with String
|
|
184
|
+
# keys), of which only the status and code tokens are repeated, never its message. http_status, when given, drops
|
|
185
|
+
# a code that only repeats it. The params are pointed at only when the provider named one of them.
|
|
186
|
+
def rejected_request_error(request, via:, body:, http_status: nil)
|
|
187
|
+
found = ErrorBody.read(body, http_status:)
|
|
188
|
+
tokens = found.values_at(:status, :code).compact
|
|
189
|
+
RequestRejectedError.new(
|
|
190
|
+
"#{request.label}: the provider rejected the request (#{[via, *tokens].join(', ')}). " \
|
|
191
|
+
"#{rejection_hint(request.params, found[:param])}",
|
|
192
|
+
provider_status: found[:status], provider_code: found[:code]
|
|
193
|
+
)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def rejection_hint(params, field)
|
|
197
|
+
named = params.keys.find { |key| names?(field, key.to_s) }
|
|
198
|
+
return "The #{suspects(params)} may be invalid." unless named
|
|
199
|
+
|
|
200
|
+
"The provider named the #{named.inspect} param; check its value."
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def suspects(params)
|
|
204
|
+
params.empty? ? "output schema or input" : "params, output schema, or input"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Whether the provider's field is the param or inside it (thinking.budget_tokens, stop[0]).
|
|
208
|
+
def names?(field, name)
|
|
209
|
+
!field.nil? && (field == name || field.start_with?("#{name}.", "#{name}["))
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# The adapters by name: the built-ins, registered as they load, and your own (Squishling.register_adapter).
|
|
214
|
+
module Registry
|
|
215
|
+
STORE = NamedRegistry.new("adapter")
|
|
216
|
+
private_constant :STORE
|
|
217
|
+
|
|
218
|
+
class << self
|
|
219
|
+
def register_built_in(name, klass)
|
|
220
|
+
STORE.register_built_in(name, klass)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Registers an adapter class under a name. It is built with the options given next to its type: (or none).
|
|
224
|
+
def register(name, klass)
|
|
225
|
+
name = STORE.checked_name(name)
|
|
226
|
+
unless implements_complete?(klass)
|
|
227
|
+
raise ConfigurationError, "register_adapter needs a class that implements #complete, got #{klass.inspect}"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
STORE.add(name, klass)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def fetch(name)
|
|
234
|
+
STORE.fetch(name)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def names
|
|
238
|
+
STORE.names
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Forgets the registered adapters; the built-ins stay.
|
|
242
|
+
def reset!
|
|
243
|
+
STORE.reset!
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
private
|
|
247
|
+
|
|
248
|
+
# A class that defines #complete itself, not one that only inherits Base's placeholder.
|
|
249
|
+
def implements_complete?(klass)
|
|
250
|
+
klass.is_a?(Class) && klass.method_defined?(:complete) && klass.instance_method(:complete).owner != Base
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
KEYWORD_KINDS = %i[key keyreq].freeze
|
|
256
|
+
private_constant :KEYWORD_KINDS
|
|
257
|
+
|
|
258
|
+
module_function
|
|
259
|
+
|
|
260
|
+
# The adapter a config.adapter value names: an instance (anything that responds to complete), a registered name,
|
|
261
|
+
# or a Hash with type: and that adapter's options. Built now, so a mistake (a missing gem, a bad option) fails at
|
|
262
|
+
# setup rather than on the first request.
|
|
263
|
+
def build(value, where)
|
|
264
|
+
case value
|
|
265
|
+
when Symbol, String then instantiate(value, {}, where)
|
|
266
|
+
when Hash
|
|
267
|
+
options = value.transform_keys(&:to_sym)
|
|
268
|
+
raise ConfigurationError, "#{where} adapter: needs a type: (#{type_list})" unless options.key?(:type)
|
|
269
|
+
|
|
270
|
+
instantiate(options.delete(:type), options, where)
|
|
271
|
+
else
|
|
272
|
+
return value if value.respond_to?(:complete)
|
|
273
|
+
|
|
274
|
+
raise ConfigurationError, "#{where} adapter must be an adapter (responding to complete), one of " \
|
|
275
|
+
"#{type_list}, or a Hash with type:, got #{Appendices.describe(value)}"
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# The adapter used when none is configured: RubyLLM's, if the ruby_llm gem is available.
|
|
280
|
+
def detect
|
|
281
|
+
Adapters::RubyLLM.new
|
|
282
|
+
rescue Adapters::RubyLLM::MissingGemError
|
|
283
|
+
raise ConfigurationError, "No adapter is configured and the ruby_llm gem isn't available. " \
|
|
284
|
+
"Add `gem \"ruby_llm\"` to your Gemfile, or set config.adapter " \
|
|
285
|
+
"(#{type_list}, or your own adapter)"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def type_list
|
|
289
|
+
Registry.names.map(&:inspect).join(", ")
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def instantiate(type, options, where)
|
|
293
|
+
name = type.to_s.to_sym
|
|
294
|
+
klass = Registry.fetch(name)
|
|
295
|
+
unless klass
|
|
296
|
+
raise ConfigurationError, "#{where} adapter: unknown adapter #{type.inspect} (available: #{type_list})"
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
check_options!(klass, options, name, where)
|
|
300
|
+
options.empty? ? klass.new : klass.new(**options)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# The keywords the adapter's initialize takes, so a typo is a ConfigurationError rather than an ArgumentError.
|
|
304
|
+
def check_options!(klass, options, name, where)
|
|
305
|
+
known, required = keywords(klass)
|
|
306
|
+
return unless known
|
|
307
|
+
|
|
308
|
+
unknown = options.keys - known
|
|
309
|
+
missing = required - options.keys
|
|
310
|
+
return if unknown.empty? && missing.empty?
|
|
311
|
+
|
|
312
|
+
problem = unknown.empty? ? "needs #{missing.join(', ')}" : "doesn't take #{unknown.join(', ')}"
|
|
313
|
+
hint = known.empty? ? "" : " (options: #{known.join(', ')})"
|
|
314
|
+
raise ConfigurationError, "#{where} adapter #{name.inspect} #{problem}#{hint}"
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# [all keywords, required keywords] of klass#initialize, or nil when it takes any (**options).
|
|
318
|
+
def keywords(klass)
|
|
319
|
+
parameters = klass.instance_method(:initialize).parameters
|
|
320
|
+
return if parameters.any? { |kind, _| kind == :keyrest }
|
|
321
|
+
|
|
322
|
+
[parameters.filter_map { |kind, key| key if KEYWORD_KINDS.include?(kind) },
|
|
323
|
+
parameters.filter_map { |kind, key| key if kind == :keyreq }]
|
|
324
|
+
end
|
|
325
|
+
private_class_method :check_options!, :keywords
|
|
326
|
+
private_class_method :instantiate
|
|
327
|
+
end
|
|
328
|
+
end
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "zlib"
|
|
8
|
+
|
|
9
|
+
module Squishling
|
|
10
|
+
module Adapters
|
|
11
|
+
# Sends requests to any server that speaks OpenAI's Chat Completions API (POST {base_url}/chat/completions):
|
|
12
|
+
# OpenAI itself, Ollama, vLLM, LM Studio, llama.cpp, Together, Groq, and the like. Plain Net::HTTP, so it needs
|
|
13
|
+
# no gem beyond Squishling's own. Squishling asks for strict structured output (response_format: json_schema);
|
|
14
|
+
# servers that lack it can use structured_output: :json_object or :none, where the schema goes into the system
|
|
15
|
+
# prompt instead, and Squishling's own validation still decides what is accepted. See docs/adapters.md.
|
|
16
|
+
#
|
|
17
|
+
# Squishling.configure do |config|
|
|
18
|
+
# config.adapter = { type: :openai_compatible, base_url: "http://localhost:11434/v1", default_model: "llama3.2" }
|
|
19
|
+
# end
|
|
20
|
+
class OpenAICompatible < Adapter::Base
|
|
21
|
+
Options = OpenAIOptions
|
|
22
|
+
Transport = OpenAITransport
|
|
23
|
+
private_constant :Options, :Transport
|
|
24
|
+
|
|
25
|
+
DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
|
26
|
+
STRUCTURED_OUTPUT = %i[json_schema json_object none].freeze
|
|
27
|
+
TOKEN_PARAMS = %i[max_completion_tokens max_tokens].freeze
|
|
28
|
+
# Params Squishling maps itself; the rest go into the request body as given.
|
|
29
|
+
PORTABLE_PARAMS = %i[temperature thinking max_output_tokens].freeze
|
|
30
|
+
# Failures worth another try inside the adapter: timeouts, connection and protocol trouble (below), HTTP 408,
|
|
31
|
+
# 429, and 5xx. A timeout is retried even though the server may have processed the request.
|
|
32
|
+
NETWORK_ERRORS = [Timeout::Error, SocketError, EOFError, IOError, SystemCallError, Net::ProtocolError,
|
|
33
|
+
Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, OpenSSL::OpenSSLError, Zlib::Error].freeze
|
|
34
|
+
DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024
|
|
35
|
+
MAX_RETRY_DELAY = 30
|
|
36
|
+
# How servers without OpenAI's error code (vLLM, Ollama, llama.cpp, ...) say a prompt is too long. Deliberately
|
|
37
|
+
# narrow (no bare "maximum ... tokens", which a bad max_tokens value also says): a wording that isn't here is a
|
|
38
|
+
# rejected request, which is the safe side. The message is only matched against this, never repeated.
|
|
39
|
+
CONTEXT_LENGTH_MESSAGE = /context (length|window|size)|too many tokens|prompt is too long/i
|
|
40
|
+
private_constant :STRUCTURED_OUTPUT, :TOKEN_PARAMS, :PORTABLE_PARAMS, :NETWORK_ERRORS, :MAX_RETRY_DELAY,
|
|
41
|
+
:CONTEXT_LENGTH_MESSAGE
|
|
42
|
+
|
|
43
|
+
# base_url: the API root, ending where /chat/completions starts. api_key: a String, or a callable that returns
|
|
44
|
+
# one; for OpenAI's own endpoint it defaults to ENV["OPENAI_API_KEY"] (read when a request is made). Any other
|
|
45
|
+
# endpoint gets only the key you give it, so an OpenAI key can't reach another host. None is sent when the key
|
|
46
|
+
# is blank (local servers). headers: extra request headers. default_model: used for steps that name no model.
|
|
47
|
+
# max_retries: how often a timeout, connection failure, HTTP 408, 429, or 5xx is retried here (honoring
|
|
48
|
+
# Retry-After) before it surfaces as an LLMError. max_tokens_param: the body key for max_output_tokens
|
|
49
|
+
# (:max_completion_tokens, or :max_tokens for older servers). structured_output: :json_schema, :json_object,
|
|
50
|
+
# or :none. allow_insecure_http: lets a credential (api_key, or a header such as Authorization or api-key) go
|
|
51
|
+
# over plain http to a host that isn't loopback; without it that is a ConfigurationError. max_response_bytes:
|
|
52
|
+
# the most a response (an error body too) may hold, 8 MiB by default. total_timeout: the most seconds one
|
|
53
|
+
# attempt may take (read_timeout restarts at every read); it defaults to open_timeout + 2 * read_timeout.
|
|
54
|
+
# Responses are requested and accepted uncompressed only. Raises ConfigurationError for a bad option.
|
|
55
|
+
def initialize(base_url: DEFAULT_BASE_URL, api_key: nil, headers: {}, default_model: nil, open_timeout: 10,
|
|
56
|
+
read_timeout: 120, max_retries: 2, max_tokens_param: :max_completion_tokens, structured_output: :json_schema,
|
|
57
|
+
allow_insecure_http: false, max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, total_timeout: nil)
|
|
58
|
+
super()
|
|
59
|
+
@endpoint = Options.endpoint(base_url)
|
|
60
|
+
@api_key = (Secret.new(api_key) unless api_key.nil?)
|
|
61
|
+
@headers = Secret.new(Options.headers(headers))
|
|
62
|
+
@allow_insecure_http = Options.flag(:allow_insecure_http, allow_insecure_http)
|
|
63
|
+
@default_model = Options.default_model(default_model)
|
|
64
|
+
@max_retries = Options.retries(max_retries)
|
|
65
|
+
@max_tokens_param = Options.choice(:max_tokens_param, max_tokens_param, TOKEN_PARAMS)
|
|
66
|
+
@structured_output = Options.choice(:structured_output, structured_output, STRUCTURED_OUTPUT)
|
|
67
|
+
@transport = build_transport(open_timeout, read_timeout, total_timeout, max_response_bytes)
|
|
68
|
+
check_cleartext!(api_key.respond_to?(:call) ? nil : api_key)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Shows where requests go, never the key or the header values (a console, a log line, or an error tracker may
|
|
72
|
+
# print the config). The key and headers are held in a Secret, so to_yaml, as_json and Marshal can't expose them
|
|
73
|
+
# either.
|
|
74
|
+
def inspect
|
|
75
|
+
"#<#{self.class.name} #{@endpoint.scheme}://#{@endpoint.authority}#{@endpoint.path} " \
|
|
76
|
+
"default_model=#{@default_model.inspect} structured_output=#{@structured_output.inspect}>"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def complete(request)
|
|
80
|
+
model = request.model || @default_model
|
|
81
|
+
unless model
|
|
82
|
+
raise configuration_error(request,
|
|
83
|
+
"no model: name one with model: or give the adapter a default_model:")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
data = post(request, JSON.generate(body(request, model)), request_headers(request))
|
|
87
|
+
Adapter::Response.new(content: content_of(data), model: data["model"] || model,
|
|
88
|
+
tokens: tokens_of(data["usage"]), finish_reason: finish_reason_of(data))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# Params first, then what Squishling owns, so a param can never replace the model, the conversation, or the
|
|
94
|
+
# strict output format. n and stream are fixed: one non-streamed completion.
|
|
95
|
+
def body(request, model)
|
|
96
|
+
params = request.params
|
|
97
|
+
body = params.except(*PORTABLE_PARAMS).transform_keys(&:to_s)
|
|
98
|
+
body["temperature"] = params[:temperature] if params.key?(:temperature)
|
|
99
|
+
body[@max_tokens_param.to_s] = params[:max_output_tokens] if params.key?(:max_output_tokens)
|
|
100
|
+
body.merge!(reasoning(request, params[:thinking]))
|
|
101
|
+
body.merge("model" => model, "messages" => messages(request), "n" => 1, "stream" => false)
|
|
102
|
+
.merge(response_format(request))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Chat Completions only has reasoning_effort: true and false have no equivalent, so they leave the default.
|
|
106
|
+
def reasoning(request, thinking)
|
|
107
|
+
return {} unless thinking.is_a?(Hash)
|
|
108
|
+
|
|
109
|
+
unsupported = thinking.keys - [:effort]
|
|
110
|
+
unless unsupported.empty?
|
|
111
|
+
raise configuration_error(request, "thinking #{unsupported.join(', ')} isn't supported by this adapter " \
|
|
112
|
+
"(only thinking: { effort: })")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
{ "reasoning_effort" => thinking[:effort].to_s }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def messages(request)
|
|
119
|
+
system = system_prompt(request)
|
|
120
|
+
turns = request.messages.map { |message| { "role" => message[:role].to_s, "content" => message[:content] } }
|
|
121
|
+
system ? [{ "role" => "system", "content" => system }] + turns : turns
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Without response_format: json_schema the model has to be told the schema in words.
|
|
125
|
+
def system_prompt(request)
|
|
126
|
+
return request.instructions if @structured_output == :json_schema
|
|
127
|
+
|
|
128
|
+
schema = JSON.pretty_generate(request.schema["schema"])
|
|
129
|
+
[request.instructions,
|
|
130
|
+
"Respond only with JSON that validates against this JSON Schema:\n#{schema}"].compact.join("\n\n")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def response_format(request)
|
|
134
|
+
case @structured_output
|
|
135
|
+
when :json_schema then { "response_format" => { "type" => "json_schema",
|
|
136
|
+
"json_schema" => json_schema(request) } }
|
|
137
|
+
when :json_object then { "response_format" => { "type" => "json_object" } }
|
|
138
|
+
else {}
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def json_schema(request)
|
|
143
|
+
schema = request.schema
|
|
144
|
+
name = schema["name"].to_s.gsub(/[^A-Za-z0-9_-]/, "_")[0, 64]
|
|
145
|
+
{ "name" => name.empty? ? "response" : name, "description" => schema["description"],
|
|
146
|
+
"schema" => schema["schema"], "strict" => true }.compact
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# One request, retried here for failures worth retrying. A refusal or an empty answer is returned as empty
|
|
150
|
+
# content, which Squishling reports as invalid output.
|
|
151
|
+
def post(request, body, headers)
|
|
152
|
+
attempt = 0
|
|
153
|
+
loop do
|
|
154
|
+
attempt += 1
|
|
155
|
+
response, error = exchange(body, headers)
|
|
156
|
+
return parse_body(request, response) if response&.success?
|
|
157
|
+
|
|
158
|
+
failure = failure_for(request, response, error)
|
|
159
|
+
raise failure, cause: error unless retryable?(response, error) && attempt <= @max_retries
|
|
160
|
+
|
|
161
|
+
pause(retry_delay(response, attempt))
|
|
162
|
+
end
|
|
163
|
+
rescue Transport::Refused => e
|
|
164
|
+
# A size or time limit, or compression: the same request would hit it again, so it isn't retried.
|
|
165
|
+
raise llm_error(request, e.message), cause: e
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def exchange(body, headers)
|
|
169
|
+
[@transport.post(body, headers), nil]
|
|
170
|
+
rescue *NETWORK_ERRORS => e
|
|
171
|
+
[nil, e]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def build_transport(open_timeout, read_timeout, total_timeout, max_response_bytes)
|
|
175
|
+
open_timeout = Options.number(:open_timeout, open_timeout)
|
|
176
|
+
read_timeout = Options.number(:read_timeout, read_timeout)
|
|
177
|
+
Transport.new(endpoint: @endpoint, open_timeout:, read_timeout:,
|
|
178
|
+
total_timeout: Options.total_timeout(total_timeout, open_timeout, read_timeout),
|
|
179
|
+
max_response_bytes: Options.number(:max_response_bytes, max_response_bytes, type: Integer))
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Built once per call, before any request and outside the network-error rescue, so the developer's own api_key
|
|
183
|
+
# callable runs once and its exceptions propagate as they are.
|
|
184
|
+
def request_headers(request)
|
|
185
|
+
key = api_key_value.to_s.strip
|
|
186
|
+
if Options.unsafe_header?(key)
|
|
187
|
+
raise configuration_error(request,
|
|
188
|
+
"the api_key can't contain line breaks or NUL")
|
|
189
|
+
end
|
|
190
|
+
check_cleartext!(key, request)
|
|
191
|
+
|
|
192
|
+
headers = { "Content-Type" => "application/json", "Accept" => "application/json",
|
|
193
|
+
"User-Agent" => "squishling/#{VERSION}" }
|
|
194
|
+
headers["Authorization"] = "Bearer #{key}" unless key.empty?
|
|
195
|
+
# Accept-Encoding is Squishling's own: a compressed response would be inflated past the size cap.
|
|
196
|
+
headers.merge(@headers.reveal).reject { |name, _| name.casecmp?("accept-encoding") }
|
|
197
|
+
.merge("Accept-Encoding" => "identity")
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def api_key_value
|
|
201
|
+
key = @api_key&.reveal
|
|
202
|
+
key.respond_to?(:call) ? key.call : (key || default_api_key)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# A credential doesn't go over plain http to another machine unless allow_insecure_http: says so. Checked when the
|
|
206
|
+
# adapter is built for a key given as a String, and again with each request's own key (a callable's). Reads the
|
|
207
|
+
# headers through their Secret; the message names neither the key nor a header value.
|
|
208
|
+
def check_cleartext!(key, request = nil)
|
|
209
|
+
return if @allow_insecure_http || !Options.cleartext_credential?(@endpoint, key, @headers.reveal)
|
|
210
|
+
|
|
211
|
+
raise configuration_error(request, Options::CLEARTEXT_CREDENTIAL) if request
|
|
212
|
+
|
|
213
|
+
raise ConfigurationError, Options::CLEARTEXT_CREDENTIAL
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def default_api_key
|
|
217
|
+
default = URI.parse(DEFAULT_BASE_URL)
|
|
218
|
+
return unless @endpoint.scheme == default.scheme && @endpoint.hostname.downcase == default.hostname
|
|
219
|
+
|
|
220
|
+
ENV.fetch("OPENAI_API_KEY", nil)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def parse_body(request, response)
|
|
224
|
+
data = JSON.parse(response.body.to_s.dup.force_encoding(Encoding::UTF_8))
|
|
225
|
+
return data if data.is_a?(Hash)
|
|
226
|
+
|
|
227
|
+
raise llm_error(request, "the response wasn't a JSON object (HTTP #{response.code})")
|
|
228
|
+
rescue JSON::ParserError
|
|
229
|
+
raise llm_error(request, "the response wasn't valid JSON (HTTP #{response.code})")
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def retryable?(response, error)
|
|
233
|
+
return !certificate_failure?(error) if error
|
|
234
|
+
|
|
235
|
+
status = response.code.to_i
|
|
236
|
+
[408, 429].include?(status) || status >= 500
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# The provider's own message is never repeated: only the class of failure or the HTTP status.
|
|
240
|
+
def failure_for(request, response, error)
|
|
241
|
+
return transport_failure(request, error) if error
|
|
242
|
+
|
|
243
|
+
status = response.code.to_i
|
|
244
|
+
http_failure(request, status:, body: response.body.to_s, via: "HTTP #{status}")
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# A certificate that doesn't verify is a setup mistake (base_url, CA store) that a retry can't fix.
|
|
248
|
+
def transport_failure(request, error)
|
|
249
|
+
if certificate_failure?(error)
|
|
250
|
+
return configuration_error(request, "TLS certificate verification failed (#{error.class}); check base_url " \
|
|
251
|
+
"and the CA certificates")
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
llm_error(request, "the request failed (#{error.class})")
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def certificate_failure?(error)
|
|
258
|
+
error.is_a?(OpenSSL::SSL::SSLError) && error.message.include?("certificate verify failed")
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# The structured code or type, as for any adapter, or the usual wording of a server that sends none (a 400).
|
|
262
|
+
def context_length?(status:, body:)
|
|
263
|
+
super || (status == 400 && Adapter::ErrorBody.error(body)&.[]("message").to_s.scrub.match?(CONTEXT_LENGTH_MESSAGE))
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def retry_delay(response, attempt)
|
|
267
|
+
after = response&.retry_after.to_s
|
|
268
|
+
seconds = after.match?(/\A\d+\z/) ? after.to_i : (0.5 * (2**(attempt - 1))) + (rand * 0.25)
|
|
269
|
+
[seconds, MAX_RETRY_DELAY].min
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def pause(seconds)
|
|
273
|
+
sleep(seconds)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def content_of(data)
|
|
277
|
+
message = Array(data["choices"]).first
|
|
278
|
+
message = message["message"] if message.is_a?(Hash)
|
|
279
|
+
content = message.is_a?(Hash) ? message["content"] : nil
|
|
280
|
+
content.is_a?(Array) ? content.filter_map { |part| part["text"] if part.is_a?(Hash) }.join : content
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# The first choice's finish_reason, in the vocabulary of Adapter::Response ("length" is the output cap).
|
|
284
|
+
def finish_reason_of(data)
|
|
285
|
+
choice = Array(data["choices"]).first
|
|
286
|
+
reason = choice["finish_reason"] if choice.is_a?(Hash)
|
|
287
|
+
return unless reason.is_a?(String)
|
|
288
|
+
|
|
289
|
+
reason == "length" ? :max_tokens : reason
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def tokens_of(usage)
|
|
293
|
+
return {} unless usage.is_a?(Hash)
|
|
294
|
+
|
|
295
|
+
{
|
|
296
|
+
input_tokens: usage["prompt_tokens"],
|
|
297
|
+
output_tokens: usage["completion_tokens"],
|
|
298
|
+
cache_read_tokens: detail(usage, "prompt_tokens_details", "cached_tokens"),
|
|
299
|
+
thinking_tokens: detail(usage, "completion_tokens_details", "reasoning_tokens")
|
|
300
|
+
}.select { |_, count| count.is_a?(Integer) }
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# A count inside a usage sub-object, which a server might send in any shape.
|
|
304
|
+
def detail(usage, group, key)
|
|
305
|
+
usage[group][key] if usage[group].is_a?(Hash)
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
Adapter::Registry.register_built_in(:openai_compatible, OpenAICompatible)
|
|
310
|
+
end
|
|
311
|
+
end
|