squishling 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +74 -0
- data/README.md +3 -3
- data/docs/adapters.md +86 -18
- data/docs/configuration.md +3 -3
- data/docs/failures.md +55 -12
- data/docs/harnesses.md +15 -1
- data/docs/measuring-tokens.md +4 -0
- data/docs/observers.md +3 -3
- data/docs/routing.md +9 -3
- data/docs/schemas.md +13 -0
- data/docs/testing.md +7 -3
- data/docs/verifying.md +4 -2
- data/lib/squishling/adapter/error_body.rb +9 -2
- data/lib/squishling/adapter.rb +62 -9
- data/lib/squishling/adapters/openai_compatible.rb +58 -51
- data/lib/squishling/adapters/openai_options.rb +45 -3
- data/lib/squishling/adapters/openai_transport.rb +123 -0
- data/lib/squishling/adapters/ruby_llm.rb +51 -15
- data/lib/squishling/class_methods.rb +2 -2
- data/lib/squishling/configuration.rb +2 -1
- data/lib/squishling/errors.rb +7 -5
- data/lib/squishling/invoker.rb +24 -6
- data/lib/squishling/observer.rb +2 -2
- data/lib/squishling/params.rb +2 -2
- data/lib/squishling/result.rb +13 -0
- data/lib/squishling/router.rb +3 -2
- data/lib/squishling/run/reports.rb +63 -0
- data/lib/squishling/run.rb +5 -22
- data/lib/squishling/schema.rb +44 -4
- data/lib/squishling/secret.rb +45 -0
- data/lib/squishling/squawk.rb +7 -4
- data/lib/squishling/testing/state.rb +2 -1
- data/lib/squishling/version.rb +1 -1
- data/lib/squishling/wrapper.rb +19 -1
- data/lib/squishling.rb +3 -0
- metadata +4 -1
|
@@ -37,7 +37,8 @@ module Squishling
|
|
|
37
37
|
# Optional Logger for routing and escalation decisions.
|
|
38
38
|
attr_accessor :logger
|
|
39
39
|
|
|
40
|
-
# Optional callable run after each LLM attempt (narrowed by squawk_on)
|
|
40
|
+
# Optional callable run after each LLM attempt (narrowed by squawk_on), and once more when a token budget or a
|
|
41
|
+
# disagreement stops the call, with the raw output (see Squawk), e.g.
|
|
41
42
|
# ->(output:, metadata:, error:) { Tracer.record(output, metadata, error) }
|
|
42
43
|
attr_reader :squawk
|
|
43
44
|
|
data/lib/squishling/errors.rb
CHANGED
|
@@ -7,10 +7,11 @@ module Squishling
|
|
|
7
7
|
# missing API key). Never retried and never passed to squish_fallback.
|
|
8
8
|
class ConfigurationError < Error; end
|
|
9
9
|
|
|
10
|
-
# The provider answered a request with HTTP 400 (or another 4xx that isn't a credentials, model,
|
|
11
|
-
#
|
|
12
|
-
# never
|
|
13
|
-
#
|
|
10
|
+
# The provider answered a request with HTTP 400 (or another 4xx that isn't a credentials, billing, model, redirect,
|
|
11
|
+
# or too-long-input problem; the adapter decides which, see Adapter::Base#http_failure). Still a ConfigurationError:
|
|
12
|
+
# never retried, escalated, or passed to a fallback. The provider's message is never repeated; the machine-readable
|
|
13
|
+
# status and code its error body carries are, when they are plain tokens (e.g. "INVALID_ARGUMENT",
|
|
14
|
+
# "invalid_request_error"). Both are nil when the body has none.
|
|
14
15
|
class RequestRejectedError < ConfigurationError
|
|
15
16
|
attr_reader :provider_status, :provider_code
|
|
16
17
|
|
|
@@ -130,6 +131,7 @@ module Squishling
|
|
|
130
131
|
end
|
|
131
132
|
|
|
132
133
|
# The LLM call itself failed (rate limit, server error, timeout, connection) after the
|
|
133
|
-
# adapter's own HTTP retries.
|
|
134
|
+
# adapter's own HTTP retries. #cause is the adapter's: a sanitized stand-in (Adapter::ProviderFailure) for a provider
|
|
135
|
+
# exception that carries a response body, or the transport error itself when it carries none.
|
|
134
136
|
class LLMError < Error; end
|
|
135
137
|
end
|
data/lib/squishling/invoker.rb
CHANGED
|
@@ -25,13 +25,14 @@ module Squishling
|
|
|
25
25
|
Built = Data.define(:prompt, :payload, :purpose)
|
|
26
26
|
|
|
27
27
|
# Runs the harness (Definition#harness) over a Run of the call: the harness decides how the escalation is
|
|
28
|
-
# used, and what it returns is checked against the output schema's type.
|
|
28
|
+
# used, and what it returns is checked against the output schema's type. A call that ends on the token budget
|
|
29
|
+
# or a disagreement is reported to squawk once here, as the stop no attempt of its own reported.
|
|
29
30
|
def call
|
|
30
31
|
built = build
|
|
31
32
|
harness = @definition.harness
|
|
32
33
|
run = Run.new(definition: @definition, receiver: @receiver, inputs: @inputs, prompt: built.prompt,
|
|
33
34
|
payload: built.payload, purpose: built.purpose, harness:)
|
|
34
|
-
accepted(harness
|
|
35
|
+
accepted(call_harness(harness, run), run, harness)
|
|
35
36
|
end
|
|
36
37
|
|
|
37
38
|
# The first request the call would make (system prompt, output schema, user message), without making it.
|
|
@@ -41,6 +42,13 @@ module Squishling
|
|
|
41
42
|
|
|
42
43
|
private
|
|
43
44
|
|
|
45
|
+
def call_harness(harness, run)
|
|
46
|
+
harness.klass.new.call(run, **harness.options)
|
|
47
|
+
rescue BudgetExhaustedError, DisagreementError => e
|
|
48
|
+
run.report_stop(e)
|
|
49
|
+
raise
|
|
50
|
+
end
|
|
51
|
+
|
|
44
52
|
def build
|
|
45
53
|
purpose = @definition.purpose(@receiver)
|
|
46
54
|
schema = @definition.schema
|
|
@@ -70,16 +78,26 @@ module Squishling
|
|
|
70
78
|
end
|
|
71
79
|
|
|
72
80
|
# Exceptions are sent as their class and message. Plain JSON would send only the message, and with
|
|
73
|
-
# json/add/exception loaded it would also send the backtrace, which exposes file paths.
|
|
74
|
-
|
|
81
|
+
# json/add/exception loaded it would also send the backtrace, which exposes file paths. path is the Hashes and
|
|
82
|
+
# Arrays being walked: one that contains itself is a ConfigurationError rather than a SystemStackError.
|
|
83
|
+
def describe(value, path = [])
|
|
75
84
|
case value
|
|
76
85
|
when Exception then { class: exception_class_name(value.class), message: value.message }
|
|
77
|
-
when Hash then value.transform_values { |item| describe(item) }
|
|
78
|
-
when Array then value.map { |item| describe(item) }
|
|
86
|
+
when Hash then within(value, path) { |inner| value.transform_values { |item| describe(item, inner) } }
|
|
87
|
+
when Array then within(value, path) { |inner| value.map { |item| describe(item, inner) } }
|
|
79
88
|
else value
|
|
80
89
|
end
|
|
81
90
|
end
|
|
82
91
|
|
|
92
|
+
def within(container, path)
|
|
93
|
+
if path.any? { |ancestor| ancestor.equal?(container) }
|
|
94
|
+
raise ConfigurationError, "#{@definition.label}: an argument or context value contains itself, " \
|
|
95
|
+
"so it can't be sent as JSON"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
yield path + [container]
|
|
99
|
+
end
|
|
100
|
+
|
|
83
101
|
# An anonymous error class (Class.new(StandardError)) is named after its closest named ancestor.
|
|
84
102
|
def exception_class_name(klass)
|
|
85
103
|
klass = klass.superclass until klass.name
|
data/lib/squishling/observer.rb
CHANGED
|
@@ -33,12 +33,12 @@ module Squishling
|
|
|
33
33
|
return observer if BUILT_IN.include?(observer)
|
|
34
34
|
|
|
35
35
|
raise ConfigurationError, "#{where} observer must be one of #{BUILT_IN.map(&:inspect).join(', ')}, " \
|
|
36
|
-
"an object that responds to around, or false, got #{observer
|
|
36
|
+
"an object that responds to around, or false, got #{Appendices.describe(observer)}"
|
|
37
37
|
end
|
|
38
38
|
return observer if observer.respond_to?(:around)
|
|
39
39
|
|
|
40
40
|
raise ConfigurationError, "#{where} observer must respond to around(call) { ... } (or be false), " \
|
|
41
|
-
"got #{observer
|
|
41
|
+
"got #{Appendices.describe(observer)}"
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
# The observer that wraps a request: the configured one, the Coolhand observer when nothing is configured and
|
data/lib/squishling/params.rb
CHANGED
|
@@ -9,8 +9,8 @@ module Squishling
|
|
|
9
9
|
# adapter builds, so these would silently replace the model, the conversation, or the strict output format.
|
|
10
10
|
RESERVED_KEYS = %i[
|
|
11
11
|
model messages input inputs instructions contents system system_instruction systemInstruction cachedContent
|
|
12
|
-
stream stream_options store include response_format text output_config outputConfig tools
|
|
13
|
-
toolConfig tool_config schema functions function_call
|
|
12
|
+
cached_content stream stream_options store include response_format text output_config outputConfig tools
|
|
13
|
+
tool_choice toolConfig tool_config schema functions function_call
|
|
14
14
|
].freeze
|
|
15
15
|
|
|
16
16
|
# Some protocols carry the structured-output format (or tool selection) inside a container that is
|
data/lib/squishling/result.rb
CHANGED
|
@@ -74,6 +74,7 @@ module Squishling
|
|
|
74
74
|
|
|
75
75
|
return @classes[schema] if @classes.key?(schema)
|
|
76
76
|
|
|
77
|
+
require_object_type!(schema)
|
|
77
78
|
klass = @classes[schema] = define_class(schema)
|
|
78
79
|
# Memoized first so recursive schemas stop; typing the fields builds nested classes now, so a bad
|
|
79
80
|
# property name in any object the result types (nested, array items, resolved $refs) raises at schema build.
|
|
@@ -83,6 +84,18 @@ module Squishling
|
|
|
83
84
|
|
|
84
85
|
private
|
|
85
86
|
|
|
87
|
+
# `properties` alone constrains objects only, so a schema without `type: "object"` accepts a String or Array
|
|
88
|
+
# that would then sit in the typed result where a nested result is expected. A raw Hash schema must say so.
|
|
89
|
+
def require_object_type!(schema)
|
|
90
|
+
type = schema["type"]
|
|
91
|
+
return if type == "object" || (type.is_a?(Array) && type.include?("object"))
|
|
92
|
+
|
|
93
|
+
raise ConfigurationError,
|
|
94
|
+
"output_schema has a schema with `properties` whose `type` isn't \"object\" (or missing), so a non-object " \
|
|
95
|
+
"value would pass validation and end up in the typed result. Set `type: \"object\"` on it " \
|
|
96
|
+
"(a union such as [\"object\", \"null\"] is also fine)"
|
|
97
|
+
end
|
|
98
|
+
|
|
86
99
|
def fields_for(schema)
|
|
87
100
|
schema["properties"].to_h { |key, property| [key.to_sym, converter_for(property)] }
|
|
88
101
|
end
|
data/lib/squishling/router.rb
CHANGED
|
@@ -25,9 +25,10 @@ module Squishling
|
|
|
25
25
|
begin
|
|
26
26
|
frame.phase = :ruby
|
|
27
27
|
value = impl.call
|
|
28
|
-
rescue NotImplementedError
|
|
28
|
+
rescue NotImplementedError => e
|
|
29
29
|
# After squish!, the error came from the LLM path (a fallback, a proc), not a missing implementation.
|
|
30
|
-
|
|
30
|
+
# Otherwise it propagates unless it is the method's own stub (see Wrapper.stub?).
|
|
31
|
+
raise if frame.handed_off || !Wrapper.stub?(e)
|
|
31
32
|
|
|
32
33
|
return route_to_llm(frame, definition, "not implemented")
|
|
33
34
|
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
class Run
|
|
5
|
+
# What a Run tells the squawk hook (see Squawk): the report of each attempt, and the one final report of a call that
|
|
6
|
+
# a token budget or a disagreement stopped.
|
|
7
|
+
module Reports
|
|
8
|
+
# Set on a stop error once it has been reported (see #report_stop).
|
|
9
|
+
REPORTED = :@squawk_reported
|
|
10
|
+
private_constant :REPORTED
|
|
11
|
+
|
|
12
|
+
# Reports to squawk the BudgetExhaustedError or DisagreementError that stopped the call: one final report with
|
|
13
|
+
# the error, for an ending that no attempt of its own reported (the attempt before a budget stop was already
|
|
14
|
+
# reported, as not final, and a disagreement's samples were both accepted). Nothing was sent, so there is no
|
|
15
|
+
# output, and the provider and params are unknown (the stop comes from the harness, not from a step).
|
|
16
|
+
# Internal: Invoker calls it once, whatever the harness, so a custom harness has nothing to do.
|
|
17
|
+
def report_stop(error)
|
|
18
|
+
hook = @definition.squawk
|
|
19
|
+
attempts = error.attempts.to_i
|
|
20
|
+
return if error.instance_variable_get(REPORTED) || !hook
|
|
21
|
+
return unless Squawk.report?(@definition.squawk_on, attempts, error, true)
|
|
22
|
+
|
|
23
|
+
# Marked first, so an error that comes back through an outer squished call (or a second report_stop) is not
|
|
24
|
+
# reported again, and a hook that raises doesn't get the same error again either.
|
|
25
|
+
error.instance_variable_set(REPORTED, true)
|
|
26
|
+
metadata = squawk_metadata(
|
|
27
|
+
attempt: attempts, attempts:, final: true, model: error.models&.last, input: @prompt.input,
|
|
28
|
+
stopped: error.is_a?(BudgetExhaustedError) ? :budget : :disagreement
|
|
29
|
+
)
|
|
30
|
+
Squawk.call(hook, output: nil, metadata:, error:)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# Runs the observability hook, if any, with this attempt's raw output (nil when the call itself failed),
|
|
36
|
+
# the error that ended it (nil when it was accepted), and what was asked. role is nil for escalation
|
|
37
|
+
# attempts, :sample (with the sample's name) or :judge. reask is whether the attempt retried in the same chat,
|
|
38
|
+
# repeated whether it sent back the reply the attempt before it was rejected for, and final whether no attempt
|
|
39
|
+
# follows it.
|
|
40
|
+
def squawk(prompt, path, attempt, response, error, sample: nil, reask: false, repeated: false,
|
|
41
|
+
final: attempt == path.size)
|
|
42
|
+
hook = @definition.squawk
|
|
43
|
+
return unless hook && Squawk.report?(@definition.squawk_on, attempt, error, final)
|
|
44
|
+
|
|
45
|
+
step = path[attempt - 1]
|
|
46
|
+
metadata = squawk_metadata(
|
|
47
|
+
attempt:, attempts: path.size, final:, repeated:, model: response&.model || step.model,
|
|
48
|
+
provider: step.provider, params: step.request_params(reask), input: prompt.input, response:,
|
|
49
|
+
role: sample ? :sample : prompt.role, sample: sample&.name
|
|
50
|
+
)
|
|
51
|
+
Squawk.call(hook, output: response&.content, metadata:, error:)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def squawk_metadata(attempt:, attempts:, final:, model:, input:, repeated: false, provider: nil, params: nil,
|
|
55
|
+
response: nil, role: nil, sample: nil, stopped: nil)
|
|
56
|
+
{
|
|
57
|
+
label:, attempt:, attempts:, final:, repeated:, model:, provider:, params:, input:,
|
|
58
|
+
usage: response&.usage, finish_reason: response&.finish_reason, role:, sample:, stopped:
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
data/lib/squishling/run.rb
CHANGED
|
@@ -14,8 +14,9 @@ module Squishling
|
|
|
14
14
|
class Run
|
|
15
15
|
include Messages
|
|
16
16
|
include Rejections
|
|
17
|
+
include Reports
|
|
17
18
|
|
|
18
|
-
private_constant :Messages, :Rejections
|
|
19
|
+
private_constant :Messages, :Rejections, :Reports
|
|
19
20
|
|
|
20
21
|
# One of #samples' outputs, between rounds.
|
|
21
22
|
Sample = Struct.new(:name, :stage, :chat, :rejected, :result, :reask)
|
|
@@ -41,7 +42,9 @@ module Squishling
|
|
|
41
42
|
@produced = {}.compare_by_identity
|
|
42
43
|
end
|
|
43
44
|
|
|
44
|
-
# Whether value is a result one of this run's primitives returned (not one built by hand).
|
|
45
|
+
# Whether value is a result one of this run's primitives returned (not one built by hand). By identity, so it
|
|
46
|
+
# tells results that are objects (the typed Data of an object schema) apart; a schema that isn't an object
|
|
47
|
+
# returns the bare JSON value, and an immediate such as 1 or true is the same object in every run.
|
|
45
48
|
def produced?(value)
|
|
46
49
|
@produced.key?(value)
|
|
47
50
|
end
|
|
@@ -287,26 +290,6 @@ module Squishling
|
|
|
287
290
|
@client.chat(step, instructions: prompt.instructions, schema: prompt.schema.llm_schema)
|
|
288
291
|
end
|
|
289
292
|
|
|
290
|
-
# Runs the observability hook, if any, with this attempt's raw output (nil when the call itself failed),
|
|
291
|
-
# the error that ended it (nil when it was accepted), and what was asked. role is nil for escalation
|
|
292
|
-
# attempts, :sample (with the sample's name) or :judge. reask is whether the attempt retried in the same chat,
|
|
293
|
-
# repeated whether it sent back the reply the attempt before it was rejected for, and final whether no attempt
|
|
294
|
-
# follows it.
|
|
295
|
-
def squawk(prompt, path, attempt, response, error, sample: nil, reask: false, repeated: false,
|
|
296
|
-
final: attempt == path.size)
|
|
297
|
-
hook = @definition.squawk
|
|
298
|
-
return unless hook && Squawk.report?(@definition.squawk_on, attempt, error, final)
|
|
299
|
-
|
|
300
|
-
step = path[attempt - 1]
|
|
301
|
-
metadata = {
|
|
302
|
-
label:, attempt:, attempts: path.size, final:, repeated:,
|
|
303
|
-
model: response&.model || step.model, provider: step.provider, params: step.request_params(reask),
|
|
304
|
-
input: prompt.input, usage: response&.usage, finish_reason: response&.finish_reason,
|
|
305
|
-
role: sample ? :sample : prompt.role, sample: sample&.name
|
|
306
|
-
}
|
|
307
|
-
Squawk.call(hook, output: response&.content, metadata:, error:)
|
|
308
|
-
end
|
|
309
|
-
|
|
310
293
|
# following is the attempt that comes next, which is not the next one when a repeated reply skips ahead.
|
|
311
294
|
def log_failure(role, attempt, path, reason, following: attempt + 1, repeated: false)
|
|
312
295
|
step = path[attempt - 1]
|
data/lib/squishling/schema.rb
CHANGED
|
@@ -4,8 +4,15 @@ module Squishling
|
|
|
4
4
|
# Normalizes the supported output-schema forms into a JSON Schema, validates data against it,
|
|
5
5
|
# and builds typed results.
|
|
6
6
|
class Schema
|
|
7
|
-
|
|
7
|
+
# Held weakly by the raw schema (a Schematist class, a Hash), so classes replaced by code reloading can be
|
|
8
|
+
# collected along with their schemas. A Hash compares by content, which yields the same schema.
|
|
9
|
+
CACHE = ObjectSpace::WeakKeyMap.new
|
|
8
10
|
CACHE_LOCK = Mutex.new
|
|
11
|
+
# What building a validator raises for a $ref (or $schema) it can't resolve. json_schemer's default resolver
|
|
12
|
+
# refuses remote URIs, so none is fetched.
|
|
13
|
+
REF_ERRORS = [
|
|
14
|
+
JSONSchemer::UnknownRef, JSONSchemer::InvalidRefResolution, JSONSchemer::InvalidRefPointer, URI::InvalidURIError
|
|
15
|
+
].freeze
|
|
9
16
|
|
|
10
17
|
# Conditional keywords (Schematist's `given` and `dependent`) that providers' strict modes don't support.
|
|
11
18
|
# They're validated locally and kept out of the schema sent to the LLM.
|
|
@@ -49,15 +56,15 @@ module Squishling
|
|
|
49
56
|
"schema" => provider_schema(@json_schema),
|
|
50
57
|
"strict" => true
|
|
51
58
|
}.compact
|
|
52
|
-
@validator =
|
|
59
|
+
@validator = validator_for(@json_schema)
|
|
53
60
|
# Built now so a property name the result can't take fails here, not on the first response.
|
|
54
61
|
@result_class = Result.build(@json_schema)
|
|
55
62
|
end
|
|
56
63
|
|
|
57
64
|
def validate(data)
|
|
58
65
|
errors = @validator.validate(data).map { |error| error["error"] }
|
|
59
|
-
# A raw Hash schema can
|
|
60
|
-
# invalid output rather than a NoMethodError
|
|
66
|
+
# A raw Hash schema's type can be a union that includes "object" (a bare `properties` is refused at build);
|
|
67
|
+
# the typed result is built from a Hash, so anything else is invalid output rather than a NoMethodError.
|
|
61
68
|
errors.empty? && result_class && !data.is_a?(Hash) ? ["root must be an object"] : errors
|
|
62
69
|
end
|
|
63
70
|
|
|
@@ -67,6 +74,39 @@ module Squishling
|
|
|
67
74
|
|
|
68
75
|
private
|
|
69
76
|
|
|
77
|
+
# The json_schemer validator, with every $ref resolved now, so one that points nowhere (a remote URI, a missing
|
|
78
|
+
# $defs entry) fails at build and in Squishling.verify, not on the first response.
|
|
79
|
+
def validator_for(json_schema)
|
|
80
|
+
validator = JSONSchemer.schema(json_schema)
|
|
81
|
+
resolve_refs!(validator, {}.compare_by_identity)
|
|
82
|
+
validator
|
|
83
|
+
rescue *REF_ERRORS => e
|
|
84
|
+
raise ConfigurationError, "output_schema has a $ref that can't be resolved (#{e.message}); " \
|
|
85
|
+
"only references within the schema are supported"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Walks the parsed schema (json_schemer's Schema#parsed and Keyword#ref_schema) and into each target a $ref
|
|
89
|
+
# resolves to, so a reference inside a subschema kept under any keyword is checked too. seen stops cycles.
|
|
90
|
+
def resolve_refs!(schema, seen)
|
|
91
|
+
return if seen.key?(schema)
|
|
92
|
+
|
|
93
|
+
seen[schema] = true
|
|
94
|
+
schema.parsed.each_value do |keyword|
|
|
95
|
+
resolve_refs!(keyword.ref_schema, seen) if keyword.respond_to?(:ref_schema)
|
|
96
|
+
subschemas(keyword.parsed).each { |subschema| resolve_refs!(subschema, seen) }
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The Schema objects directly under a parsed keyword: itself, in a list, or in a map of names.
|
|
101
|
+
def subschemas(parsed)
|
|
102
|
+
case parsed
|
|
103
|
+
when JSONSchemer::Schema then [parsed]
|
|
104
|
+
when Array then parsed.grep(JSONSchemer::Schema)
|
|
105
|
+
when Hash then parsed.values.grep(JSONSchemer::Schema)
|
|
106
|
+
else []
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
70
110
|
def to_hash(raw)
|
|
71
111
|
case raw
|
|
72
112
|
when Hash then raw
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
# Holds a credential (an API key, or the headers that may carry one) so a serializer that walks an object's instance
|
|
5
|
+
# variables can't write it out: to_s, inspect, to_yaml, as_json (so ActiveSupport's instance_values walk too) and
|
|
6
|
+
# to_json all show "[REDACTED]", and Marshal refuses. The value sits in a closure rather than an instance variable;
|
|
7
|
+
# #reveal is the only way to read it, and it is for the code that sends the request. Code running in the process can
|
|
8
|
+
# still call #reveal; this guards against accidental serialization (a logged config, an error tracker, a cache
|
|
9
|
+
# dump), not against a hostile caller.
|
|
10
|
+
class Secret
|
|
11
|
+
REDACTED = "[REDACTED]"
|
|
12
|
+
|
|
13
|
+
def initialize(value)
|
|
14
|
+
@reveal = -> { value }
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def reveal
|
|
19
|
+
@reveal.call
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def inspect
|
|
23
|
+
REDACTED
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
alias_method :to_s, :inspect
|
|
27
|
+
|
|
28
|
+
def encode_with(coder)
|
|
29
|
+
coder.scalar = REDACTED
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def as_json(*)
|
|
33
|
+
REDACTED
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def to_json(*)
|
|
37
|
+
REDACTED.to_json
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def marshal_dump
|
|
41
|
+
raise TypeError, "Squishling::Secret can't be dumped: it holds a credential"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
private_constant :Secret
|
|
45
|
+
end
|
data/lib/squishling/squawk.rb
CHANGED
|
@@ -4,14 +4,16 @@ module Squishling
|
|
|
4
4
|
# The opt-in observability hook: a callable run after every LLM attempt (or only the rejected and retried
|
|
5
5
|
# ones, see squawk_on:) with the attempt's raw output, so it can be sent somewhere (an error tracker, a
|
|
6
6
|
# tracing tool). It's the one sanctioned way for model output to leave the process; nothing runs unless one
|
|
7
|
-
# is configured.
|
|
7
|
+
# is configured. A call that a token budget or a disagreement stops gets one more report, with the error and no
|
|
8
|
+
# output (metadata's stopped:), since neither has an attempt of its own.
|
|
8
9
|
module Squawk
|
|
9
10
|
KEYWORD_TYPES = %i[key keyreq].freeze
|
|
10
11
|
POSITIONAL_TYPES = %i[req opt].freeze
|
|
11
12
|
KEYWORD_CAPTURE_TYPES = %i[key keyreq keyrest].freeze
|
|
12
13
|
KEYWORDS = %i[output metadata error].freeze
|
|
13
14
|
# The squawk_on: conditions, each answering "does the hook run for this attempt?" from the attempt's 1-based
|
|
14
|
-
# number (the one its metadata carries), its error (nil when accepted), and whether it is the last one.
|
|
15
|
+
# number (the one its metadata carries), its error (nil when accepted), and whether it is the last one. A stopped
|
|
16
|
+
# call's report has an error and is final, so it passes every condition whatever its attempt number (0 or more).
|
|
15
17
|
CONDITIONS = {
|
|
16
18
|
all: ->(_attempt, _error, _final) { true },
|
|
17
19
|
rejections: ->(_attempt, error, _final) { !error.nil? },
|
|
@@ -25,7 +27,8 @@ module Squishling
|
|
|
25
27
|
# Which attempts reach the hook (squawk_on:): one condition or a list of them, or nil to inherit. :all is
|
|
26
28
|
# every attempt, :rejections only the ones that errored, :retries those plus any accepted attempt after the
|
|
27
29
|
# first, and :final the last attempt the escalation allows (metadata's final:), however it ended; an earlier
|
|
28
|
-
# accepted attempt is not final.
|
|
30
|
+
# accepted attempt is not final. The report of a stopped call is final and has an error, so every
|
|
31
|
+
# condition lets it through. A list runs the hook when any of them holds.
|
|
29
32
|
def validate_on(value, where)
|
|
30
33
|
return value if value.nil?
|
|
31
34
|
|
|
@@ -48,7 +51,7 @@ module Squishling
|
|
|
48
51
|
return hook if hook.nil? || hook == false
|
|
49
52
|
|
|
50
53
|
unless hook.respond_to?(:call)
|
|
51
|
-
raise ConfigurationError, "#{where} squawk must respond to call (or be false), got #{hook
|
|
54
|
+
raise ConfigurationError, "#{where} squawk must respond to call (or be false), got #{Appendices.describe(hook)}"
|
|
52
55
|
end
|
|
53
56
|
if positional_only?(parameters(hook))
|
|
54
57
|
raise ConfigurationError,
|
|
@@ -52,7 +52,8 @@ module Squishling
|
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
# What a test asked squawk to record: every attempt's raw output, error, and metadata (role, sample, attempt,
|
|
55
|
-
# final, usage, ...)
|
|
55
|
+
# final, usage, ...), and the report of a call stopped by a token budget or a disagreement (stopped).
|
|
56
|
+
# Use Testing.with_squawk, which puts the previous hook back.
|
|
56
57
|
class SquawkCapture
|
|
57
58
|
Call = Data.define(:output, :error, :metadata)
|
|
58
59
|
|
data/lib/squishling/version.rb
CHANGED
data/lib/squishling/wrapper.rb
CHANGED
|
@@ -4,6 +4,24 @@ module Squishling
|
|
|
4
4
|
# Module prepended to a squishling class. It intercepts squished methods regardless of
|
|
5
5
|
# whether they're defined before or after the `squish` declaration (or at all).
|
|
6
6
|
class Wrapper < Module
|
|
7
|
+
# Backtrace locations report real paths, so resolve this one too (a symlinked install keeps __FILE__ as given).
|
|
8
|
+
FILE = File.realpath(__FILE__)
|
|
9
|
+
private_constant :FILE
|
|
10
|
+
|
|
11
|
+
# Raised by the wrapper when a squished method has no implementation at all.
|
|
12
|
+
class NoImplementation < NotImplementedError; end
|
|
13
|
+
private_constant :NoImplementation
|
|
14
|
+
|
|
15
|
+
# Whether a NotImplementedError is the "no implementation" signal rather than an error from called code: the
|
|
16
|
+
# wrapper's own, or one raised directly in the method's own frame (`def x = raise NotImplementedError`). The
|
|
17
|
+
# implementation lambda below sits one frame above the method, so anything raised deeper (a helper, a block,
|
|
18
|
+
# another object, a `super` into an unsquished parent) has more frames in between. A missing backtrace fails
|
|
19
|
+
# closed; an exception re-raised or reused keeps the backtrace of its first raise, so a stub that escapes
|
|
20
|
+
# another squished call (after its `squish!`) still reads as a stub to the caller.
|
|
21
|
+
def self.stub?(error)
|
|
22
|
+
error.is_a?(NoImplementation) || error.backtrace_locations&.at(1)&.absolute_path == FILE
|
|
23
|
+
end
|
|
24
|
+
|
|
7
25
|
# The method beneath any Squishling wrappers (instance_method resolves to the prepended wrapper first),
|
|
8
26
|
# or nil when the method has no implementation.
|
|
9
27
|
def self.implementation(method)
|
|
@@ -20,7 +38,7 @@ module Squishling
|
|
|
20
38
|
if defined?(super)
|
|
21
39
|
-> { super(*args, **kwargs, &block) }
|
|
22
40
|
else
|
|
23
|
-
-> { raise
|
|
41
|
+
-> { raise NoImplementation, "#{self.class}##{name} has no implementation" }
|
|
24
42
|
end
|
|
25
43
|
|
|
26
44
|
Router.dispatch(self, name, args, kwargs, impl, wrapper:)
|
data/lib/squishling.rb
CHANGED
|
@@ -9,10 +9,12 @@ require_relative "squishling/errors"
|
|
|
9
9
|
require_relative "squishling/configuration"
|
|
10
10
|
require_relative "squishling/params"
|
|
11
11
|
require_relative "squishling/named_registry"
|
|
12
|
+
require_relative "squishling/secret"
|
|
12
13
|
require_relative "squishling/adapter/error_body"
|
|
13
14
|
require_relative "squishling/adapter"
|
|
14
15
|
require_relative "squishling/adapters/ruby_llm"
|
|
15
16
|
require_relative "squishling/adapters/openai_options"
|
|
17
|
+
require_relative "squishling/adapters/openai_transport"
|
|
16
18
|
require_relative "squishling/adapters/openai_compatible"
|
|
17
19
|
require_relative "squishling/squawk"
|
|
18
20
|
require_relative "squishling/observer"
|
|
@@ -36,6 +38,7 @@ require_relative "squishling/llm_client"
|
|
|
36
38
|
require_relative "squishling/output_check"
|
|
37
39
|
require_relative "squishling/run/messages"
|
|
38
40
|
require_relative "squishling/run/rejections"
|
|
41
|
+
require_relative "squishling/run/reports"
|
|
39
42
|
require_relative "squishling/run"
|
|
40
43
|
require_relative "squishling/invoker"
|
|
41
44
|
require_relative "squishling/judge"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: squishling
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.5.
|
|
4
|
+
version: 0.5.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michael Carroll
|
|
@@ -74,6 +74,7 @@ files:
|
|
|
74
74
|
- lib/squishling/adapter/error_body.rb
|
|
75
75
|
- lib/squishling/adapters/openai_compatible.rb
|
|
76
76
|
- lib/squishling/adapters/openai_options.rb
|
|
77
|
+
- lib/squishling/adapters/openai_transport.rb
|
|
77
78
|
- lib/squishling/adapters/ruby_llm.rb
|
|
78
79
|
- lib/squishling/appendices.rb
|
|
79
80
|
- lib/squishling/budget.rb
|
|
@@ -107,7 +108,9 @@ files:
|
|
|
107
108
|
- lib/squishling/run.rb
|
|
108
109
|
- lib/squishling/run/messages.rb
|
|
109
110
|
- lib/squishling/run/rejections.rb
|
|
111
|
+
- lib/squishling/run/reports.rb
|
|
110
112
|
- lib/squishling/schema.rb
|
|
113
|
+
- lib/squishling/secret.rb
|
|
111
114
|
- lib/squishling/source.rb
|
|
112
115
|
- lib/squishling/squawk.rb
|
|
113
116
|
- lib/squishling/tasks/squishling.rake
|