axn-ruby_llm 0.2.0 → 0.3.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.
@@ -6,7 +6,6 @@ module Axn
6
6
  include Axn
7
7
 
8
8
  expects :prompt
9
- expects :json, type: :boolean, default: false
10
9
  expects :schema, optional: true
11
10
  expects :model, optional: true
12
11
  expects :system_prompt, optional: true
@@ -24,22 +23,36 @@ module Axn
24
23
  exposes :cost_breakdown, allow_nil: true
25
24
  exposes :stubbed, type: :boolean, default: false
26
25
 
27
- StubMessage = Data.define(:content, :input_tokens, :output_tokens, :cache_read_tokens, :cache_write_tokens, :model_id)
26
+ # Shape-compatible with a real ::RubyLLM::Message on the disabled path: `.content` is the raw
27
+ # text (JSON when `schema:` is set, matching 2.0's read-only String #content), `.tokens` is a
28
+ # real ::RubyLLM::Tokens (so `.input`/`.output`/`.cache_read`/`.cache_write` all resolve), and
29
+ # `.parsed` mirrors Message#parsed (memoized JSON.parse over #content).
30
+ StubMessage = Data.define(:content, :tokens, :model) do
31
+ def parsed
32
+ return if content.nil? || content.empty?
33
+
34
+ JSON.parse(content)
35
+ end
36
+ end
28
37
 
29
38
  # 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.
39
+ # non-HTTP errors (bad config, missing model/prompt/role, unsupported attachment, a stale model
40
+ # registry, an unresolved pending-tool-call/approval loop state) subclass StandardError
41
+ # directly -- so RubyLLM::Error alone misses them. Connection-level failures (timeout, DNS,
42
+ # refused) never reach RubyLLM at all and surface as raw Faraday errors. All these are "known"
43
+ # failure shapes safe to surface verbatim; anything outside this is a bug and must not leak its
44
+ # message into a user-facing result.
35
45
  KNOWN_ERROR_CLASSES = [
36
46
  ::RubyLLM::Error,
37
47
  ::Faraday::Error,
38
48
  ::RubyLLM::ConfigurationError,
39
49
  ::RubyLLM::ModelNotFoundError,
50
+ ::RubyLLM::ModelRegistryError,
40
51
  ::RubyLLM::PromptNotFoundError,
41
52
  ::RubyLLM::InvalidRoleError,
42
53
  ::RubyLLM::InvalidToolChoiceError,
54
+ ::RubyLLM::PendingToolCallsError,
55
+ ::RubyLLM::CancelledError,
43
56
  ::RubyLLM::UnsupportedAttachmentError,
44
57
  ].freeze
45
58
  KNOWN_ERROR = ->(exception:) { KNOWN_ERROR_CLASSES.any? { |k| exception.is_a?(k) } }
@@ -79,20 +92,20 @@ module Axn
79
92
  expose(
80
93
  response: parsed_response,
81
94
  raw_message: llm_response,
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),
95
+ input_tokens: token_usage.input,
96
+ output_tokens: token_usage.output,
97
+ cache_read_tokens: token_usage.cache_read,
98
+ cache_write_tokens: token_usage.cache_write,
86
99
  prompt_tokens: total_input_tokens,
87
100
  cost_breakdown:,
88
101
  cost: cost_breakdown&.total,
89
102
  stubbed: false,
90
103
  )
91
104
  record_otel_attributes!(
92
- input_tokens: sum_across(:input_tokens),
93
- output_tokens: sum_across(:output_tokens),
105
+ input_tokens: token_usage.input,
106
+ output_tokens: token_usage.output,
94
107
  cost: cost_breakdown&.total,
95
- response_model: response_message&.model_id,
108
+ response_model: llm_response&.model,
96
109
  stubbed: false,
97
110
  )
98
111
  rescue ::RubyLLM::RateLimitError => e
@@ -104,10 +117,12 @@ module Axn
104
117
  def disabled? = !Axn::RubyLLM.enabled?
105
118
 
106
119
  def stubbed_exposures
