axn-ruby_llm 0.1.2 → 0.2.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 +118 -0
- data/README.md +202 -13
- data/lib/axn/ruby_llm/ask.rb +132 -29
- data/lib/axn/ruby_llm/rspec.rb +26 -12
- data/lib/axn/ruby_llm/tool_adapter.rb +379 -0
- data/lib/axn/ruby_llm/version.rb +1 -1
- data/lib/axn/ruby_llm.rb +61 -6
- metadata +4 -5
- data/Rakefile +0 -13
- data/lib/axn/ruby_llm/configuration.rb +0 -20
data/lib/axn/ruby_llm/ask.rb
CHANGED
|
@@ -11,6 +11,7 @@ module Axn
|
|
|
11
11
|
expects :model, optional: true
|
|
12
12
|
expects :system_prompt, optional: true
|
|
13
13
|
expects :temperature, optional: true
|
|
14
|
+
expects :tools, optional: true
|
|
14
15
|
|
|
15
16
|
exposes :response
|
|
16
17
|
exposes :raw_message
|
|
@@ -25,8 +26,39 @@ module Axn
|
|
|
25
26
|
|
|
26
27
|
StubMessage = Data.define(:content, :input_tokens, :output_tokens, :cache_read_tokens, :cache_write_tokens, :model_id)
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
# RubyLLM wraps HTTP-response-level provider errors (4xx/5xx) under RubyLLM::Error, but its
|
|
30
|
+
# non-HTTP errors (bad config, missing model/prompt/role, unsupported attachment) subclass
|
|
31
|
+
# StandardError directly -- so RubyLLM::Error alone misses them. Connection-level failures
|
|
32
|
+
# (timeout, DNS, refused) never reach RubyLLM at all and surface as raw Faraday errors. All
|
|
33
|
+
# three are "known" failure shapes safe to surface verbatim; anything outside this is a bug
|
|
34
|
+
# and must not leak its message into a user-facing result.
|
|
35
|
+
KNOWN_ERROR_CLASSES = [
|
|
36
|
+
::RubyLLM::Error,
|
|
37
|
+
::Faraday::Error,
|
|
38
|
+
::RubyLLM::ConfigurationError,
|
|
39
|
+
::RubyLLM::ModelNotFoundError,
|
|
40
|
+
::RubyLLM::PromptNotFoundError,
|
|
41
|
+
::RubyLLM::InvalidRoleError,
|
|
42
|
+
::RubyLLM::InvalidToolChoiceError,
|
|
43
|
+
::RubyLLM::UnsupportedAttachmentError,
|
|
44
|
+
].freeze
|
|
45
|
+
KNOWN_ERROR = ->(exception:) { KNOWN_ERROR_CLASSES.any? { |k| exception.is_a?(k) } }
|
|
46
|
+
RETRYABLE_ERROR = lambda { |exception:|
|
|
47
|
+
[::RubyLLM::OverloadedError, ::RubyLLM::ServiceUnavailableError, ::RubyLLM::ServerError].any? { |k| exception.is_a?(k) }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# Base headlines for a consistent result.error / result.success surface: failures read
|
|
51
|
+
# "<error_headline>: <reason>" (configurable via Axn::RubyLLM.configure); successes read
|
|
52
|
+
# "LLM request completed", with any detail attached parenthetically via join: (e.g. the
|
|
53
|
+
# stubbed-values note on the disabled path below).
|
|
54
|
+
# Reason entries are ordered most-specific-last (axn checks most-recently-declared first), so a
|
|
55
|
+
# narrower match (retryable, context length, JSON parse) wins over the generic KNOWN_ERROR catch-all.
|
|
56
|
+
error { Axn::RubyLLM.config.error_headline }
|
|
57
|
+
error(if: KNOWN_ERROR, &:message)
|
|
58
|
+
error(if: RETRYABLE_ERROR) { |e| "Provider temporarily unavailable, try again later: #{e.message}" }
|
|
59
|
+
error(if: ::RubyLLM::ContextLengthExceededError) { |e| "Prompt exceeds the model's context window: #{e.message}" }
|
|
60
|
+
error "Response was not valid JSON", if: JSON::ParserError
|
|
61
|
+
success "LLM request completed", join: ->(base, reason) { "#{base} (#{reason})" }
|
|
30
62
|
|
|
31
63
|
before do
|
|
32
64
|
if disabled?
|
|
@@ -38,7 +70,8 @@ module Axn
|
|
|
38
70
|
response_model: nil,
|
|
39
71
|
stubbed: true,
|
|
40
72
|
)
|
|
41
|
-
|
|
73
|
+
# Reason attaches to the "LLM request completed" base via the parenthetical join: above.
|
|
74
|
+
done!("using stubbed values - actual LLM request disabled", **exposures)
|
|
42
75
|
end
|
|
43
76
|
end
|
|
44
77
|
|
|
@@ -46,20 +79,20 @@ module Axn
|
|
|
46
79
|
expose(
|
|
47
80
|
response: parsed_response,
|
|
48
81
|
raw_message: llm_response,
|
|
49
|
-
input_tokens:
|
|
50
|
-
output_tokens:
|
|
51
|
-
cache_read_tokens:
|
|
52
|
-
cache_write_tokens:
|
|
82
|
+
input_tokens: sum_across(:input_tokens),
|
|
83
|
+
output_tokens: sum_across(:output_tokens),
|
|
84
|
+
cache_read_tokens: sum_across(:cache_read_tokens),
|
|
85
|
+
cache_write_tokens: sum_across(:cache_write_tokens),
|
|
53
86
|
prompt_tokens: total_input_tokens,
|
|
54
87
|
cost_breakdown:,
|
|
55
88
|
cost: cost_breakdown&.total,
|
|
56
89
|
stubbed: false,
|
|
57
90
|
)
|
|
58
91
|
record_otel_attributes!(
|
|
59
|
-
input_tokens:
|
|
60
|
-
output_tokens:
|
|
92
|
+
input_tokens: sum_across(:input_tokens),
|
|
93
|
+
output_tokens: sum_across(:output_tokens),
|
|
61
94
|
cost: cost_breakdown&.total,
|
|
62
|
-
response_model:
|
|
95
|
+
response_model: response_message&.model_id,
|
|
63
96
|
stubbed: false,
|
|
64
97
|
)
|
|
65
98
|
rescue ::RubyLLM::RateLimitError => e
|
|
@@ -68,7 +101,7 @@ module Axn
|
|
|
68
101
|
|
|
69
102
|
private
|
|
70
103
|
|
|
71
|
-
def disabled? = !Axn::RubyLLM.
|
|
104
|
+
def disabled? = !Axn::RubyLLM.enabled?
|
|
72
105
|
|
|
73
106
|
def stubbed_exposures
|
|
74
107
|
content = schema || json ? { "stubbed" => true } : "stubbed response value"
|
|
@@ -87,6 +120,8 @@ module Axn
|
|
|
87
120
|
end
|
|
88
121
|
|
|
89
122
|
def parsed_response
|
|
123
|
+
return halted_response if halted?
|
|
124
|
+
|
|
90
125
|
if schema
|
|
91
126
|
# with_schema makes RubyLLM parse the response into a Hash on success
|
|
92
127
|
return llm_response.content if llm_response.content.is_a?(Hash)
|
|
@@ -96,52 +131,120 @@ module Axn
|
|
|
96
131
|
json ? JSON.parse(llm_response.content) : llm_response.content
|
|
97
132
|
end
|
|
98
133
|
|
|
134
|
+
# A halted tool (halt_after:) short-circuits the model's final turn, so with_schema/json never
|
|
135
|
+
# parsed a model response — the "response" is the tool's own payload (Halt#content). For a
|
|
136
|
+
# :structured tool that's JSON text, so parse it to honor the Hash contract a schema:/json:
|
|
137
|
+
# caller expects; fall back to the raw string for a :message tool or an unparseable payload.
|
|
138
|
+
def halted_response
|
|
139
|
+
content = llm_response.content
|
|
140
|
+
return content unless (schema || json) && content.is_a?(String)
|
|
141
|
+
|
|
142
|
+
JSON.parse(content)
|
|
143
|
+
rescue JSON::ParserError
|
|
144
|
+
content
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# A tool call makes multiple model round-trips inside one `ask`; every assistant turn is
|
|
148
|
+
# accumulated on the chat and reports its OWN usage, so sum across them for the true per-call
|
|
149
|
+
# totals rather than just the final turn's. Non-response messages (the user prompt, tool
|
|
150
|
+
# results) carry no tokens — they contribute 0 to the token sums, and RubyLLM::Cost.aggregate
|
|
151
|
+
# ignores them (no `tokens?`) — so summing over every message is correct, and a plain (no-tool)
|
|
152
|
+
# ask (one assistant turn) is a no-op.
|
|
153
|
+
def usage_messages
|
|
154
|
+
chat.messages
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# nil only when NO turn reported the field (preserving the "nil if the provider didn't return it"
|
|
158
|
+
# contract); otherwise the summed count, treating a missing turn as 0.
|
|
159
|
+
def sum_across(field)
|
|
160
|
+
values = usage_messages.map(&field)
|
|
161
|
+
values.all?(&:nil?) ? nil : values.sum(&:to_i)
|
|
162
|
+
end
|
|
163
|
+
|
|
99
164
|
def total_input_tokens
|
|
100
|
-
vals = [
|
|
165
|
+
vals = usage_messages.flat_map { |m| [m.input_tokens, m.cache_read_tokens, m.cache_write_tokens] }
|
|
101
166
|
vals.all?(&:nil?) ? nil : vals.sum(&:to_i)
|
|
102
167
|
end
|
|
103
168
|
|
|
104
|
-
def cost_breakdown
|
|
169
|
+
memo def cost_breakdown
|
|
105
170
|
return nil unless model_info
|
|
106
171
|
|
|
107
|
-
|
|
172
|
+
# chat.messages always includes the user prompt (chat.ask appends it before completing) and,
|
|
173
|
+
# in a tool loop, the tool-result messages -- none of which carry token usage. Keep only the
|
|
174
|
+
# token-bearing (billable) costs BEFORE the one?-vs-aggregate decision: a normal single-turn
|
|
175
|
+
# call then preserves the response's OWN Cost (with its tokens/model) via costs.one?, instead
|
|
176
|
+
# of being forced through aggregate -- which returns a Cost with nil tokens/model -- by the
|
|
177
|
+
# ever-present user message. `select(&:tokens?)` mirrors Cost.aggregate's own billable filter,
|
|
178
|
+
# so the multi-turn total is unchanged.
|
|
179
|
+
costs = usage_messages.map { |message| message.cost(model: model_info) }.select(&:tokens?)
|
|
180
|
+
return nil if costs.empty?
|
|
181
|
+
|
|
182
|
+
# One billable turn → its own Cost (identical to the pre-tool-loop non-tool call). Multiple →
|
|
183
|
+
# RubyLLM::Cost.aggregate sums the per-tier costs into a single breakdown.
|
|
184
|
+
costs.one? ? costs.first : ::RubyLLM::Cost.aggregate(costs)
|
|
108
185
|
end
|
|
109
186
|
|
|
110
187
|
memo def model_info
|
|
111
|
-
|
|
188
|
+
return nil unless response_message&.model_id
|
|
189
|
+
|
|
190
|
+
::RubyLLM.models.find(response_message.model_id)
|
|
112
191
|
rescue ::RubyLLM::ModelNotFoundError
|
|
113
192
|
nil
|
|
114
193
|
end
|
|
115
194
|
|
|
116
195
|
memo def llm_response = chat.ask(prompt)
|
|
117
196
|
|
|
197
|
+
# When a wrapped tool halts the loop (halt_after:), chat.ask returns a ::RubyLLM::Tool::Halt
|
|
198
|
+
# carrying the tool payload as #content, not a Message — and a Halt has no #model_id. Read the
|
|
199
|
+
# model (for cost lookup + OTel) from the last assistant turn accumulated on the chat in that
|
|
200
|
+
# case; for a normal response, llm_response IS that final message. Token/cost SUMS already read
|
|
201
|
+
# chat.messages, so only the model-id reads needed this indirection.
|
|
202
|
+
def response_message
|
|
203
|
+
return llm_response unless halted?
|
|
204
|
+
|
|
205
|
+
chat.messages.reverse.find { |message| message.role == :assistant }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def halted? = llm_response.is_a?(::RubyLLM::Tool::Halt)
|
|
209
|
+
|
|
118
210
|
memo def chat
|
|
119
211
|
::RubyLLM.chat(model: resolved_model).tap do |c|
|
|
120
212
|
c.with_instructions(system_prompt) if system_prompt
|
|
121
213
|
c.with_schema(schema) if schema
|
|
122
214
|
c.with_params(response_format: { type: "json_object" }) if json && !schema
|
|
123
215
|
c.with_params(temperature:) if temperature
|
|
216
|
+
c.with_tools(*resolved_tools) if resolved_tools.any?
|
|
124
217
|
end
|
|
125
218
|
end
|
|
126
219
|
|
|
127
220
|
def resolved_model
|
|
128
|
-
model || Axn::RubyLLM.
|
|
221
|
+
model || Axn::RubyLLM.config.default_model
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# `tools:` accepts a mix of bare Axn classes (wrapped here, so callers can pass their own Axns
|
|
225
|
+
# straight in) and already-wrapped `::RubyLLM::Tool`s -- a class or an instance, the latter being
|
|
226
|
+
# how you pass a tool that closed over explicit context via `Axn::RubyLLM.wrap(axn, ambient_context:)`.
|
|
227
|
+
# RubyLLM's `with_tools` accepts either a class or an instance, so wrapped classes register as-is.
|
|
228
|
+
def resolved_tools
|
|
229
|
+
Array(tools).map { |tool| _as_ruby_llm_tool(tool) }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def _as_ruby_llm_tool(tool)
|
|
233
|
+
return tool if tool.is_a?(::RubyLLM::Tool)
|
|
234
|
+
return tool if tool.is_a?(::Class) && tool < ::RubyLLM::Tool
|
|
235
|
+
|
|
236
|
+
Axn::RubyLLM.wrap(tool)
|
|
129
237
|
end
|
|
130
238
|
|
|
131
239
|
def record_otel_attributes!(input_tokens:, output_tokens:, cost:, response_model:, stubbed:)
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
span.set_attribute("gen_ai.usage.output_tokens", output_tokens) if output_tokens
|
|
141
|
-
span.set_attribute("gen_ai.usage.cost", cost) if cost
|
|
142
|
-
span.set_attribute("axn.ruby_llm.stubbed", stubbed) unless stubbed.nil?
|
|
143
|
-
rescue StandardError
|
|
144
|
-
# never let telemetry break the action
|
|
240
|
+
Axn::Extensions::Tracing.annotate_span(
|
|
241
|
+
"gen_ai.request.model" => resolved_model,
|
|
242
|
+
"gen_ai.response.model" => response_model,
|
|
243
|
+
"gen_ai.usage.input_tokens" => input_tokens,
|
|
244
|
+
"gen_ai.usage.output_tokens" => output_tokens,
|
|
245
|
+
"gen_ai.usage.cost" => cost,
|
|
246
|
+
"axn.ruby_llm.stubbed" => stubbed,
|
|
247
|
+
)
|
|
145
248
|
end
|
|
146
249
|
end
|
|
147
250
|
end
|
data/lib/axn/ruby_llm/rspec.rb
CHANGED
|
@@ -6,19 +6,28 @@ module Axn
|
|
|
6
6
|
module RubyLLM
|
|
7
7
|
module RSpec
|
|
8
8
|
module Helpers
|
|
9
|
-
|
|
9
|
+
UNSET = Object.new
|
|
10
|
+
private_constant :UNSET
|
|
11
|
+
|
|
12
|
+
# Stubs RubyLLM so that Ask returns a canned response. The response can be given
|
|
13
|
+
# positionally (the common case) or as `response:` — both are equivalent.
|
|
10
14
|
#
|
|
11
15
|
# Usage in a spec:
|
|
12
|
-
# stub_axn_ruby_llm(
|
|
13
|
-
# stub_axn_ruby_llm(
|
|
14
|
-
# stub_axn_ruby_llm(
|
|
15
|
-
# stub_axn_ruby_llm(
|
|
16
|
-
# stub_axn_ruby_llm(
|
|
16
|
+
# stub_axn_ruby_llm("Here is a summary.")
|
|
17
|
+
# stub_axn_ruby_llm({ "key" => "value" }) # auto-JSON-serialized for json: true calls
|
|
18
|
+
# stub_axn_ruby_llm({ "k" => "v" }, schema: MySchema) # Hash passed through unparsed
|
|
19
|
+
# stub_axn_ruby_llm("...", input_tokens: 100, output_tokens: 50, cost: 0.0023)
|
|
20
|
+
# stub_axn_ruby_llm("...", cache_read_tokens: 500, cache_write_tokens: 200)
|
|
21
|
+
# stub_axn_ruby_llm(response: "...") # keyword form still works
|
|
17
22
|
#
|
|
18
23
|
# Returns the chat instance double for further assertions if needed.
|
|
19
|
-
def stub_axn_ruby_llm(
|
|
20
|
-
|
|
21
|
-
|
|
24
|
+
def stub_axn_ruby_llm(positional_response = UNSET, response: UNSET, model: nil, schema: nil,
|
|
25
|
+
input_tokens: nil, output_tokens: nil, cache_read_tokens: nil,
|
|
26
|
+
cache_write_tokens: nil, cost: nil)
|
|
27
|
+
response = positional_response unless positional_response.equal?(UNSET)
|
|
28
|
+
raise ArgumentError, "stub_axn_ruby_llm requires a response (positionally or as `response:`)" if response.equal?(UNSET)
|
|
29
|
+
|
|
30
|
+
resolved_model_id = model || Axn::RubyLLM.config.default_model
|
|
22
31
|
llm_message = _stub_axn_ruby_llm_message(response, resolved_model_id, input_tokens, output_tokens,
|
|
23
32
|
cache_read_tokens:, cache_write_tokens:, schema:)
|
|
24
33
|
chat_instance = _stub_axn_ruby_llm_chat(model, llm_message, schema:)
|
|
@@ -49,8 +58,9 @@ module Axn
|
|
|
49
58
|
else
|
|
50
59
|
allow(::RubyLLM).to receive(:chat).and_return(chat_instance)
|
|
51
60
|
end
|
|
52
|
-
|
|
53
|
-
|
|
61
|
+
%i[with_instructions with_params with_tools].each do |method|
|
|
62
|
+
allow(chat_instance).to receive(method).and_return(chat_instance)
|
|
63
|
+
end
|
|
54
64
|
# Always stub with_schema so specs don't blow up if production code passes schema:
|
|
55
65
|
# even when the helper is called without schema:. Use a tight matcher when schema
|
|
56
66
|
# is known so the stub still validates the correct class is passed.
|
|
@@ -60,6 +70,8 @@ module Axn
|
|
|
60
70
|
allow(chat_instance).to receive(:with_schema).and_return(chat_instance)
|
|
61
71
|
end
|
|
62
72
|
allow(chat_instance).to receive(:ask).and_return(llm_message)
|
|
73
|
+
# Ask sums usage across the chat's assistant turns; a stubbed call is single-turn.
|
|
74
|
+
allow(chat_instance).to receive(:messages).and_return([llm_message])
|
|
63
75
|
chat_instance
|
|
64
76
|
end
|
|
65
77
|
|
|
@@ -69,7 +81,9 @@ module Axn
|
|
|
69
81
|
# Default to zero cost so specs exercise the "model found, cost computed" path.
|
|
70
82
|
# Pass cost: explicitly to assert a specific value.
|
|
71
83
|
cost_total = cost || 0.0
|
|
72
|
-
|
|
84
|
+
# tokens?: true — the stubbed message is a billable assistant turn, and Ask's cost_breakdown
|
|
85
|
+
# keeps only token-bearing (tokens?) costs before its one?-vs-aggregate decision.
|
|
86
|
+
cost_struct = instance_double(::RubyLLM::Cost, total: cost_total, tokens?: true)
|
|
73
87
|
allow(llm_message).to receive(:cost).with(model: model_info).and_return(cost_struct)
|
|
74
88
|
end
|
|
75
89
|
end
|