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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +179 -0
- data/README.md +67 -35
- data/lib/axn/ruby_llm/ask.rb +130 -112
- data/lib/axn/ruby_llm/rspec.rb +23 -43
- data/lib/axn/ruby_llm/tool_adapter.rb +100 -75
- data/lib/axn/ruby_llm/version.rb +1 -1
- data/lib/axn/ruby_llm.rb +8 -40
- metadata +22 -8
data/lib/axn/ruby_llm/ask.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
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:
|
|
83
|
-
output_tokens:
|
|
84
|
-
cache_read_tokens:
|
|
85
|
-
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:
|
|
93
|
-
output_tokens:
|
|
105
|
+
input_tokens: token_usage.input,
|
|
106
|
+
output_tokens: token_usage.output,
|
|
94
107
|
cost: cost_breakdown&.total,
|
|
95
|
-
response_model:
|
|
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
|
-
|
|
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:,
|
|
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
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
148
|
-
#
|
|
149
|
-
#
|
|
150
|
-
#
|
|
151
|
-
|
|
152
|
-
|
|
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 =
|
|
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(
|
|
214
|
-
c.
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
data/lib/axn/ruby_llm/rspec.rb
CHANGED
|
@@ -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({ "
|
|
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,
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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,
|
|
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
|
|
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
|
|
74
|
-
|
|
75
|
-
chat_instance
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|