107
- content = schema || json ? { "stubbed" => true } : "stubbed response value"
120
+ parsed_content = schema ? { "stubbed" => true } : nil
121
+ content = parsed_content ? parsed_content.to_json : "stubbed response value"
122
+ zero_tokens = ::RubyLLM::Tokens.new(input: 0, output: 0, cache_read: 0, cache_write: 0)
108
123
  {
109
- response: content,
110
- raw_message: StubMessage.new(content:, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0, model_id: "stubbed"),
124
+ response: parsed_content || content,
125
+ raw_message: StubMessage.new(content:, tokens: zero_tokens, model: "stubbed"),
111
126
  input_tokens: 0,
112
127
  output_tokens: 0,
113
128
  cache_read_tokens: 0,
@@ -120,99 +135,38 @@ module Axn
120
135
  end
121
136
 
122
137
  def parsed_response
123
- return halted_response if halted?
124
-
125
- if schema
126
- # with_schema makes RubyLLM parse the response into a Hash on success
127
- return llm_response.content if llm_response.content.is_a?(Hash)
138
+ return llm_response.content unless schema
128
139
 
129
- fail! "Schema response was not valid JSON"
130
- end
131
- json ? JSON.parse(llm_response.content) : llm_response.content
132
- end
140
+ # with_schema makes RubyLLM parse the response into JSON text on success; #parsed memoizes
141
+ # JSON.parse over #content and raises JSON::ParserError on malformed JSON (caught by the
142
+ # declared `error "Response was not valid JSON", if: JSON::ParserError` handler above).
143
+ parsed = llm_response.parsed
144
+ return parsed if parsed.is_a?(Hash)
133
145
 
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
146
+ fail! "Schema response was not valid JSON"
145
147
  end
146
148
 
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
149
+ # Every provider attempt this chat has made -- including retries and fallback attempts that
150
+ # produced no message -- aggregated by RubyLLM itself (Chat#tokens / Chat#cost), rather than
151
+ # summed by hand across chat.messages. A tool call makes multiple model round-trips inside one
152
+ # `ask`; this still reflects the whole call, not just the final response.
153
+ memo def token_usage = chat.tokens
154
+ memo def cost_breakdown = chat.cost
163
155
 
156
+ # nil only when NO turn reported the field (preserving the "nil if the provider didn't return
157
+ # it" contract); otherwise the summed count, treating a missing component as 0.
164
158
  def total_input_tokens
165
- vals = usage_messages.flat_map { |m| [m.input_tokens, m.cache_read_tokens, m.cache_write_tokens] }
159
+ vals = [token_usage.input, token_usage.cache_read, token_usage.cache_write]
166
160
  vals.all?(&:nil?) ? nil : vals.sum(&:to_i)
167
161
  end
168
162
 
169
- memo def cost_breakdown
170
- return nil unless model_info
171
-
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)
185
- end
186
-
187
- memo def model_info
188
- return nil unless response_message&.model_id
189
-
190
- ::RubyLLM.models.find(response_message.model_id)
191
- rescue ::RubyLLM::ModelNotFoundError
192
- nil
193
- end
194
-
195
163
  memo def llm_response = chat.ask(prompt)
196
164
 
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
-
210
165
  memo def chat
211
166
  ::RubyLLM.chat(model: resolved_model).tap do |c|
212
167
  c.with_instructions(system_prompt) if system_prompt
213
- c.with_schema(schema) if schema
214
- c.with_params(response_format: { type: "json_object" }) if json && !schema
215
- c.with_params(temperature:) if temperature
168
+ c.with_schema(resolved_schema) if schema
169
+ c.with_temperature(temperature) if temperature
216
170
  c.with_tools(*resolved_tools) if resolved_tools.any?
217
171
  end
218
172
  end
@@ -221,6 +175,78 @@ module Axn
221
175
  model || Axn::RubyLLM.config.default_model
222
176
  end
223
177
 
