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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -0
  3. data/README.md +26 -10
  4. data/docs/adapters.md +257 -0
  5. data/docs/configuration.md +54 -19
  6. data/docs/failures.md +170 -22
  7. data/docs/harnesses.md +39 -15
  8. data/docs/measuring-tokens.md +12 -6
  9. data/docs/observers.md +3 -3
  10. data/docs/routing.md +14 -3
  11. data/docs/schemas.md +24 -2
  12. data/docs/testing.md +187 -0
  13. data/docs/verifying.md +86 -0
  14. data/lib/squishling/adapter/error_body.rb +54 -0
  15. data/lib/squishling/adapter.rb +328 -0
  16. data/lib/squishling/adapters/openai_compatible.rb +311 -0
  17. data/lib/squishling/adapters/openai_options.rb +114 -0
  18. data/lib/squishling/adapters/openai_transport.rb +123 -0
  19. data/lib/squishling/adapters/ruby_llm.rb +229 -0
  20. data/lib/squishling/budget.rb +71 -0
  21. data/lib/squishling/class_methods.rb +43 -7
  22. data/lib/squishling/class_registry.rb +63 -0
  23. data/lib/squishling/configuration.rb +58 -6
  24. data/lib/squishling/definition.rb +90 -16
  25. data/lib/squishling/errors.rb +94 -7
  26. data/lib/squishling/harness/judged_squishsum.rb +2 -2
  27. data/lib/squishling/harness/option_types.rb +2 -1
  28. data/lib/squishling/harness/registry.rb +8 -36
  29. data/lib/squishling/invoker.rb +44 -14
  30. data/lib/squishling/judge.rb +16 -3
  31. data/lib/squishling/llm_client.rb +66 -132
  32. data/lib/squishling/model_path.rb +60 -13
  33. data/lib/squishling/named_registry.rb +64 -0
  34. data/lib/squishling/observer.rb +13 -4
  35. data/lib/squishling/output_check.rb +13 -9
  36. data/lib/squishling/params.rb +7 -24
  37. data/lib/squishling/railtie.rb +10 -0
  38. data/lib/squishling/result.rb +50 -16
  39. data/lib/squishling/rotation.rb +5 -0
  40. data/lib/squishling/router.rb +3 -2
  41. data/lib/squishling/rspec.rb +18 -0
  42. data/lib/squishling/run/messages.rb +40 -3
  43. data/lib/squishling/run/rejections.rb +43 -0
  44. data/lib/squishling/run/reports.rb +63 -0
  45. data/lib/squishling/run.rb +60 -53
  46. data/lib/squishling/schema.rb +50 -9
  47. data/lib/squishling/secret.rb +45 -0
  48. data/lib/squishling/squawk.rb +37 -4
  49. data/lib/squishling/tasks/squishling.rake +18 -0
  50. data/lib/squishling/testing/adapter.rb +194 -0
  51. data/lib/squishling/testing/checks.rb +39 -0
  52. data/lib/squishling/testing/direct.rb +41 -0
  53. data/lib/squishling/testing/errors.rb +18 -0
  54. data/lib/squishling/testing/fixtures.rb +206 -0
  55. data/lib/squishling/testing/helpers.rb +71 -0
  56. data/lib/squishling/testing/minitest.rb +32 -0
  57. data/lib/squishling/testing/observer.rb +31 -0
  58. data/lib/squishling/testing/reply.rb +60 -0
  59. data/lib/squishling/testing/request.rb +75 -0
  60. data/lib/squishling/testing/rspec.rb +30 -0
  61. data/lib/squishling/testing/script.rb +189 -0
  62. data/lib/squishling/testing/state.rb +74 -0
  63. data/lib/squishling/testing/stubs.rb +112 -0
  64. data/lib/squishling/testing.rb +126 -0
  65. data/lib/squishling/verification.rb +39 -0
  66. data/lib/squishling/verifier.rb +179 -0
  67. data/lib/squishling/version.rb +1 -1
  68. data/lib/squishling/wrapper.rb +19 -1
  69. data/lib/squishling.rb +60 -4
  70. 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