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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +51 -0
- data/LICENSE +201 -0
- data/README.md +136 -0
- data/Rakefile +12 -0
- data/SECURITY.md +20 -0
- data/docs/configuration.md +128 -0
- data/docs/failures.md +57 -0
- data/docs/routing.md +210 -0
- data/docs/schemas.md +87 -0
- data/lib/squishling/appendices.rb +59 -0
- data/lib/squishling/class_methods.rb +168 -0
- data/lib/squishling/configuration.rb +36 -0
- data/lib/squishling/definition.rb +156 -0
- data/lib/squishling/errors.rb +26 -0
- data/lib/squishling/invoker.rb +151 -0
- data/lib/squishling/params.rb +66 -0
- data/lib/squishling/result.rb +122 -0
- data/lib/squishling/router.rb +90 -0
- data/lib/squishling/schema.rb +80 -0
- data/lib/squishling/source.rb +194 -0
- data/lib/squishling/version.rb +5 -0
- data/lib/squishling/wrapper.rb +34 -0
- data/lib/squishling.rb +65 -0
- metadata +119 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
# A squished method's settings, with per-method overrides falling back to class-level defaults.
|
|
5
|
+
# `for_call` layers one call's `squish!` overrides on top.
|
|
6
|
+
class Definition
|
|
7
|
+
# Matches the keys a squish! context: Hash may use.
|
|
8
|
+
NAME_KEY = ->(key) { key.is_a?(String) || key.is_a?(Symbol) }
|
|
9
|
+
|
|
10
|
+
attr_reader :klass, :name, :call_context
|
|
11
|
+
|
|
12
|
+
def initialize(klass:, name:, instructions: nil, append_instructions: nil, output_schema: nil, model: nil,
|
|
13
|
+
provider: nil, params: nil, predicate: nil, fallback: nil, call_context: {})
|
|
14
|
+
@klass = klass
|
|
15
|
+
@name = name
|
|
16
|
+
@instructions = instructions
|
|
17
|
+
@append_instructions = append_instructions
|
|
18
|
+
@output_schema = output_schema
|
|
19
|
+
@model = model
|
|
20
|
+
@provider = provider
|
|
21
|
+
@params = params
|
|
22
|
+
@predicate = predicate
|
|
23
|
+
@fallback = fallback
|
|
24
|
+
@call_context = call_context
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# This definition with one call's overrides on top. The output schema and predicate can't be overridden:
|
|
28
|
+
# the call must still return the method's result type.
|
|
29
|
+
def for_call(instructions: nil, append_instructions: nil, context: nil, model: nil, provider: nil, params: nil)
|
|
30
|
+
raise ConfigurationError, "#{label}: squish! provider: needs a model:" if provider && !model
|
|
31
|
+
unless context.nil? || (context.is_a?(Hash) && context.each_key.all?(NAME_KEY))
|
|
32
|
+
raise ConfigurationError, "#{label}: squish! context: must be a Hash with String or Symbol keys"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
appended = Appendices.normalize(append_instructions, "#{label} squish!") unless append_instructions.nil?
|
|
36
|
+
call_params = params && Params.normalize(params, "#{label} squish! params")
|
|
37
|
+
self.class.new(
|
|
38
|
+
klass:, name:, output_schema: @output_schema, predicate: @predicate, fallback: @fallback,
|
|
39
|
+
instructions: instructions || @instructions,
|
|
40
|
+
append_instructions: [*@append_instructions, *appended],
|
|
41
|
+
model: model || @model, provider: model ? provider : @provider,
|
|
42
|
+
# merge, not Params.resolve: a nil at the method level must still unset the class's key.
|
|
43
|
+
params: call_params ? (@params || {}).merge(call_params) : @params,
|
|
44
|
+
call_context: @call_context.merge((context || {}).transform_keys(&:to_sym))
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def label
|
|
49
|
+
"#{klass}##{name}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The system prompt: the instructions, then each append_instructions section.
|
|
53
|
+
def instructions(receiver)
|
|
54
|
+
value = @instructions || klass.instructions
|
|
55
|
+
value = receiver.instance_exec(&value) if value.is_a?(Proc)
|
|
56
|
+
return value if value.nil? || value.empty?
|
|
57
|
+
|
|
58
|
+
[value, *Appendices.render(append_instructions, receiver, label)].join("\n\n")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def append_instructions
|
|
62
|
+
Appendices.resolve(klass.squishling_append_instructions + (@append_instructions || []))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def schema
|
|
66
|
+
raw = @output_schema || klass.output_schema
|
|
67
|
+
raw && Schema.for(raw)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def model
|
|
71
|
+
model_and_provider.first
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def provider
|
|
75
|
+
model_and_provider.last
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A provider travels with the model declared at the same level (method, class, or config),
|
|
79
|
+
# so a per-method Anthropic model never inherits a class-level OpenAI provider.
|
|
80
|
+
def model_and_provider
|
|
81
|
+
config = Squishling.config
|
|
82
|
+
[[@model, @provider], [klass.squishling_model, klass.squishling_provider],
|
|
83
|
+
[config.default_model, config.default_provider]].find(&:first) || [nil, nil]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Generation params: config defaults, overridden key by key by the class, then by the method.
|
|
87
|
+
def params
|
|
88
|
+
Params.resolve(Squishling.config.default_params, klass.squishling_params, @params)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def context_names
|
|
92
|
+
klass.squishling_context_names
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def squish?(receiver, inputs)
|
|
96
|
+
predicate = @predicate || klass.squishling_predicate
|
|
97
|
+
return false unless predicate
|
|
98
|
+
|
|
99
|
+
receiver.instance_exec(**inputs, &predicate) ? true : false
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Runs the elastic path. When the LLM fails (InvalidOutputError or LLMError) and a fallback is
|
|
103
|
+
# declared, the fallback's return value is used instead, coerced like a deterministic return.
|
|
104
|
+
def invoke_llm(receiver, inputs)
|
|
105
|
+
Invoker.new(self, receiver, inputs).call
|
|
106
|
+
rescue InvalidOutputError, LLMError => e
|
|
107
|
+
handler = @fallback || klass.squishling_fallback
|
|
108
|
+
raise unless handler
|
|
109
|
+
|
|
110
|
+
Squishling.config.logger&.warn("[Squishling] #{label} LLM failed, using fallback: #{e.message}")
|
|
111
|
+
coerce(receiver.instance_exec(e, **inputs, &handler))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Deterministic return values: hashes are validated and turned into the typed result;
|
|
115
|
+
# anything else (including an already-built result) passes through.
|
|
116
|
+
def coerce(value)
|
|
117
|
+
value.is_a?(Hash) && schema ? build_result(value, squished: false) : value
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def build_result(attrs, squished:)
|
|
121
|
+
raise ConfigurationError, "#{label} has no output_schema" unless schema
|
|
122
|
+
return attrs if attrs.is_a?(Result::Instance)
|
|
123
|
+
|
|
124
|
+
data = Schema.jsonify(attrs)
|
|
125
|
+
errors = schema.validate(data)
|
|
126
|
+
raise InvalidOutputError.new(errors:, raw: attrs, source: "Deterministic") if errors.any?
|
|
127
|
+
|
|
128
|
+
schema.build(data, squished:)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Map positional and keyword arguments onto the original method's parameter names.
|
|
132
|
+
def bind_arguments(args, kwargs)
|
|
133
|
+
positional = args.dup
|
|
134
|
+
bound = {}
|
|
135
|
+
|
|
136
|
+
parameters.each do |type, param|
|
|
137
|
+
case type
|
|
138
|
+
when :req, :opt
|
|
139
|
+
bound[param] = positional.shift unless positional.empty? || param.nil?
|
|
140
|
+
when :rest
|
|
141
|
+
bound[param.nil? || param == :* ? :args : param] = positional.shift(positional.size)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
positional.each_with_index { |value, index| bound[:"arg#{index}"] = value }
|
|
145
|
+
|
|
146
|
+
bound.merge(kwargs)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
# The implementation's parameters, beneath the prepended wrappers.
|
|
152
|
+
def parameters
|
|
153
|
+
Wrapper.implementation(klass.instance_method(name))&.parameters || []
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# A programming or setup mistake (missing instructions/schema, non-strict schema, unknown model,
|
|
7
|
+
# missing API key). Never retried and never passed to squish_fallback.
|
|
8
|
+
class ConfigurationError < Error; end
|
|
9
|
+
|
|
10
|
+
# Output that didn't match the schema: from the LLM after all retries, or from the deterministic path.
|
|
11
|
+
class InvalidOutputError < Error
|
|
12
|
+
attr_reader :errors, :raw, :attempts
|
|
13
|
+
|
|
14
|
+
def initialize(errors:, raw: nil, source: "LLM", attempts: nil)
|
|
15
|
+
@errors = errors
|
|
16
|
+
@raw = raw
|
|
17
|
+
@attempts = attempts
|
|
18
|
+
tries = attempts ? " after #{attempts} attempt#{'s' unless attempts == 1}" : ""
|
|
19
|
+
super("#{source} output did not match the output schema#{tries}: #{errors.join('; ')}")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The LLM call itself failed (rate limit, server error, timeout, connection) after RubyLLM's own
|
|
24
|
+
# HTTP retries. The original exception is available as #cause.
|
|
25
|
+
class LLMError < Error; end
|
|
26
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
# The elastic path: sends a squished method's inputs through RubyLLM with structured output.
|
|
5
|
+
class Invoker
|
|
6
|
+
INPUT_NOTE = <<~NOTE
|
|
7
|
+
The input is a JSON object. "arguments" holds the values passed to this operation; "context", when
|
|
8
|
+
present, holds additional state about the caller. Respond only with JSON matching the required schema.
|
|
9
|
+
NOTE
|
|
10
|
+
|
|
11
|
+
def initialize(definition, receiver, inputs)
|
|
12
|
+
@definition = definition
|
|
13
|
+
@receiver = receiver
|
|
14
|
+
@inputs = inputs
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call
|
|
18
|
+
instructions = @definition.instructions(@receiver)
|
|
19
|
+
schema = @definition.schema
|
|
20
|
+
raise ConfigurationError, "#{@definition.label} has no instructions" if instructions.nil? || instructions.empty?
|
|
21
|
+
raise ConfigurationError, "#{@definition.label} has no output_schema" unless schema
|
|
22
|
+
|
|
23
|
+
chat = build_chat
|
|
24
|
+
chat.with_instructions("#{instructions}\n\n#{INPUT_NOTE}")
|
|
25
|
+
chat.with_schema(schema.llm_schema)
|
|
26
|
+
apply_params(chat)
|
|
27
|
+
|
|
28
|
+
response = ask(chat, JSON.generate(payload))
|
|
29
|
+
attempts = 0
|
|
30
|
+
loop do
|
|
31
|
+
attempts += 1
|
|
32
|
+
data, errors = parse(response.content)
|
|
33
|
+
errors = schema.validate(data) if errors.empty?
|
|
34
|
+
return schema.build(data, squished: true) if errors.empty?
|
|
35
|
+
|
|
36
|
+
if attempts > Squishling.config.max_retries
|
|
37
|
+
raise InvalidOutputError.new(errors:, raw: response.content, attempts:)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
response = ask(chat, retry_message(errors))
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def build_chat
|
|
47
|
+
RubyLLM.chat(**chat_options)
|
|
48
|
+
rescue RubyLLM::ModelNotFoundError, RubyLLM::ConfigurationError => e
|
|
49
|
+
raise ConfigurationError, "#{@definition.label}: #{e.message}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# RubyLLM validates some settings locally (e.g. an impossible thinking budget for the model)
|
|
53
|
+
# and raises ArgumentError before any request is sent.
|
|
54
|
+
def apply_params(chat)
|
|
55
|
+
Params.apply(chat, @definition.params)
|
|
56
|
+
rescue ArgumentError => e
|
|
57
|
+
raise ConfigurationError, "#{@definition.label}: invalid params #{@definition.params.inspect} (#{e.message})"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Transient HTTP failures are already retried by RubyLLM (config.max_retries); anything that
|
|
61
|
+
# still fails is surfaced as an LLMError rather than retried again here. A 400 means the request
|
|
62
|
+
# we built is invalid (an unsupported param, a schema the provider rejects), so it's a setup
|
|
63
|
+
# mistake: a fallback would otherwise hide it on every call.
|
|
64
|
+
def ask(chat, message)
|
|
65
|
+
chat.ask(message)
|
|
66
|
+
rescue RubyLLM::ConfigurationError, RubyLLM::UnauthorizedError, RubyLLM::ForbiddenError => e
|
|
67
|
+
raise ConfigurationError, "#{@definition.label}: #{e.class}: #{e.message}"
|
|
68
|
+
rescue RubyLLM::BadRequestError => e
|
|
69
|
+
raise ConfigurationError, "#{@definition.label}: the provider rejected the request (#{e.message})#{params_hint}"
|
|
70
|
+
rescue RubyLLM::Error, Faraday::Error => e
|
|
71
|
+
raise LLMError, "#{@definition.label}: #{e.class}: #{e.message}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Models missing from RubyLLM's registry (e.g. newly released ones) are only usable when a
|
|
75
|
+
# provider is named, so RubyLLM is told to assume they exist.
|
|
76
|
+
def chat_options
|
|
77
|
+
model = @definition.model
|
|
78
|
+
provider = @definition.provider
|
|
79
|
+
options = { model:, provider: }.compact
|
|
80
|
+
options[:assume_model_exists] = true if model && provider && !known_model?(model, provider)
|
|
81
|
+
options
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def known_model?(model, provider)
|
|
85
|
+
RubyLLM.models.find(model, provider:)
|
|
86
|
+
true
|
|
87
|
+
rescue RubyLLM::ModelNotFoundError
|
|
88
|
+
false
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def params_hint
|
|
92
|
+
params = @definition.params
|
|
93
|
+
return "" if params.empty?
|
|
94
|
+
|
|
95
|
+
". Check params #{params.inspect}; reasoning models often reject sampling params such as temperature and top_p"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Only the arguments, the declared squish_context names, and a squish! call's context leave the process.
|
|
99
|
+
def payload
|
|
100
|
+
body = { arguments: Schema.jsonify(describe(@inputs)) }
|
|
101
|
+
context = @definition.context_names.to_h { |name| [name, context_value(name)] }.merge(@definition.call_context)
|
|
102
|
+
body[:context] = Schema.jsonify(describe(context)) if context.any?
|
|
103
|
+
body
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Exceptions are sent as their class and message. Plain JSON would send only the message, and with
|
|
107
|
+
# json/add/exception loaded it would also send the backtrace, which exposes file paths.
|
|
108
|
+
def describe(value)
|
|
109
|
+
case value
|
|
110
|
+
when Exception then { class: exception_class_name(value.class), message: value.message }
|
|
111
|
+
when Hash then value.transform_values { |item| describe(item) }
|
|
112
|
+
when Array then value.map { |item| describe(item) }
|
|
113
|
+
else value
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# An anonymous error class (Class.new(StandardError)) is named after its closest named ancestor.
|
|
118
|
+
def exception_class_name(klass)
|
|
119
|
+
klass = klass.superclass until klass.name
|
|
120
|
+
klass.name
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def context_value(name)
|
|
124
|
+
return @receiver.send(name) if @receiver.respond_to?(name, true)
|
|
125
|
+
|
|
126
|
+
@receiver.instance_variable_get(:"@#{name}")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# RubyLLM parses structured output itself and leaves the raw string when that fails, so
|
|
130
|
+
# content is a Hash on success, or a String/nil when the model refused, was cut off, or
|
|
131
|
+
# ignored the schema.
|
|
132
|
+
def parse(content)
|
|
133
|
+
return [nil, ["response was empty"]] if content.nil? || (content.is_a?(String) && content.strip.empty?)
|
|
134
|
+
return [content, []] unless content.is_a?(String)
|
|
135
|
+
|
|
136
|
+
[JSON.parse(strip_code_fence(content)), []]
|
|
137
|
+
rescue JSON::ParserError => e
|
|
138
|
+
[nil, ["response was not valid JSON (#{e.message.lines.first&.strip})"]]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Models without native structured output sometimes wrap JSON in a markdown code fence.
|
|
142
|
+
def strip_code_fence(text)
|
|
143
|
+
text[/\A\s*```(?:json)?\s*\n(.*?)\n\s*```\s*\z/m, 1] || text
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def retry_message(errors)
|
|
147
|
+
"Your previous response did not match the required schema:\n- #{errors.join("\n- ")}\n" \
|
|
148
|
+
"Respond again with corrected JSON only."
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
# Generation params (temperature, thinking, max_output_tokens, top_p, ...) layered config -> class -> method.
|
|
5
|
+
module Params
|
|
6
|
+
THINKING_KEYS = %i[effort budget display].freeze
|
|
7
|
+
|
|
8
|
+
# Request keys Squishling or RubyLLM own. Provider options are merged into the request last,
|
|
9
|
+
# overriding RubyLLM's defaults, so these would silently replace the model, the conversation,
|
|
10
|
+
# or the strict output format.
|
|
11
|
+
RESERVED_KEYS = %i[
|
|
12
|
+
model messages input instructions contents system system_instruction stream stream_options store include
|
|
13
|
+
response_format text output_config tools tool_choice schema
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Validates and symbolizes one layer. nil values are kept so a layer can unset an inherited key.
|
|
19
|
+
def normalize(params, label)
|
|
20
|
+
raise ConfigurationError, "#{label} must be a Hash, got #{params.class}" unless params.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
params = params.transform_keys(&:to_sym)
|
|
23
|
+
reserved = params.keys & RESERVED_KEYS
|
|
24
|
+
if reserved.any?
|
|
25
|
+
raise ConfigurationError, "#{label}: #{reserved.join(', ')} can't be set through params " \
|
|
26
|
+
"(controlled by Squishling/RubyLLM; use model:/provider:/output_schema)"
|
|
27
|
+
end
|
|
28
|
+
params[:thinking] = normalize_thinking(params[:thinking], label) unless params[:thinking].nil?
|
|
29
|
+
params.freeze
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Later layers override earlier ones key by key; nil removes the key (back to the provider default).
|
|
33
|
+
def resolve(*layers)
|
|
34
|
+
layers.compact.reduce({}) { |merged, layer| merged.merge(layer) }.compact
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Portable settings use RubyLLM's dedicated setters, which translate them for each provider;
|
|
38
|
+
# everything else is merged into the provider request as-is.
|
|
39
|
+
def apply(chat, params)
|
|
40
|
+
rest = params.except(:temperature, :thinking, :max_output_tokens)
|
|
41
|
+
chat.with_temperature(params[:temperature]) if params.key?(:temperature)
|
|
42
|
+
chat.with_max_output_tokens(params[:max_output_tokens]) if params.key?(:max_output_tokens)
|
|
43
|
+
apply_thinking(chat, params[:thinking]) if params.key?(:thinking)
|
|
44
|
+
chat.with_provider_options(rest) if rest.any?
|
|
45
|
+
chat
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# true: the model's default thinking; false: off; a Hash: { effort:, budget:, display: }.
|
|
49
|
+
def apply_thinking(chat, thinking)
|
|
50
|
+
thinking.is_a?(Hash) ? chat.with_thinking(**thinking) : chat.with_thinking(thinking)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def normalize_thinking(thinking, label)
|
|
54
|
+
return thinking if [true, false].include?(thinking)
|
|
55
|
+
|
|
56
|
+
thinking = thinking.transform_keys(&:to_sym) if thinking.is_a?(Hash)
|
|
57
|
+
unless thinking.is_a?(Hash) && thinking.any? && (thinking.keys - THINKING_KEYS).empty? && !thinking.value?(nil)
|
|
58
|
+
raise ConfigurationError,
|
|
59
|
+
"#{label}: thinking must be true, false, or a Hash with :effort, :budget and/or :display, " \
|
|
60
|
+
"got #{thinking.inspect}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
thinking
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
# Builds Data classes from a JSON Schema so both the LLM and deterministic paths return
|
|
5
|
+
# the same typed object.
|
|
6
|
+
module Result
|
|
7
|
+
# Mixed into every generated result class.
|
|
8
|
+
module Instance
|
|
9
|
+
def initialize(squishling_squished: false, **attrs)
|
|
10
|
+
@squished = squishling_squished
|
|
11
|
+
super(**attrs)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# True when this result came from the LLM rather than Ruby code.
|
|
15
|
+
def squished?
|
|
16
|
+
@squished
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def [](key)
|
|
20
|
+
members.include?(key.to_sym) ? public_send(key.to_sym) : nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_h
|
|
24
|
+
super.transform_values { |value| Result.deep_to_h(value) }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
module ClassMethods
|
|
29
|
+
def from_h(hash, squished: false)
|
|
30
|
+
hash = hash.transform_keys(&:to_sym)
|
|
31
|
+
attrs = members.to_h { |member| [member, squishling_fields[member].call(hash[member], squished)] }
|
|
32
|
+
new(squishling_squished: squished, **attrs)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def squishling_fields
|
|
38
|
+
@squishling_fields ||= @squishling_builder.fields_for(@squishling_schema)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
def build(json_schema)
|
|
44
|
+
Builder.new(json_schema).class_for(json_schema)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def deep_to_h(value)
|
|
48
|
+
case value
|
|
49
|
+
when Instance then value.to_h
|
|
50
|
+
when Array then value.map { |item| deep_to_h(item) }
|
|
51
|
+
else value
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Resolves $refs and builds (memoized, so recursive schemas work) one Data class per object schema.
|
|
57
|
+
class Builder
|
|
58
|
+
IDENTITY = ->(value, _squished) { value.is_a?(Hash) ? value.transform_keys(&:to_sym) : value }
|
|
59
|
+
|
|
60
|
+
def initialize(root)
|
|
61
|
+
@root = root
|
|
62
|
+
@classes = {}.compare_by_identity
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Returns a Data class, or nil when the schema isn't an object with properties.
|
|
66
|
+
def class_for(schema)
|
|
67
|
+
schema = resolve(schema)
|
|
68
|
+
return nil unless schema.is_a?(Hash) && schema["properties"].is_a?(Hash)
|
|
69
|
+
|
|
70
|
+
@classes[schema] ||= begin
|
|
71
|
+
klass = Data.define(*schema["properties"].keys.map(&:to_sym))
|
|
72
|
+
klass.include(Instance)
|
|
73
|
+
klass.extend(ClassMethods)
|
|
74
|
+
klass.instance_variable_set(:@squishling_builder, self)
|
|
75
|
+
klass.instance_variable_set(:@squishling_schema, schema)
|
|
76
|
+
klass
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def fields_for(schema)
|
|
81
|
+
schema["properties"].to_h { |key, property| [key.to_sym, converter_for(property)] }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def converter_for(property)
|
|
87
|
+
property = resolve(property)
|
|
88
|
+
return IDENTITY unless property.is_a?(Hash)
|
|
89
|
+
|
|
90
|
+
# `optional` (anyOf [schema, null]) is typed as its schema; nil passes through every converter.
|
|
91
|
+
if (branch = nullable_branch(property))
|
|
92
|
+
converter_for(branch)
|
|
93
|
+
elsif (klass = class_for(property))
|
|
94
|
+
->(value, squished) { value.is_a?(Hash) ? klass.from_h(value, squished:) : value }
|
|
95
|
+
elsif property["items"]
|
|
96
|
+
item = converter_for(property["items"])
|
|
97
|
+
->(value, squished) { value.is_a?(Array) ? value.map { |v| item.call(v, squished) } : value }
|
|
98
|
+
else
|
|
99
|
+
IDENTITY
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The single non-null branch of an anyOf/oneOf. Unions with several non-null branches are
|
|
104
|
+
# ambiguous, so their values are left untyped.
|
|
105
|
+
def nullable_branch(property)
|
|
106
|
+
union = property["anyOf"] || property["oneOf"]
|
|
107
|
+
return unless union.is_a?(Array)
|
|
108
|
+
|
|
109
|
+
branches = union.reject { |branch| resolve(branch).is_a?(Hash) && resolve(branch)["type"] == "null" }
|
|
110
|
+
branches.first if branches.size == 1
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def resolve(schema)
|
|
114
|
+
return schema unless schema.is_a?(Hash) && schema["$ref"].is_a?(String)
|
|
115
|
+
return schema unless schema["$ref"].start_with?("#/")
|
|
116
|
+
|
|
117
|
+
path = schema["$ref"].delete_prefix("#/").split("/")
|
|
118
|
+
@root.dig(*path) || schema
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
# Decides, per call, whether a squished method runs its Ruby implementation or the LLM.
|
|
5
|
+
module Router
|
|
6
|
+
# One squished call in progress. `phase` is :routing (deciding, e.g. running squish_when), :ruby (running
|
|
7
|
+
# the implementation), or :llm (on the LLM path, including any fallback); `escalated` records that squish!
|
|
8
|
+
# already sent it to the LLM.
|
|
9
|
+
Frame = Struct.new(:receiver, :definition, :wrapper, :inputs, :phase, :escalated)
|
|
10
|
+
|
|
11
|
+
FRAMES_KEY = :__squishling_frames__
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def dispatch(receiver, name, args, kwargs, impl, wrapper:)
|
|
15
|
+
if (frame = enclosing_frame(receiver, name, wrapper))
|
|
16
|
+
return frame.definition.coerce(impl.call)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
definition = receiver.class.squishling_definition(name)
|
|
20
|
+
frame = Frame.new(receiver, definition, wrapper, definition.bind_arguments(args, kwargs), :routing, false)
|
|
21
|
+
frames.push(frame)
|
|
22
|
+
begin
|
|
23
|
+
return route_to_llm(frame, definition, "predicate") if definition.squish?(receiver, frame.inputs)
|
|
24
|
+
|
|
25
|
+
begin
|
|
26
|
+
frame.phase = :ruby
|
|
27
|
+
value = impl.call
|
|
28
|
+
rescue NotImplementedError
|
|
29
|
+
# After squish!, the error came from the LLM path (a fallback, a proc), not a missing implementation.
|
|
30
|
+
raise if frame.escalated
|
|
31
|
+
|
|
32
|
+
return route_to_llm(frame, definition, "not implemented")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
definition.coerce(value)
|
|
36
|
+
ensure
|
|
37
|
+
frames.pop
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def current_frame(receiver)
|
|
42
|
+
frames.reverse_each.find { |frame| frame.receiver.equal?(receiver) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# squish!: hand the call in progress on this receiver to the LLM, with this call's overrides.
|
|
46
|
+
def escalate(receiver, overrides)
|
|
47
|
+
frame = current_frame(receiver)
|
|
48
|
+
raise Error, "#{receiver.class}#squish! called outside a squished method" unless frame
|
|
49
|
+
unless frame.phase == :ruby
|
|
50
|
+
raise Error, "#{frame.definition.label}: squish! can only be called from the method's Ruby implementation, " \
|
|
51
|
+
"not from squish_when or while already on the LLM path (e.g. from squish_fallback)"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
frame.escalated = true
|
|
55
|
+
route_to_llm(frame, frame.definition.for_call(**overrides), "squish!")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def route_to_llm(frame, definition, reason)
|
|
61
|
+
log(definition, reason)
|
|
62
|
+
previous = frame.phase
|
|
63
|
+
frame.phase = :llm
|
|
64
|
+
definition.invoke_llm(frame.receiver, frame.inputs)
|
|
65
|
+
ensure
|
|
66
|
+
frame.phase = previous
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Thread#[] is fiber-local, so concurrent fibers don't share frames.
|
|
70
|
+
def frames
|
|
71
|
+
Thread.current[FRAMES_KEY] ||= []
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The call in progress that this one is part of, which runs its Ruby implementation without routing again:
|
|
75
|
+
# a subclass override's `super` (entered through an ancestor's wrapper), or a call made while deciding or on
|
|
76
|
+
# the LLM path (squish_when, squish_fallback, an instructions proc). Only recursion from the Ruby
|
|
77
|
+
# implementation itself is a new call, routed on its own.
|
|
78
|
+
def enclosing_frame(receiver, name, wrapper)
|
|
79
|
+
frame = frames.reverse_each.find do |candidate|
|
|
80
|
+
candidate.receiver.equal?(receiver) && candidate.definition.name == name
|
|
81
|
+
end
|
|
82
|
+
frame unless frame.nil? || (frame.wrapper.equal?(wrapper) && frame.phase == :ruby)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def log(definition, reason)
|
|
86
|
+
Squishling.config.logger&.debug("[Squishling] #{definition.label} -> LLM (#{reason})")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
# Normalizes the supported output-schema forms into a JSON Schema, validates data against it,
|
|
5
|
+
# and builds typed results.
|
|
6
|
+
class Schema
|
|
7
|
+
CACHE = {}.compare_by_identity
|
|
8
|
+
CACHE_LOCK = Mutex.new
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def for(raw)
|
|
12
|
+
CACHE_LOCK.synchronize { CACHE[raw] ||= new(raw) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Deep-convert to plain JSON types (string keys), as the LLM would return them.
|
|
16
|
+
def jsonify(value)
|
|
17
|
+
JSON.parse(JSON.generate(value))
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The payload handed to RubyLLM's `with_schema`: always strict.
|
|
22
|
+
attr_reader :llm_schema
|
|
23
|
+
# The bare JSON Schema used for validation and result typing.
|
|
24
|
+
attr_reader :json_schema
|
|
25
|
+
|
|
26
|
+
def initialize(raw)
|
|
27
|
+
hash = self.class.jsonify(to_hash(raw))
|
|
28
|
+
wrapped = wrapped?(hash)
|
|
29
|
+
body = wrapped ? hash["schema"] : hash
|
|
30
|
+
|
|
31
|
+
# Same precedence RubyLLM uses: a top-level strict flag wins over one inside the schema.
|
|
32
|
+
strict = hash.key?("strict") ? hash["strict"] : body["strict"]
|
|
33
|
+
raise ConfigurationError, "Squishling only supports strict output schemas (got strict: false)" if strict == false
|
|
34
|
+
|
|
35
|
+
@json_schema = body.except("strict")
|
|
36
|
+
@llm_schema = {
|
|
37
|
+
"name" => hash["name"] || body["title"],
|
|
38
|
+
"description" => hash["description"] || body["description"],
|
|
39
|
+
"schema" => @json_schema,
|
|
40
|
+
"strict" => true
|
|
41
|
+
}.compact
|
|
42
|
+
@validator = JSONSchemer.schema(@json_schema)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def validate(data)
|
|
46
|
+
@validator.validate(data).map { |error| error["error"] }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def build(data, squished:)
|
|
50
|
+
result_class ? result_class.from_h(data, squished:) : data
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def result_class
|
|
54
|
+
return @result_class if defined?(@result_class)
|
|
55
|
+
|
|
56
|
+
@result_class = Result.build(@json_schema)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def to_hash(raw)
|
|
62
|
+
case raw
|
|
63
|
+
when Hash then raw
|
|
64
|
+
when Class
|
|
65
|
+
raise ConfigurationError, "#{raw} is not a Schematist::Schema" unless raw <= Schematist::Schema
|
|
66
|
+
|
|
67
|
+
raw.new.to_json_schema
|
|
68
|
+
else
|
|
69
|
+
return raw.to_json_schema if raw.respond_to?(:to_json_schema)
|
|
70
|
+
|
|
71
|
+
raise ConfigurationError, "Unsupported output_schema: #{raw.inspect}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Some schema objects emit a { name:, description:, schema: {...} } wrapper instead of a bare schema.
|
|
76
|
+
def wrapped?(hash)
|
|
77
|
+
hash["schema"].is_a?(Hash) && !hash.key?("type") && !hash.key?("properties")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|