178
+ # `schema:` accepts a raw JSON Schema Hash (passed through unchanged -- Chat#with_schema
179
+ # already normalizes a bare Hash), a Schematist::Schema class/instance (likewise passed
180
+ # through -- with_schema itself checks for #to_json_schema), or an Axn class: the same
181
+ # reflection the tool adapter already uses for input (`input_schema`), mirrored here for
182
+ # output. `output_schema` is axn's own public JSON Schema Hash for its `exposes` contract.
183
+ #
184
+ # Adjustments axn's own `exposes` contract has no reason to make on its own -- each one
185
+ # confirmed live against a real model, not just read off docs (a docs summary claimed
186
+ # `minLength` was ALSO unsupported by Anthropic; a flat schema with `minLength: 1` on a
187
+ # String field succeeded live regardless, so only what's actually confirmed failing is
188
+ # stripped, nothing broader):
189
+ #
190
+ # - `additionalProperties: false` on every fixed-shape object node. Anthropic's
191
+ # `output_config.format.schema` REQUIRES this unconditionally -- confirmed live
192
+ # ("output_config.format.schema: For 'object' type, 'additionalProperties' must be
193
+ # explicitly set to false"), and it deletes any `strict:` key before validating
194
+ # (protocols/anthropic/chat.rb#build_output_config), so there is no "non-strict" escape
195
+ # hatch there. OpenAI's strict mode has the same requirement (its own docs / community
196
+ # reports). Injected on every object node that declares `properties` and has no
197
+ # `additionalProperties` of its own -- which excludes a map (`type: Hash, of: {...}`),
198
+ # whose `additionalProperties` already names its value schema and must stay that way.
199
+ # - `minProperties`/`maxProperties` stripped from every object node. axn emits
200
+ # `minProperties: 1` on any fixed-shape object (a nested `type: Hash, shape: {...}` field,
201
+ # or a map) by default -- Anthropic's schema validator rejects it outright, confirmed live
202
+ # ("output_config.format.schema: For 'object' type, property 'minProperties' is not
203
+ # supported"). No prose-restatement fallback (the kind PRO-3172's now-deleted Gemini
204
+ # workaround used) -- that machinery existed for a fixed-whitelist converter Gemini no
205
+ # longer has (see tool_adapter.rb); reintroducing it here for a narrower, less common case
206
+ # (a nested fixed-shape object's entry-count bound) isn't worth the complexity back.
207
+ # - `strict: false`, pinned rather than left to RubyLLM's own inference. Chat#with_schema's
208
+ # strict_schema? (chat_completions/chat.rb) infers `strict: true` whenever every property
209
+ # is required -- the common case for an Axn's output contract (see the README example) --
210
+ # and OpenAI's *full* strict mode additionally requires every property to be listed in
211
+ # `required` even when conceptually optional (via a nullable type), which axn's reflection
212
+ # doesn't promise. Pinned rather than relying on the `additionalProperties: false` fix
213
+ # above to make strict inference merely harmless.
214
+ def resolved_schema
215
+ return schema unless schema.is_a?(::Class) && schema.respond_to?(:output_schema)
216
+
217
+ { name: schema.name || "response", schema: sanitize_output_schema(schema.output_schema), strict: false }
218
+ end
219
+
220
+ # Builds a new Hash/Array throughout rather than mutating -- axn may hand back a memoized
221
+ # output_schema, and mutating it would corrupt every other reader.
222
+ #
223
+ # Every adjustment is gated on `object_node` -- the recursion walks every Hash in the
224
+ # schema, but not every Hash IS a schema node. `properties` is a name-to-schema map, so an
225
+ # Axn with a field literally named `minProperties`/`maxProperties`/`additionalProperties`
226
+ # puts a Hash at exactly the key this pass reads; an ungated delete/injection would corrupt
227
+ # that container instead of a schema node's own keywords (confirmed live: an Axn exposing
228
+ # `minProperties` had that field silently dropped from `properties` while `required` still
229
+ # named it -- an invalid schema). The `properties` container itself never carries `type`, so
230
+ # gating on `object_node` -- true only for an actual object-schema node -- keeps the pass off
231
+ # it entirely.
232
+ def sanitize_output_schema(node)
233
+ case node
234
+ when Hash
235
+ rebuilt = node.transform_values { |value| sanitize_output_schema(value) }
236
+ object_node = rebuilt[:type] == "object" || Array(rebuilt[:type]).include?("object")
237
+ if object_node
238
+ rebuilt.delete(:minProperties)
239
+ rebuilt.delete(:maxProperties)
240
+ rebuilt[:additionalProperties] = false if rebuilt.key?(:properties) && !rebuilt.key?(:additionalProperties)
241
+ end
242
+ rebuilt
243
+ when Array
244
+ node.map { |value| sanitize_output_schema(value) }
245
+ else
246
+ node
247
+ end
248
+ end
249
+
224
250
  # `tools:` accepts a mix of bare Axn classes (wrapped here, so callers can pass their own Axns
