squishling 0.1.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.
data/docs/routing.md ADDED
@@ -0,0 +1,210 @@
1
+ # Routing: Ruby or LLM
2
+
3
+ Every squished method call is routed to one of two paths: the method's own Ruby implementation (the
4
+ *deterministic* path) or an LLM call (the *elastic* path). Both return the same validated, typed result.
5
+
6
+ ## Which methods are squished
7
+
8
+ - **`call`** is squished by default, and `InvoiceParser.call(...)` is shorthand for `new.call(...)`.
9
+ - **Any other method** is squished once you declare it with `squish :name, ...` (see [Entry points](#entry-points)).
10
+ - **Methods you don't declare are never wrapped.** They're plain Ruby, though a squished method can call them,
11
+ and they can call `squish!` on its behalf.
12
+
13
+ ## When a call goes to the LLM
14
+
15
+ A squished call runs its Ruby implementation unless one of these sends it to the LLM:
16
+
17
+ | Trigger | Declared where | Decided |
18
+ |---|---|---|
19
+ | `squish_when` (or `when:`) predicate is truthy | class, or per method | before Ruby runs |
20
+ | The method has no implementation: it isn't defined, or it raises `NotImplementedError` | the method body | when Ruby gives up |
21
+ | `squish!` | inside the method, e.g. in a `rescue` | after Ruby has partly run, see [Escalating from Ruby](#escalating-from-ruby-with-squish) |
22
+
23
+ With no predicate and a working implementation, every call runs Ruby.
24
+
25
+ The predicate receives the method's inputs as keywords and is evaluated against the instance, so it can read
26
+ instance state too. Accept `**` to ignore inputs you don't need.
27
+
28
+ ```ruby
29
+ class InvoiceParser
30
+ include Squishling
31
+ # ...
32
+ squish_when { |client_name:, **| !HARDENED_CLIENTS.include?(client_name) }
33
+ end
34
+
35
+ def summarize(text) = raise NotImplementedError # elastic until someone writes it
36
+ ```
37
+
38
+ When the Ruby implementation runs, a `Hash` it returns is validated against the schema and turned into the
39
+ same typed result the LLM path produces. `result(...)` (alias `squishling_result`) does the same explicitly.
40
+ Invalid deterministic output raises `Squishling::InvalidOutputError` too, so a hardened path can't silently
41
+ drift from the contract.
42
+
43
+ A `NotImplementedError` raised anywhere inside the method, including from code it calls, also routes to the
44
+ LLM.
45
+
46
+ Each call is routed on its own, including a squished method that calls itself on smaller inputs. A subclass
47
+ override that calls `super` is one call: it's routed once, at the subclass. A call to the method from its own
48
+ `squish_when` or `squish_fallback` (or an instructions proc) isn't routed again: it runs the Ruby
49
+ implementation, so a fallback can hand the input back to Ruby with `call(**inputs)`.
50
+
51
+ ## Hardening a path
52
+
53
+ This is the workflow from [Elastic Software](https://everythingengineer.substack.com/p/beginners-write-software-with-ai):
54
+
55
+ 1. Ship the class with instructions and a schema but no implementation. Every call goes to the LLM.
56
+ 2. Watch which inputs carry the volume. `squished?` on each result tells you which path served it.
57
+ 3. Write Ruby for the high-volume cases and narrow `squish_when` so only the rest go to the LLM.
58
+
59
+ Callers never change, because both paths return the same result class.
60
+
61
+ ## Entry points
62
+
63
+ Squish methods other than `call` with `squish`, optionally overriding the class-level settings per method:
64
+
65
+ ```ruby
66
+ class TicketTriager
67
+ include Squishling
68
+
69
+ squish_context :customer_tier, :product # instance state sent alongside the arguments
70
+
71
+ squish :triage, instructions: "Assign a priority and team.", model: "claude-haiku-4-5" do
72
+ string :priority, enum: %w[low med high]
73
+ string :team
74
+ end
75
+
76
+ def initialize(customer_tier:, product:, db:)
77
+ @customer_tier, @product, @db = customer_tier, product, db
78
+ end
79
+
80
+ def triage(ticket_text) = raise NotImplementedError
81
+ end
82
+ ```
83
+
84
+ `squish` accepts:
85
+
86
+ - `instructions:` (replaces the class's)
87
+ - `append_instructions:` (added to the class's, see [Appending to the instructions](#appending-to-the-instructions))
88
+ - `output_schema:` (or a schema block)
89
+ - `model:`, `provider:`, and `params:` (generation params; see [Configuration](configuration.md))
90
+ - `when:`, a predicate proc
91
+ - `fallback:` (see [Failure handling](failures.md))
92
+
93
+ `squish` can come before or after the method's `def`.
94
+
95
+ ## Escalating from Ruby with `squish!`
96
+
97
+ Call `squish!` inside a squished method to hand *this call* to the LLM: for example, when the Ruby parser
98
+ fails on an input it wasn't written for. It sends the call's arguments, as any LLM call would, and returns the
99
+ typed result (`squished?` is `true`, or `false` if the declared `squish_fallback` supplied it). Return that result
100
+ from the method.
101
+
102
+ ```ruby
103
+ class InvoiceParser
104
+ include Squishling
105
+
106
+ instructions "Extract invoice fields from the client's raw data."
107
+ append_instructions "Here is the Ruby that parses well-formed invoices, for context on the logic and goals:",
108
+ self
109
+ output_schema do
110
+ string :invoice_number
111
+ number :total
112
+ end
113
+
114
+ def call(client_name:, data:)
115
+ parsed = AcmeParser.parse(data)
116
+ result(invoice_number: parsed.id, total: parsed.sum)
117
+ rescue AcmeParser::ParseError => e
118
+ squish!(append_instructions: "The Ruby parser above failed on this input; the error is in the context.",
119
+ context: { parse_error: e })
120
+ end
121
+ end
122
+ ```
123
+
124
+ `squish!` takes these overrides, all optional and all for this call only:
125
+
126
+ | Option | Effect |
127
+ |---|---|
128
+ | `context:` | A Hash sent under `"context"` with any `squish_context` values (a same-named key wins). Exceptions are sent as `{ "class", "message" }`, never their backtrace. |
129
+ | `append_instructions:` | Added to the declared sections; `false` (alone or first in an Array) drops them for this call |
130
+ | `instructions:` | Replaces the instructions |
131
+ | `model:`, `provider:`, `params:` | E.g. escalate to a stronger model when Ruby fails. A `provider:` needs a `model:`; `params:` merge key by key over the declared ones. |
132
+
133
+ - **The output schema can't be overridden.** The call still returns the method's result type.
134
+ - **Failures** go through the normal LLM path: a declared `squish_fallback` is used, otherwise
135
+ `InvalidOutputError` or `LLMError` is raised. When you call `squish!` from a `rescue`, Ruby sets the
136
+ rescued error as the `cause` of whatever is raised, so you can rescue the LLM failure and raise your own:
137
+
138
+ ```ruby
139
+ rescue AcmeParser::ParseError => e
140
+ begin
141
+ squish!(context: { parse_error: e })
142
+ rescue Squishling::InvalidOutputError, Squishling::LLMError
143
+ raise InvoiceUnreadable, "neither Ruby nor the LLM could parse invoice #{client_name}"
144
+ end
145
+ ```
146
+
147
+ - It works anywhere beneath a squished method on the same object: in the method itself, in a helper it calls,
148
+ or in a parent implementation reached through `super`. Calling it anywhere else, or from a `squish_fallback`
149
+ (which would loop), raises `Squishling::Error`.
150
+
151
+ ## Appending to the instructions
152
+
153
+ `append_instructions` adds sections to the system prompt after the instructions. It takes items, an Array of
154
+ them, or a block (treated as a Proc item). Each item is one of:
155
+
156
+ | Item | Sent as |
157
+ |---|---|
158
+ | a String | itself |
159
+ | a class or module (`self` inside a class body is that class) | its Ruby source, including bodies that reopen it in other files (see the limits below) |
160
+ | a method (`instance_method(:call)`, `AcmeParser.method(:parse)`) | that method's source |
161
+ | a Proc | evaluated against the instance on each call; it can return any of the above, an Array of them, or `nil`/`false` to add nothing (`-> { strict? && "Be strict." }`) |
162
+
163
+ ```ruby
164
+ class InvoiceParser
165
+ include Squishling
166
+
167
+ append_instructions "The Ruby that parses well-formed invoices:", self, AcmeParser
168
+ append_instructions -> { "This client's invoices are in #{currency}." }
169
+
170
+ squish :summarize, append_instructions: false do # no appendices for this method
171
+ string :summary
172
+ end
173
+ end
174
+ ```
175
+
176
+ The same option goes in the class-wide call: `squishling append_instructions: ["...", self]`.
177
+
178
+ Sections are added down the chain: class, then subclass, then `squish :name, append_instructions:`, then
179
+ `squish!(append_instructions:)`. `false` drops everything declared above it, so `[false, "Only this."]`
180
+ replaces it.
181
+
182
+ Source is read with Ruby's own parser (Prism) the first time it's needed and cached. Some limits:
183
+
184
+ - A class's source is every `class`/`module` body that defines one of its methods, plus the one where its
185
+ constant is first assigned (including `Parser = Class.new do ... end`). A body that reopens the class
186
+ without defining a method isn't included.
187
+ - A class without a constant (built with `Class.new` and never assigned, nested in an anonymous module, or given
188
+ a temporary name) is sent method by method: each of its own `def`s, without the code around them.
189
+ - A class built from a `Struct.new`/`Data.define` block (`Point = Struct.new(:x) do ... end`) isn't found; reopen
190
+ it with `class Point` or append its methods individually.
191
+ - Only `def`s count. Methods made with `define_method` or `attr_*` aren't shown, and a method item made that way
192
+ raises `ConfigurationError`, as does code with no source file, such as code generated by `eval`.
193
+
194
+ **Appended source is sent to your provider.** Don't append classes that hold secrets in constants.
195
+
196
+ ## What the LLM sees
197
+
198
+ - **System prompt:** your instructions (a String, or a Proc evaluated against the instance), then any
199
+ `append_instructions` sections, then a short note describing the input format.
200
+ - **User message:** JSON with the method's arguments, mapped to their parameter names. Any
201
+ `squish_context` values, and a `squish!` call's `context:`, go under `"context"`:
202
+
203
+ ```json
204
+ { "arguments": { "ticket_text": "API is down!" },
205
+ "context": { "customer_tier": "enterprise", "product": "API" } }
206
+ ```
207
+
208
+ `squish_context` names are read from a method of that name if there is one, otherwise from the instance
209
+ variable. Only context you name is sent. Instance variables are never dumped wholesale, so API clients,
210
+ database connections, and secrets stay out of the prompt.
data/docs/schemas.md ADDED
@@ -0,0 +1,87 @@
1
+ # Output schemas and typed results
2
+
3
+ ## Declaring a schema
4
+
5
+ `output_schema` (and `squish`) accept any of:
6
+
7
+ - a [Schematist](https://github.com/crmne/schematist) DSL block (the schema DSL RubyLLM 2.0 uses)
8
+ - a `Schematist::Schema` subclass (`RubyLLM::Schema` too, if your app uses the `ruby_llm-schema` shim)
9
+ - a raw JSON Schema `Hash`
10
+
11
+ ```ruby
12
+ output_schema do
13
+ string :invoice_number
14
+ number :total
15
+ array :line_items do
16
+ object do
17
+ string :description
18
+ number :amount
19
+ end
20
+ end
21
+ end
22
+
23
+ output_schema InvoiceSchema # class InvoiceSchema < Schematist::Schema
24
+
25
+ output_schema(
26
+ type: "object",
27
+ properties: { sentiment: { type: "string", enum: %w[positive negative neutral] } },
28
+ required: ["sentiment"],
29
+ additionalProperties: false
30
+ )
31
+ ```
32
+
33
+ ## Strict schemas only
34
+
35
+ Squishling only supports **strict** output schemas: it always asks RubyLLM for strict structured output, and a
36
+ schema declaring `strict: false` raises `Squishling::ConfigurationError`. RubyLLM 2.0 on its own would send a
37
+ schema with optional properties non-strict; Squishling always sets the flag explicitly, so it stays strict.
38
+
39
+ With raw hashes, follow your provider's strict-mode rules (e.g. list every property in `required` and set
40
+ `additionalProperties: false`). The DSL generates compliant schemas for you, as long as you don't mark fields
41
+ `required: false`. Use `optional` instead (below). A schema the provider rejects in strict mode raises
42
+ `ConfigurationError`.
43
+
44
+ Not every provider enforces strict mode server-side (Anthropic, for example, doesn't receive the flag), so
45
+ Squishling validates every result itself. See [Failure handling](failures.md).
46
+
47
+ ## Typed results
48
+
49
+ Object schemas produce `Data` result classes, and nested objects become nested `Data`. Both paths of a
50
+ squished method return the same class.
51
+
52
+ ```ruby
53
+ r = InvoiceParser.call(client_name: "globex", data: csv)
54
+ r.total # => 1250.0
55
+ r[:total] # => 1250.0
56
+ r.line_items.first.description # nested Data
57
+ r.to_h # deep Hash with symbol keys
58
+ r.squished? # => true when it came from the LLM
59
+ ```
60
+
61
+ On the deterministic path, return a `Hash` or build the result with `result(...)`. Either way it's validated
62
+ against the schema.
63
+
64
+ ## Optional vs. empty
65
+
66
+ Strict mode requires **every key to be present**, so a field can't be left out. To say "not provided", make the
67
+ field nullable with the DSL's `optional`. The key stays required, but its value may be `null`:
68
+
69
+ ```ruby
70
+ output_schema do
71
+ array :symptoms, of: :string # always a list; [] means "none"
72
+ optional :allergies do # [] means "none", nil means "not mentioned"
73
+ array of: :string
74
+ end
75
+ optional :vitals do # a typed Data object, or nil
76
+ object do
77
+ integer :heart_rate
78
+ end
79
+ end
80
+ end
81
+ ```
82
+
83
+ `optional` produces `anyOf: [<schema>, {type: "null"}]`, which is strict-compatible. Squishling types the non-null
84
+ branch as usual: `vitals` is a `Data` object or `nil`, and a list of objects is a list of `Data` objects. In a raw
85
+ JSON Schema, `type: ["array", "null"]` works too. A union with more than one non-null branch is ambiguous, so its
86
+ values come back as plain hashes. If you need the model to tell "none" apart from "not mentioned", say so in your
87
+ instructions.
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ # append_instructions: extra system-prompt sections placed after the instructions, layered
5
+ # class -> subclass -> method -> call. Each level adds to the levels above it; `false` drops them.
6
+ module Appendices
7
+ ITEM_TYPES = [String, Module, Method, UnboundMethod, Proc].freeze
8
+
9
+ module_function
10
+
11
+ # Validates one level's items. A single item or an Array; `false` (alone or as an element) is kept as a
12
+ # marker that clears everything declared before it.
13
+ def normalize(items, label)
14
+ items = [items] unless items.is_a?(Array)
15
+ items.each { |item| check!(item, label) unless item == false }
16
+ items.dup.freeze
17
+ end
18
+
19
+ # The items in effect once every level is concatenated in order.
20
+ def resolve(items)
21
+ items.reduce([]) { |kept, item| item == false ? [] : kept << item }
22
+ end
23
+
24
+ # Strings verbatim, classes/modules/methods as their source, procs evaluated against the receiver
25
+ # (returning any of those, an Array of them, or nil/false to add nothing, so `-> { strict? && "..." }` works).
26
+ def render(items, receiver, label)
27
+ items.flat_map do |item|
28
+ next [render_item(item)] unless item.is_a?(Proc)
29
+
30
+ values = receiver.instance_exec(&item)
31
+ (values.is_a?(Array) ? values : [values]).select(&:itself).map do |value|
32
+ check!(value, "#{label} append_instructions proc", procs: false)
33
+ render_item(value)
34
+ end
35
+ end
36
+ end
37
+
38
+ def render_item(item)
39
+ item.is_a?(String) ? item : Source.render(item)
40
+ end
41
+
42
+ def check!(item, label, procs: true)
43
+ allowed = procs ? ITEM_TYPES : ITEM_TYPES - [Proc]
44
+ return if allowed.any? { |type| item.is_a?(type) }
45
+
46
+ raise ConfigurationError, "#{label}: append_instructions items must be Strings, classes or modules, " \
47
+ "#{procs ? 'methods, or procs' : 'or methods'} (got #{describe(item)})"
48
+ end
49
+
50
+ # Simple values as written; anything else by class only, since a proc returning the wrong object (a user,
51
+ # a config) shouldn't copy its attributes into an error message.
52
+ def describe(item)
53
+ case item
54
+ when Symbol, Numeric, true, nil then item.inspect
55
+ else "an instance of #{item.class}"
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ # Class-level DSL available to every squishling class.
5
+ module ClassMethods
6
+ DEFAULT_METHOD = :call
7
+
8
+ # Set class-wide options in one call:
9
+ # squishling model: "claude-sonnet-5-5", instructions: "...", output_schema: MySchema
10
+ # Pass provider: alongside model: for models missing from RubyLLM's registry, e.g.
11
+ # squishling model: "gpt-6-luna", provider: :openai
12
+ # params: are generation params merged over the configured defaults (see Configuration#default_params):
13
+ # squishling params: { temperature: 0.1, top_p: 0.9 }
14
+ # append_instructions: adds sections after the instructions (see #append_instructions):
15
+ # squishling append_instructions: ["The Ruby that handles well-formed input:", self]
16
+ def squishling(model: nil, provider: nil, params: nil, instructions: nil, append_instructions: nil,
17
+ output_schema: nil)
18
+ @squishling_model = model if model
19
+ @squishling_provider = provider if provider
20
+ @squishling_params = Params.normalize(params, "#{self} params") if params
21
+ self.instructions(instructions) if instructions
22
+ self.append_instructions(append_instructions) unless append_instructions.nil?
23
+ self.output_schema(output_schema) if output_schema
24
+ self
25
+ end
26
+
27
+ def squishling_model
28
+ squishling_lookup(:@squishling_model)
29
+ end
30
+
31
+ def squishling_provider
32
+ squishling_lookup(:@squishling_provider)
33
+ end
34
+
35
+ # Generation params merged down the inheritance chain, so a subclass overrides individual keys.
36
+ def squishling_params
37
+ squishling_inherited(:squishling_params, {}).merge(@squishling_params || {})
38
+ end
39
+
40
+ # The system prompt. A String, or a Proc evaluated against the instance.
41
+ def instructions(text = nil, &block)
42
+ return squishling_lookup(:@squishling_instructions) if text.nil? && block.nil?
43
+
44
+ @squishling_instructions = block || text
45
+ end
46
+
47
+ # Sections appended to the system prompt after the instructions, added to by subclasses, `squish`, and
48
+ # `squish!`. Items: Strings; a class or module (`self` for this class) or a method (`instance_method(:call)`),
49
+ # sent as its Ruby source; or a Proc evaluated against the instance. `false` drops inherited items.
50
+ # append_instructions "Here is the Ruby that parses well-formed invoices:", self
51
+ # append_instructions { "This client's invoices are in #{currency}." }
52
+ def append_instructions(*items, &block)
53
+ items = items.first if items.size == 1 && items.first.is_a?(Array)
54
+ items += [block] if block
55
+ (@squishling_append_instructions ||= []).concat(Appendices.normalize(items, "#{self} append_instructions"))
56
+ self
57
+ end
58
+
59
+ # Every level's items in declaration order, `false` markers included (see Appendices.resolve).
60
+ def squishling_append_instructions
61
+ squishling_inherited(:squishling_append_instructions, []) + (@squishling_append_instructions || [])
62
+ end
63
+
64
+ # The output format: a Schematist::Schema subclass (RubyLLM::Schema with the ruby_llm-schema shim),
65
+ # a raw JSON Schema Hash, or a Schematist DSL block.
66
+ def output_schema(schema = nil, &block)
67
+ return squishling_lookup(:@squishling_output_schema) if schema.nil? && block.nil?
68
+
69
+ @squishling_output_schema = block ? Schematist::Schema.create(&block) : schema
70
+ end
71
+
72
+ # Routing predicate, called with the method's inputs as keywords and evaluated against the
73
+ # instance. Truthy sends the call to the LLM; falsy runs the Ruby implementation.
74
+ def squish_when(&block)
75
+ @squishling_predicate = block
76
+ end
77
+
78
+ def squishling_predicate
79
+ squishling_lookup(:@squishling_predicate)
80
+ end
81
+
82
+ # Called with the error and the method's inputs (as keywords) when the LLM path fails with an
83
+ # InvalidOutputError or LLMError, evaluated against the instance. Its return value is used as
84
+ # the result (hashes are validated and typed); re-raise to propagate.
85
+ # squish_fallback { |error, **inputs| { priority: "medium", team: "support" } }
86
+ def squish_fallback(&block)
87
+ @squishling_fallback = block
88
+ end
89
+
90
+ def squishling_fallback
91
+ squishling_lookup(:@squishling_fallback)
92
+ end
93
+
94
+ # Instance state (attributes or instance variables) to send to the LLM alongside the arguments.
95
+ def squish_context(*names)
96
+ (@squishling_context_names ||= []).concat(names.map(&:to_sym))
97
+ end
98
+
99
+ def squishling_context_names
100
+ (squishling_inherited(:squishling_context_names, []) + (@squishling_context_names || [])).uniq
101
+ end
102
+
103
+ # Make methods elastic. Each may override the class-level settings:
104
+ # squish :triage, instructions: "...", model: "...", provider: :openai, when: ->(**) { true },
105
+ # fallback: ->(error, **) { { priority: "medium" } }, append_instructions: [...] do
106
+ # string :priority
107
+ # end
108
+ def squish(*names, instructions: nil, append_instructions: nil, output_schema: nil, model: nil, provider: nil,
109
+ params: nil, when: nil, fallback: nil, &schema_block)
110
+ schema = schema_block ? Schematist::Schema.create(&schema_block) : output_schema
111
+ params &&= Params.normalize(params, "#{self} squish params")
112
+ unless append_instructions.nil?
113
+ append_instructions = Appendices.normalize(append_instructions, "#{self} squish append_instructions")
114
+ end
115
+ options = { instructions:, append_instructions:, output_schema: schema, model:, provider:, params:,
116
+ predicate: binding.local_variable_get(:when), fallback: }.compact
117
+
118
+ names.map(&:to_sym).each do |name|
119
+ (@squishling_methods ||= {})[name] = options
120
+ @squishling_wrapper.wrap(name)
121
+ end
122
+ end
123
+
124
+ def squished_methods
125
+ squishling_inherited(:squished_methods, { DEFAULT_METHOD => {} }).merge(@squishling_methods || {})
126
+ end
127
+
128
+ def squishling_definition(name)
129
+ options = squished_methods.fetch(name.to_sym) { raise Error, "#{self}##{name} is not squished" }
130
+ Definition.new(klass: self, name: name.to_sym, **options)
131
+ end
132
+
133
+ # Service-object shortcut: InvoiceParser.call(...) == InvoiceParser.new.call(...)
134
+ def call(...)
135
+ new.call(...)
136
+ end
137
+
138
+ def inherited(subclass)
139
+ super
140
+ subclass.send(:squishling_install_wrapper)
141
+ end
142
+
143
+ private
144
+
145
+ # Each class gets its own prepended wrapper so overrides in subclasses are routed too.
146
+ def squishling_install_wrapper
147
+ @squishling_wrapper = Wrapper.new
148
+ prepend(@squishling_wrapper)
149
+
150
+ squished_methods.each_key { |name| @squishling_wrapper.wrap(name) }
151
+ end
152
+
153
+ # The superclass's merged setting, or the default at the top of the chain.
154
+ def squishling_inherited(reader, default)
155
+ superclass.respond_to?(reader) ? superclass.public_send(reader) : default
156
+ end
157
+
158
+ def squishling_lookup(ivar)
159
+ klass = self
160
+ while klass.is_a?(ClassMethods)
161
+ return klass.instance_variable_get(ivar) if klass.instance_variable_defined?(ivar)
162
+
163
+ klass = klass.superclass
164
+ end
165
+ nil
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ class Configuration
5
+ # Model used by every squishling class that doesn't declare its own.
6
+ # When nil, RubyLLM's own default model is used.
7
+ attr_accessor :default_model
8
+
9
+ # Provider for default_model (e.g. :openai). Only needed for models missing from
10
+ # RubyLLM's registry.
11
+ attr_accessor :default_provider
12
+
13
+ # Generation params applied to every call, overridable per class and per method.
14
+ # :temperature and :thinking ({ effort:, budget: }) map to RubyLLM's with_temperature and
15
+ # with_thinking; any other key (top_p, max_tokens, seed, ...) is passed to the provider as-is.
16
+ attr_reader :default_params
17
+
18
+ # How many times to re-ask the LLM when its output fails schema validation.
19
+ attr_accessor :max_retries
20
+
21
+ # Optional Logger for routing decisions.
22
+ attr_accessor :logger
23
+
24
+ def initialize
25
+ @default_model = nil
26
+ @default_provider = nil
27
+ @default_params = {}
28
+ @max_retries = 1
29
+ @logger = nil
30
+ end
31
+
32
+ def default_params=(params)
33
+ @default_params = Params.normalize(params, "default_params")
34
+ end
35
+ end
36
+ end