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.
@@ -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
- error prefix: "LLM request failed: "
29
- error "Failed to parse JSON from LLM response", if: JSON::ParserError
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
- done!("disabled - returning stubbed values", **exposures)
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: llm_response.input_tokens,
50
- output_tokens: llm_response.output_tokens,
51
- cache_read_tokens: llm_response.cache_read_tokens,
52
- cache_write_tokens: llm_response.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: llm_response.input_tokens,
60
- output_tokens: llm_response.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: llm_response.model_id,
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.configuration.enabled?
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 = [llm_response.input_tokens, llm_response.cache_read_tokens, llm_response.cache_write_tokens]
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
- llm_response.cost(model: model_info)
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
- ::RubyLLM.models.find(llm_response.model_id)
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.configuration.default_model
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
- return unless defined?(::OpenTelemetry::Trace)
133
-
134
- span = ::OpenTelemetry::Trace.current_span
135
- return unless span&.context&.valid?
136
-
137
- span.set_attribute("gen_ai.request.model", resolved_model) if resolved_model
138
- span.set_attribute("gen_ai.response.model", response_model) if response_model
139
- span.set_attribute("gen_ai.usage.input_tokens", input_tokens) if input_tokens
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
@@ -6,19 +6,28 @@ module Axn
6
6
  module RubyLLM
7
7
  module RSpec
8
8
  module Helpers
9
- # Stubs RubyLLM so that Ask returns a canned response.
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(response: "Here is a summary.")
13
- # stub_axn_ruby_llm(response: { "key" => "value" }) # auto-JSON-serialized for json: true calls
14
- # stub_axn_ruby_llm(response: { "k" => "v" }, schema: MySchema) # Hash passed through unparsed
15
- # stub_axn_ruby_llm(response: "...", input_tokens: 100, output_tokens: 50, cost: 0.0023)
16
- # stub_axn_ruby_llm(response: "...", cache_read_tokens: 500, cache_write_tokens: 200)
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(response:, model: nil, schema: nil, input_tokens: nil, output_tokens: nil,
20
- cache_read_tokens: nil, cache_write_tokens: nil, cost: nil)
21
- resolved_model_id = model || Axn::RubyLLM.configuration.default_model
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
- allow(chat_instance).to receive(:with_instructions).and_return(chat_instance)
53
- allow(chat_instance).to receive(:with_params).and_return(chat_instance)
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
- cost_struct = instance_double(::RubyLLM::Cost, total: cost_total)
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