225
251
  # straight in) and already-wrapped `::RubyLLM::Tool`s -- a class or an instance, the latter being
226
252
  # how you pass a tool that closed over explicit context via `Axn::RubyLLM.wrap(axn, ambient_context:)`.
@@ -237,22 +263,14 @@ module Axn
237
263
  end
238
264
 
239
265
  def record_otel_attributes!(input_tokens:, output_tokens:, cost:, response_model:, stubbed:)
240
- # Telemetry is a best-effort side effect: it must never break the LLM call. Route it through
241
- # axn core's guard (PRO-2950) rather than a bare rescue — it swallows + warn-logs on failure
242
- # (and fails loud in dev when best_effort_raises_in_dev is set) instead of silently vanishing.
243
- Axn::Extensions.best_effort("recording OpenTelemetry attributes on the axn.call span") do
244
- next unless defined?(::OpenTelemetry::Trace)
245
-
246
- span = ::OpenTelemetry::Trace.current_span
247
- next unless span&.context&.valid?
248
-
249
- span.set_attribute("gen_ai.request.model", resolved_model) if resolved_model
250
- span.set_attribute("gen_ai.response.model", response_model) if response_model
251
- span.set_attribute("gen_ai.usage.input_tokens", input_tokens) if input_tokens
252
- span.set_attribute("gen_ai.usage.output_tokens", output_tokens) if output_tokens
253
- span.set_attribute("gen_ai.usage.cost", cost) if cost
254
- span.set_attribute("axn.ruby_llm.stubbed", stubbed) unless stubbed.nil?
255
- end
266
+ Axn::Extensions::Tracing.annotate_span(
267
+ "gen_ai.request.model" => resolved_model,
268
+ "gen_ai.response.model" => response_model,
269
+ "gen_ai.usage.input_tokens" => input_tokens,
270
+ "gen_ai.usage.output_tokens" => output_tokens,
271
+ "gen_ai.usage.cost" => cost,
272
+ "axn.ruby_llm.stubbed" => stubbed,
273
+ )
256
274
  end
257
275
  end
258
276
  end
@@ -14,8 +14,7 @@ module Axn
14
14
  #
15
15
  # Usage in a spec:
16
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
17
+ # stub_axn_ruby_llm({ "k" => "v" }, schema: MySchema) # Hash passed through as `parsed`
19
18
  # stub_axn_ruby_llm("...", input_tokens: 100, output_tokens: 50, cost: 0.0023)
20
19
  # stub_axn_ruby_llm("...", cache_read_tokens: 500, cache_write_tokens: 200)
21
20
  # stub_axn_ruby_llm(response: "...") # keyword form still works
@@ -28,63 +27,44 @@ module Axn
28
27
  raise ArgumentError, "stub_axn_ruby_llm requires a response (positionally or as `response:`)" if response.equal?(UNSET)
29
28
 
30
29
  resolved_model_id = model || Axn::RubyLLM.config.default_model
31
- llm_message = _stub_axn_ruby_llm_message(response, resolved_model_id, input_tokens, output_tokens,
32
- cache_read_tokens:, cache_write_tokens:, schema:)
33
- chat_instance = _stub_axn_ruby_llm_chat(model, llm_message, schema:)
34
- _stub_axn_ruby_llm_cost(llm_message, resolved_model_id, cost)
35
- chat_instance
30
+ llm_message = _stub_axn_ruby_llm_message(response, resolved_model_id, schema:)
31
+ _stub_axn_ruby_llm_chat(model, llm_message, input_tokens:, output_tokens:,
32
+ cache_read_tokens:, cache_write_tokens:, cost:)
36
33
  end
37
34
 
38
35
  private
39
36
 
40
- def _stub_axn_ruby_llm_message(response, model_id, input_tokens, output_tokens, cache_read_tokens:,
41
- cache_write_tokens:, schema:)
42
- content = if schema
43
- response
44
- elsif response.is_a?(Hash)
45
- response.to_json
46
- else
47
- response.to_s
48
- end
49
- instance_double(::RubyLLM::Message,
50
- content:, input_tokens:, output_tokens:,
51
- cache_read_tokens:, cache_write_tokens:, model_id:)
37
+ # `content` mirrors real ::RubyLLM::Message#content (a read-only String, JSON text when
38
+ # `schema:` is set); `parsed` mirrors #parsed (the Hash `schema:` callers actually want back
39
+ # via Ask's `parsed_response`, which reads `.parsed` -- not `.content` -- once schema is set).
40
+ def _stub_axn_ruby_llm_message(response, model_id, schema:)
41
+ content = schema ? response.to_json : response.to_s
42
+ parsed = schema ? response : nil
43
+ instance_double(::RubyLLM::Message, content:, parsed:, model: model_id)
52
44
  end
53
45
 
54
- def _stub_axn_ruby_llm_chat(model, llm_message, schema:)
46
+ def _stub_axn_ruby_llm_chat(model, llm_message, input_tokens:, output_tokens:,
47
+ cache_read_tokens:, cache_write_tokens:, cost:)
55
48
  chat_instance = instance_double(::RubyLLM::Chat)
56
49
  if model
57
50
  allow(::RubyLLM).to receive(:chat).with(model:).and_return(chat_instance)
58
51
  else
59
52
  allow(::RubyLLM).to receive(:chat).and_return(chat_instance)
60
53
  end
61
- %i[with_instructions with_params with_tools].each do |method|
54
+ %i[with_instructions with_schema with_temperature with_provider_options with_tools].each do |method|
62
55
  allow(chat_instance).to receive(method).and_return(chat_instance)
63
56
  end
64
- # Always stub with_schema so specs don't blow up if production code passes schema:
65
- # even when the helper is called without schema:. Use a tight matcher when schema
66
- # is known so the stub still validates the correct class is passed.
67
- if schema
68
- allow(chat_instance).to receive(:with_schema).with(schema).and_return(chat_instance)
69
- else
70
- allow(chat_instance).to receive(:with_schema).and_return(chat_instance)
71
- end
72
57
  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])
75
- chat_instance
76
- end
77
-
78
- def _stub_axn_ruby_llm_cost(llm_message, model_id, cost)
79
- model_info = instance_double("RubyLLM::Model")
80
- allow(::RubyLLM.models).to receive(:find).with(model_id).and_return(model_info)
81
- # Default to zero cost so specs exercise the "model found, cost computed" path.
58
+ # Ask reads usage off the chat's own ledger (Chat#tokens / Chat#cost), not per-message --
59
+ # a stubbed call is single-turn, so the ledger is just these values directly.
60
+ allow(chat_instance).to receive(:tokens).and_return(
61
+ instance_double(::RubyLLM::Tokens, input: input_tokens, output: output_tokens,
62
+ cache_read: cache_read_tokens, cache_write: cache_write_tokens),
63
+ )
64
+ # Default to zero cost so specs exercise the "cost computed" path.
82
65
  # Pass cost: explicitly to assert a specific value.
83
- cost_total = cost || 0.0
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)
87
- allow(llm_message).to receive(:cost).with(model: model_info).and_return(cost_struct)
66
+ allow(chat_instance).to receive(:cost).and_return(instance_double(::RubyLLM::Cost, total: cost || 0.0))
67
+ chat_instance
88
68
  end
89
69
  end
90
70
  end