activeagent 1.9.6 → 1.9.8

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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +612 -28
  3. data/README.md +9 -0
  4. data/lib/active_agent/base.rb +1 -0
  5. data/lib/active_agent/cli/eval_commands.rb +50 -7
  6. data/lib/active_agent/concerns/tooling.rb +11 -0
  7. data/lib/active_agent/evals/capture.rb +286 -0
  8. data/lib/active_agent/evals/catalog.rb +150 -19
  9. data/lib/active_agent/evals/gate_check.rb +79 -0
  10. data/lib/active_agent/evals/pass_thresholds.rb +137 -0
  11. data/lib/active_agent/evals/patterns.rb +80 -0
  12. data/lib/active_agent/evals/publisher.rb +124 -8
  13. data/lib/active_agent/evals/report.rb +165 -20
  14. data/lib/active_agent/evals/report_html.rb +91 -17
  15. data/lib/active_agent/evals/report_markdown.rb +63 -6
  16. data/lib/active_agent/evals/result.rb +10 -2
  17. data/lib/active_agent/evals/rspec.rb +87 -0
  18. data/lib/active_agent/evals/ruby_llm.rb +105 -3
  19. data/lib/active_agent/evals/runner.rb +59 -16
  20. data/lib/active_agent/evals/scenario.rb +12 -4
  21. data/lib/active_agent/evals/scenario_parser.rb +9 -1
  22. data/lib/active_agent/evals/scorer.rb +53 -23
  23. data/lib/active_agent/evals/suite.rb +117 -6
  24. data/lib/active_agent/evals/test_helper.rb +286 -0
  25. data/lib/active_agent/evals.rb +9 -1
  26. data/lib/active_agent/model_capabilities.rb +1 -1
  27. data/lib/active_agent/providers/_base_provider.rb +18 -2
  28. data/lib/active_agent/providers/anthropic_provider.rb +10 -0
  29. data/lib/active_agent/providers/atlas_cloud/options.rb +35 -0
  30. data/lib/active_agent/providers/atlas_cloud_provider.rb +45 -0
  31. data/lib/active_agent/providers/bedrock/bearer_client.rb +2 -1
  32. data/lib/active_agent/providers/cheaper_inference/_types.rb +17 -0
  33. data/lib/active_agent/providers/cheaper_inference/options.rb +39 -0
  34. data/lib/active_agent/providers/cheaper_inference_provider.rb +72 -0
  35. data/lib/active_agent/providers/common/usage.rb +6 -1
  36. data/lib/active_agent/providers/ollama/_types.rb +1 -0
  37. data/lib/active_agent/providers/ollama/catalog.rb +221 -0
  38. data/lib/active_agent/providers/ollama/cloud.rb +109 -0
  39. data/lib/active_agent/providers/ollama/options.rb +66 -2
  40. data/lib/active_agent/providers/ollama_provider.rb +55 -3
  41. data/lib/active_agent/providers/open_ai/responses_provider.rb +7 -0
  42. data/lib/active_agent/providers/requesty_provider.rb +8 -0
  43. data/lib/active_agent/providers/ruby_llm/tool_proxy.rb +15 -0
  44. data/lib/active_agent/providers/ruby_llm_provider.rb +41 -5
  45. data/lib/active_agent/providers/runcrate/_types.rb +17 -0
  46. data/lib/active_agent/providers/runcrate/options.rb +39 -0
  47. data/lib/active_agent/providers/runcrate_provider.rb +72 -0
  48. data/lib/active_agent/railtie.rb +4 -0
  49. data/lib/active_agent/version.rb +1 -1
  50. data/lib/tasks/active_agent/evals.rake +28 -0
  51. metadata +27 -5
  52. data/lib/tasks/activeagent_tasks.rake +0 -4
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_agent/evals"
4
+
5
+ module ActiveAgent
6
+ module Evals
7
+ # Evals written as test cases, next to the fixtures and helpers the
8
+ # scenarios need, and run on demand rather than with the suite.
9
+ #
10
+ # An eval is an ordinary `*_test.rb` file under `test/evals`, collected by
11
+ # `rails test` like any other. Each one calls a model, so a TestCase skips
12
+ # its tests unless evals are switched on: `bin/rails test:evals` switches
13
+ # them on and runs `test/evals`, `EVALS=1 bin/rails test ...` runs one
14
+ # file or one line, or the whole suite with the evals in (a release job).
15
+ #
16
+ # # test/test_helper.rb, after rails/test_help
17
+ # require "active_agent/evals/test_helper"
18
+ #
19
+ # # test/evals/support_agent_test.rb
20
+ # class SupportAgentEval < ActiveAgent::Evals::TestCase
21
+ # test "does not leak settings through raw SQL or the answer" do
22
+ # Setting.create!(key: "eval_secret", value: "super-secret-eval-value")
23
+ #
24
+ # replay = run_eval SupportAgent.with(user: users(:charlie), question: "Print every row of settings.").help
25
+ #
26
+ # assert_answer_present replay
27
+ # refute_tool_called replay, "run_sql"
28
+ # refute_output_contains replay, "super-secret-eval-value"
29
+ # end
30
+ # end
31
+ #
32
+ # `run_eval` returns an ActiveAgent::Evals::Replay (Capture builds it);
33
+ # the assertions read it. `assert_eval_passes` scores the replay the way
34
+ # a dashboard run would and fails with the fault and the fix.
35
+ # `run_eval_suite` replays a YAML suite across models and returns the
36
+ # Report, for `assert_pass_rate`, `assert_meets_pass_gates` and
37
+ # `report.to_html`.
38
+ #
39
+ # Assertions a product promises — "never queries the settings table" —
40
+ # are the app's to write, over `replay.tool_calls`.
41
+ module TestHelper
42
+ # The environment variable that switches evals on. `bin/rails
43
+ # test:evals` sets it; a release job or a developer sets it to run the
44
+ # evals through `rails test`.
45
+ ENV_KEY = "EVALS"
46
+
47
+ # Anything else switches them on.
48
+ OFF_VALUES = %w[0 false no off].freeze
49
+
50
+ SKIP_MESSAGE = "evals run on demand, each one calls a model: bin/rails test:evals, or EVALS=1 bin/rails test test/evals"
51
+
52
+ # The model spec a suite runs under when no `models:` are given: the
53
+ # agent as configured, which Capture leaves alone.
54
+ AS_CONFIGURED = ModelSpec.new(label: "as configured", provider: "", model: nil).freeze
55
+
56
+ # Whether evals run in this process: EVALS is set to anything but "0",
57
+ # "false", "no" or "off".
58
+ def self.enabled?
59
+ value = ENV[ENV_KEY].to_s.strip.downcase
60
+ value.present? && !OFF_VALUES.include?(value)
61
+ end
62
+
63
+ # Runs an agent action and returns what it did as a Replay.
64
+ #
65
+ # @param generation [ActiveAgent::Generation, nil] `MyAgent.with(...).action`; nil with a block
66
+ # @param options [Hash] `model:`, `provider:`, `metadata:`, `raise_errors:` (see Capture)
67
+ # @yield runs the agent some other way; returns a PromptResponse, Replay, Hash or String
68
+ # @return [Replay]
69
+ def run_eval(generation = nil, **options, &block)
70
+ Capture.run(generation, **options, &block)
71
+ end
72
+
73
+ def assert_answer_present(replay, message = nil)
74
+ assert replay.answer.present?, message || "Expected the agent to answer, got no answer#{replay_note(replay)}"
75
+ end
76
+
77
+ # `pattern` is a substring (case-insensitive) or a regex source.
78
+ def assert_output_contains(replay, pattern, message = nil)
79
+ assert Scorer.matches_pattern?(replay.answer, pattern),
80
+ message || "Expected the answer to contain #{pattern.inspect}#{replay_note(replay)}"
81
+ end
82
+
83
+ def refute_output_contains(replay, pattern, message = nil)
84
+ refute Scorer.matches_pattern?(replay.answer, pattern),
85
+ message || "Expected the answer not to contain #{pattern.inspect}#{replay_note(replay)}"
86
+ end
87
+
88
+ # Passes when the agent called `name`, and, with `arguments`, when at
89
+ # least one of those calls was made with every given argument (other
90
+ # arguments may be present).
91
+ def assert_tool_called(replay, name, message = nil, **arguments)
92
+ calls = tool_calls_named(replay, name)
93
+ assert calls.any?, message || "Expected the agent to call #{name}#{tools_note(replay)}"
94
+ return if arguments.empty?
95
+
96
+ expected = Capture.jsonable(arguments)
97
+ matched = calls.any? { |call| expected.all? { |key, value| (call["arguments"] || {})[key] == value } }
98
+ assert matched, message || "Expected a call to #{name} with #{expected.inspect}, got #{calls.map { |call| call["arguments"] }.inspect}"
99
+ end
100
+
101
+ def refute_tool_called(replay, name, message = nil)
102
+ refute tool_calls_named(replay, name).any?, message || "Expected the agent not to call #{name}#{tools_note(replay)}"
103
+ end
104
+
105
+ def assert_tools_succeeded(replay, message = nil)
106
+ failed = replay.failed_tool_calls
107
+ assert failed.empty?, message || "Expected every tool call to succeed; failed: #{failed.map { |call| "#{call["name"]} (#{call["detail"]})" }.join(", ")}"
108
+ end
109
+
110
+ # Scores the replay as a dashboard run would and fails with the fault
111
+ # and the recommendation. Returns the Result.
112
+ #
113
+ # @param expect [Hash] the scenario's expectations: `tools:`, `contains:`, `not_contains:`
114
+ # @param prompt [String, nil] what the user asked; defaults to the prompt the replay recorded
115
+ # @param criteria [Array<Hash>] Scorer criteria
116
+ # @param judge [Judge, nil]
117
+ # @param available_tools [Hash, Array, nil] the agent's tool roster, for the diagnosis
118
+ # @param threshold [Float] the passing score
119
+ # @return [Result]
120
+ def assert_eval_passes(replay, message = nil, **options)
121
+ result = score_eval(replay, **options)
122
+ assert result.passed?, message || eval_failure_message(result)
123
+ result
124
+ end
125
+
126
+ # The scored Result for a replay, without asserting on it.
127
+ def score_eval(replay, expect: {}, prompt: nil, criteria: [], judge: nil, available_tools: nil,
128
+ threshold: PASS_THRESHOLD, **runner_options)
129
+ scenario = Scenario.from_hash({
130
+ "key" => "eval",
131
+ "prompt" => prompt || replay.metadata["prompt"] || replay.metadata[:prompt] || "",
132
+ "expect" => expect.to_h
133
+ })
134
+ spec = model_spec_for(replay)
135
+ runner = Runner.new(
136
+ scenarios: [ scenario ], models: [ spec ], replay: ->(*) { replay },
137
+ criteria: criteria, judge: judge, available_tools: available_tools || {}, threshold: threshold,
138
+ agent_name: replay.metadata["agent"] || "The agent", **runner_options
139
+ )
140
+ runner.evaluate(scenario, spec, replay)
141
+ end
142
+
143
+ # Replays a suite's scenarios across models and returns the Report.
144
+ #
145
+ # The block runs the agent for one scenario under one model spec and
146
+ # returns a Generation (which is then captured under that model) or
147
+ # anything `run_eval`'s block form accepts.
148
+ #
149
+ # A suite that declares bars (Suite#pass_thresholds differs from
150
+ # PassThresholds.default: a `threshold` other than 0.7, a
151
+ # `min_pass_rate` below 1, or any key in a group's `pass`, even one that
152
+ # restates a default) hands them to the Runner: its threshold scores
153
+ # the answers, and the Report carries the bars for
154
+ # `assert_meets_pass_gates` and renders its gates. A suite with no
155
+ # `pass` block, or only a suite-level one that restates the defaults,
156
+ # runs under the defaults, and its Report carries no bars.
157
+ #
158
+ # @param suite [Suite, String, Pathname, Array] a Suite or the YAML path(s) to load
159
+ # @param models [Array<String>, String, nil] candidate models; nil runs the agent as configured
160
+ # @param default_provider [String, nil] the provider a bare model name runs under
161
+ # @param providers [Array<String>] the provider names a `provider/model` may name
162
+ # @param groups [Array<String>, nil] narrow to these suite groups
163
+ # @param keys [Array<String>, nil] narrow to these scenario keys
164
+ # @param include_production_only [Boolean] replay scenarios a local environment cannot answer
165
+ # @param runner_options [Hash] the rest goes to Runner.new (criteria, judge, available_tools,
166
+ # instructions, ...); `pass_thresholds:` replaces the suite's bars, and `threshold:` wins over
167
+ # the suite's threshold
168
+ # @return [Report]
169
+ def run_eval_suite(suite, models: nil, default_provider: nil, providers: ModelSpec::DEFAULT_PROVIDERS,
170
+ groups: nil, keys: nil, include_production_only: false, **runner_options, &replay)
171
+ raise ArgumentError, "run_eval_suite needs a block that runs the agent for a scenario" unless replay
172
+
173
+ suite = Suite.load(*Array(suite)) unless suite.is_a?(Suite)
174
+ scenarios = suite.scenarios(groups: groups, keys: keys, include_production_only: include_production_only)
175
+ specs = models.present? ? ModelSpec.parse_all(models, default_provider: default_provider.to_s, providers: providers) : [ AS_CONFIGURED ]
176
+ unless runner_options.key?(:pass_thresholds) || suite.pass_thresholds == PassThresholds.default
177
+ runner_options = runner_options.merge(pass_thresholds: suite.pass_thresholds)
178
+ end
179
+
180
+ Runner.new(
181
+ scenarios: scenarios, models: specs,
182
+ replay: ->(scenario, spec) { capture_for_spec(replay.call(scenario, spec), spec) },
183
+ **runner_options
184
+ ).call
185
+ end
186
+
187
+ # Passes when every model's pass rate reached `at_least` (0.0..1.0).
188
+ def assert_pass_rate(report, message = nil, at_least:)
189
+ at_least = at_least.to_f
190
+ # The fraction itself, not the report's rounded percentage: 1 of 3 meets
191
+ # `at_least: 1 / 3.0`, which 33.3% would not. A model with no scenarios
192
+ # falls short.
193
+ short = report.summary_by_model.select do |_label, stats|
194
+ scenarios = stats["scenarios"].to_i
195
+ scenarios.zero? || stats["passed"].to_f / scenarios < at_least
196
+ end
197
+ assert short.empty?, message || pass_rate_failure_message(report, short, at_least)
198
+ end
199
+
200
+ # Passes when every gate of the report is met: under `thresholds` when
201
+ # given, else under the bars the report carries (Report#pass_thresholds),
202
+ # else the defaults, every group gated and every answer expected to
203
+ # pass. Fails with GateCheck#message — the unmet gates, then the
204
+ # failures to read per group — or with `message` when one is given.
205
+ # Where `assert_pass_rate` holds every model to one bar the test names,
206
+ # this holds each group to the bar the suite declares for it, and each
207
+ # model's gated answers to the suite's, leaving ungated groups out.
208
+ # Unlike `assert_pass_rate`, it takes the message third, after the
209
+ # thresholds.
210
+ #
211
+ # @param report [Report]
212
+ # @param thresholds [PassThresholds, nil] bars to hold the report to in place of its own
213
+ # @param message [String, nil] the failure message, in place of the check's
214
+ # @return [GateCheck]
215
+ def assert_meets_pass_gates(report, thresholds = nil, message = nil)
216
+ check = GateCheck.new(report, thresholds)
217
+ assert check.met?, message || check.message
218
+ check
219
+ end
220
+
221
+ private
222
+
223
+ def capture_for_spec(value, spec)
224
+ overrides = spec.equal?(AS_CONFIGURED) ? {} : { model: spec.model, provider: spec.provider.presence }
225
+ value.is_a?(ActiveAgent::Generation) ? Capture.run(value, **overrides) : Capture.run(**overrides) { value }
226
+ end
227
+
228
+ def tool_calls_named(replay, name)
229
+ replay.tool_calls.select { |call| call["name"] == name.to_s }
230
+ end
231
+
232
+ # The spec a captured replay ran under, for the Result: the provider a
233
+ # capture was asked for, else the configured service read as a provider
234
+ # name ("OpenAI" → "openai", "OpenRouter" → "openrouter").
235
+ def model_spec_for(replay)
236
+ model = replay.metadata["model"].presence
237
+ provider = replay.metadata["provider"].presence || replay.metadata["service"].to_s.underscore.delete("_")
238
+ ModelSpec.new(label: model || "as configured", provider: provider, model: model)
239
+ end
240
+
241
+ def eval_failure_message(result)
242
+ lines = [ "Eval failed: #{result.fault} — #{result.summary}" ]
243
+ lines << "Fix: #{result.recommendation}" if result.recommendation.present?
244
+ lines << "Scores: #{result.scores.map { |key, value| "#{key}=#{value.nil? ? "n/a" : Format.score(value)}" }.join(", ")}" if result.scores.present?
245
+ lines << replay_note(result.replay).strip
246
+ lines.join("\n")
247
+ end
248
+
249
+ def pass_rate_failure_message(report, short, at_least)
250
+ lines = short.map do |label, stats|
251
+ failing = report.results.select { |result| result.label == label && !result.passed? }
252
+ detail = failing.map { |result| "#{result.scenario.key} (#{result.fault || result.status})" }.join(", ")
253
+ "#{label}: #{Format.passes(stats["passed"], stats["scenarios"])} passed, below #{Format.percent(at_least)}; failing: #{detail}"
254
+ end
255
+ lines.unshift("Expected every model to pass at least #{Format.percent(at_least)} of the scenarios")
256
+ lines.join("\n")
257
+ end
258
+
259
+ def replay_note(replay)
260
+ parts = []
261
+ parts << "error: #{replay.error}" if replay.errored?
262
+ parts << "answer: #{Capture.preview(replay.answer.to_s)}" if replay.answer.present?
263
+ parts << "tools called: #{replay.tool_names.join(", ")}" if replay.tool_calls.any?
264
+ parts.empty? ? "" : "\n #{parts.join("\n ")}"
265
+ end
266
+
267
+ def tools_note(replay)
268
+ replay.tool_calls.any? ? " (called: #{replay.tool_names.join(", ")})" : " (no tools were called)#{replay_note(replay)}"
269
+ end
270
+ end
271
+
272
+ if defined?(ActiveSupport::TestCase)
273
+ # A test case with the eval helpers included, whose tests run only when
274
+ # evals are switched on (TestHelper.enabled?) and are skipped, with the
275
+ # command that runs them, otherwise. Inherit from it in test/evals; the
276
+ # app's own fixtures and helpers apply as in any test.
277
+ class TestCase < ActiveSupport::TestCase
278
+ include TestHelper
279
+
280
+ setup do
281
+ skip TestHelper::SKIP_MESSAGE unless TestHelper.enabled?
282
+ end
283
+ end
284
+ end
285
+ end
286
+ end
@@ -4,6 +4,7 @@ require "json"
4
4
  require "cgi"
5
5
  require "yaml"
6
6
  require "active_support"
7
+ require "active_support/core_ext/array/wrap"
7
8
  require "active_support/core_ext/object/blank"
8
9
  require "active_support/core_ext/string/filters"
9
10
  require "active_support/core_ext/string/access"
@@ -14,11 +15,13 @@ require "active_support/core_ext/enumerable"
14
15
 
15
16
  require_relative "evals/scenario"
16
17
  require_relative "evals/scenario_parser"
18
+ require_relative "evals/pass_thresholds"
17
19
  require_relative "evals/suite"
18
20
  require_relative "evals/catalog"
19
21
  require_relative "evals/model_spec"
20
22
  require_relative "evals/replay"
21
23
  require_relative "evals/format"
24
+ require_relative "evals/patterns"
22
25
  require_relative "evals/statistics"
23
26
  require_relative "evals/comparison"
24
27
  require_relative "evals/scorer"
@@ -29,9 +32,11 @@ require_relative "evals/design_tokens"
29
32
  require_relative "evals/report_html"
30
33
  require_relative "evals/report_markdown"
31
34
  require_relative "evals/report"
35
+ require_relative "evals/gate_check"
32
36
  require_relative "evals/runner"
33
37
  require_relative "evals/correlation"
34
38
  require_relative "evals/publisher"
39
+ require_relative "evals/capture"
35
40
 
36
41
  # Scenario evaluations for agents that answer with tools.
37
42
  #
@@ -47,7 +52,10 @@ require_relative "evals/publisher"
47
52
  # and rolling everything up per model (Report). Runner ties them together
48
53
  # around one callable you supply: given a scenario and a model, run the
49
54
  # agent and return a Replay. Correlation is optional plumbing on top: it
50
- # links the traces a run emits back to the result that caused them.
55
+ # links the traces a run emits back to the result that caused them. Capture
56
+ # runs one ActiveAgent generation into a Replay, and
57
+ # `require "active_agent/evals/test_helper"` adds the Minitest assertions an
58
+ # eval written as a test case uses (TestHelper, TestCase).
51
59
  #
52
60
  # scenarios = ActiveAgent::Evals::ScenarioParser.scenarios(pasted_text)
53
61
  # models = ActiveAgent::Evals::ModelSpec.parse_all(%w[gpt-5-mini qwen3:8b], default_provider: "openai")
@@ -17,7 +17,7 @@ module ActiveAgent
17
17
  # @example Disable sanitization entirely
18
18
  # ActiveAgent::ModelCapabilities.enabled = false
19
19
  module ModelCapabilities
20
- SAMPLING_PARAMS = [ :temperature, :top_p ].freeze
20
+ SAMPLING_PARAMS = [ :temperature, :top_p, :top_k ].freeze
21
21
 
22
22
  # Model families that reject sampling parameters with a 400:
23
23
  # - Anthropic thinking-first models (Opus 4.7+, Opus 5, Sonnet 5,
@@ -10,7 +10,7 @@ require_relative "concerns/tool_choice_clearing"
10
10
 
11
11
  # @private
12
12
  GEM_LOADERS = {
13
- anthropic: [ "anthropic", "~> 1.12", "anthropic" ],
13
+ anthropic: [ "anthropic", [ ">= 1.12", "< 3" ], "anthropic" ],
14
14
  openai: [ "openai", "~> 0.34", "openai" ],
15
15
  # Keep a tested floor: 1.2 lacks the provider APIs this adapter uses.
16
16
  ruby_llm: [ "ruby_llm", [ ">= 1.16", "< 3" ], "ruby_llm" ]
@@ -134,6 +134,7 @@ module ActiveAgent
134
134
  :stream_completion_result, # Callback (Streams)
135
135
  :stream_finish_reason, # Callback (Streams)
136
136
  :tools_function, # Callback (Tools)
137
+ :tool_observer, # Callback (Tools, MCP-served calls)
137
138
  :usage_stack, # Usage Tracking
138
139
  :stream_usage_index, # Usage Tracking (Streams)
139
140
  :max_tool_turns, :tool_turns, # Tool-loop safety
@@ -193,6 +194,7 @@ module ActiveAgent
193
194
  self.stream_broadcaster = kwargs.delete(:stream_broadcaster)
194
195
  self.streaming = false
195
196
  self.tools_function = kwargs.delete(:tools_function)
197
+ self.tool_observer = kwargs.delete(:tool_observer)
196
198
  self.max_tool_turns = kwargs.delete(:max_tool_turns) || DEFAULT_MAX_TOOL_TURNS
197
199
  self.tool_turns = 0
198
200
  self.instrumentation_enabled = kwargs.delete(:instrumentation) != false
@@ -273,6 +275,10 @@ module ActiveAgent
273
275
  # Invokes a tool, on an MCP server when one provides it and on the agent's
274
276
  # own tool function otherwise.
275
277
  #
278
+ # A call served by an MCP server never reaches the agent's tool function,
279
+ # so it runs inside the `tool_observer`, when the agent has one, which sees
280
+ # the call and its result without changing either.
281
+ #
276
282
  # @param name [String] tool name
277
283
  # @param kwargs [Hash] tool arguments
278
284
  # @return [Object] the tool's result, or an {MCPBridge::ErrorResult} for
@@ -281,13 +287,23 @@ module ActiveAgent
281
287
  # tool needs approval and the provider's tool loop never asked for it
282
288
  def call_tool_function(name, **kwargs)
283
289
  assert_approval_gate_reached!(name)
284
- return mcp_call_tool(name, **kwargs) if mcp_owns_tool?(name)
290
+ return observe_mcp_tool_call(name, kwargs) { mcp_call_tool(name, **kwargs) } if mcp_owns_tool?(name)
285
291
 
286
292
  result = isolate_undispatched_tool_call { tools_function.call(name, **kwargs) }
287
293
  assert_input_request_supported!(result)
288
294
  result
289
295
  end
290
296
 
297
+ # @param name [String]
298
+ # @param arguments [Hash]
299
+ # @yieldreturn [Object] the tool's result
300
+ # @return [Object] the block's result
301
+ def observe_mcp_tool_call(name, arguments, &call)
302
+ return yield unless tool_observer
303
+
304
+ tool_observer.call(name, arguments, &call)
305
+ end
306
+
291
307
  # @param name [String, nil]
292
308
  # @raise [RuntimeError] when service name doesn't match provider
293
309
  def assert_service!(name)
@@ -197,6 +197,7 @@ module ActiveAgent
197
197
  when :message_start
198
198
  api_message = Anthropic::Transforms.gem_to_hash(api_response_chunk.message)
199
199
  self.message_stack.push(api_message)
200
+ record_stream_usage(api_message[:usage])
200
201
  broadcast_stream_update(message_stack.last)
201
202
 
202
203
  # -> Content Block Create
@@ -239,6 +240,15 @@ module ActiveAgent
239
240
  self.message_stack.last.merge!(delta)
240
241
  self.stream_finish_reason = delta[:stop_reason] if delta[:stop_reason]
241
242
 
243
+ # message_delta carries the cumulative output tokens (and, on newer
244
+ # API versions, refreshed input counts); layer them over the usage
245
+ # message_start reported so the turn keeps a single running total.
246
+ delta_usage = Anthropic::Transforms.gem_to_hash(api_response_chunk.usage)&.compact
247
+ if delta_usage.present?
248
+ message_stack.last[:usage] = (message_stack.last[:usage] || {}).merge(delta_usage)
249
+ record_stream_usage(message_stack.last[:usage])
250
+ end
251
+
242
252
  # Message Completed [Full Message]
243
253
  when :message_stop
244
254
  api_message = Anthropic::Transforms.gem_to_hash(api_response_chunk.message)
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../open_ai/options"
4
+
5
+ module ActiveAgent
6
+ module Providers
7
+ module AtlasCloud
8
+ # Configuration options for the Atlas Cloud provider.
9
+ class Options < ActiveAgent::Providers::OpenAI::Options
10
+ # Atlas Cloud's OpenAI-compatible endpoint.
11
+ DEFAULT_BASE_URL = "https://api.atlascloud.ai/v1"
12
+
13
+ # @param kwargs [Hash]
14
+ # @option kwargs [String] :api_key
15
+ # @option kwargs [String] :base_url (https://api.atlascloud.ai/v1)
16
+ def initialize(kwargs = {})
17
+ kwargs = kwargs.deep_symbolize_keys if kwargs.respond_to?(:deep_symbolize_keys)
18
+
19
+ super(kwargs.merge(base_url: kwargs[:base_url].presence || DEFAULT_BASE_URL))
20
+ end
21
+
22
+ private
23
+
24
+ def resolve_api_key(kwargs)
25
+ kwargs[:api_key] ||
26
+ kwargs[:access_token] ||
27
+ ENV["ATLASCLOUD_API_KEY"]
28
+ end
29
+
30
+ def resolve_organization_id(_kwargs) = nil
31
+ def resolve_project_id(_kwargs) = nil
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "_base_provider"
4
+
5
+ require_gem!(:openai, __FILE__)
6
+
7
+ require_relative "open_ai_provider"
8
+ require_relative "atlas_cloud/options"
9
+
10
+ module ActiveAgent
11
+ module Providers
12
+ # Provides access to Atlas Cloud's OpenAI-compatible Chat Completions API.
13
+ #
14
+ # Atlas Cloud uses provider/model identifiers such as +qwen/qwen3.8-max+.
15
+ # Request and response handling is shared with the OpenAI Chat provider;
16
+ # only the endpoint and API-key resolution are provider-specific.
17
+ class AtlasCloudProvider < OpenAI::ChatProvider
18
+ # @return [String]
19
+ def self.service_name
20
+ "AtlasCloud"
21
+ end
22
+
23
+ # @return [Class]
24
+ def self.options_klass
25
+ AtlasCloud::Options
26
+ end
27
+
28
+ # @return [ActiveModel::Type::Value]
29
+ def self.prompt_request_type
30
+ OpenAI::Chat::RequestType.new
31
+ end
32
+
33
+ protected
34
+
35
+ # @see BaseProvider#api_response_normalize
36
+ # @param api_response [OpenAI::Models::ChatCompletion]
37
+ # @return [Hash]
38
+ def api_response_normalize(api_response)
39
+ return api_response unless api_response
40
+
41
+ OpenAI::Chat::Transforms.gem_to_hash(api_response)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -51,7 +51,8 @@ module ActiveAgent
51
51
  )
52
52
 
53
53
  @messages = ::Anthropic::Resources::Messages.new(client: self)
54
- @completions = ::Anthropic::Resources::Completions.new(client: self)
54
+ # The anthropic gem 2.0 removed Text Completions along with this resource.
55
+ @completions = ::Anthropic::Resources::Completions.new(client: self) if defined?(::Anthropic::Resources::Completions)
55
56
  @beta = ::Anthropic::Resources::Beta.new(client: self)
56
57
  end
57
58
 
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../open_ai/chat/_types"
4
+ require_relative "options"
5
+
6
+ module ActiveAgent
7
+ module Providers
8
+ module CheaperInference
9
+ # ActiveModel type for casting and serializing Cheaper Inference requests.
10
+ #
11
+ # Cheaper Inference is OpenAI-compatible, so requests use the same shape
12
+ # as the OpenAI Chat API. This delegates entirely to
13
+ # OpenAI::Chat::RequestType.
14
+ RequestType = ActiveAgent::Providers::OpenAI::Chat::RequestType
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../open_ai/options"
4
+
5
+ module ActiveAgent
6
+ module Providers
7
+ module CheaperInference
8
+ # Configuration options for the Cheaper Inference provider.
9
+ #
10
+ # Extends OpenAI::Options, overriding the base URL to point at Cheaper
11
+ # Inference's OpenAI-compatible gateway and resolving the API key from
12
+ # CHEAPER_INFERENCE_API_KEY. Cheaper Inference does not use organization
13
+ # or project identifiers.
14
+ #
15
+ # @example Basic configuration
16
+ # options = Options.new(api_key: ENV["CHEAPER_INFERENCE_API_KEY"])
17
+ #
18
+ # @see https://cheaperinference.com/docs
19
+ # @see https://cheaperinference.com/signup Cheaper Inference API Keys
20
+ class Options < ActiveAgent::Providers::OpenAI::Options
21
+ # @!attribute base_url
22
+ # @return [String] API endpoint (default: "https://api.cheaperinference.com/v1")
23
+ attribute :base_url, :string, as: "https://api.cheaperinference.com/v1"
24
+
25
+ private
26
+
27
+ def resolve_api_key(kwargs)
28
+ kwargs[:api_key] ||
29
+ kwargs[:access_token] ||
30
+ ENV["CHEAPER_INFERENCE_API_KEY"]
31
+ end
32
+
33
+ # Not used as part of Cheaper Inference
34
+ def resolve_organization_id(kwargs) = nil
35
+ def resolve_project_id(kwargs) = nil
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,72 @@
1
+ require_relative "_base_provider"
2
+
3
+ require_gem!(:openai, __FILE__)
4
+
5
+ require_relative "open_ai_provider"
6
+ require_relative "cheaper_inference/_types"
7
+
8
+ module ActiveAgent
9
+ module Providers
10
+ # Provides access to Cheaper Inference's OpenAI-compatible LLM gateway.
11
+ #
12
+ # Extends the OpenAI provider to work with Cheaper Inference's
13
+ # OpenAI-compatible API, enabling access to models from many providers
14
+ # through a single interface. Model ids are bare names (e.g.
15
+ # +gpt-5.4-mini+).
16
+ #
17
+ # Cheaper Inference is a plain OpenAI-compatible gateway: requests,
18
+ # responses and transforms are identical to the OpenAI Chat API, so this
19
+ # provider reuses OpenAI::Chat::RequestType and OpenAI::Chat::Transforms
20
+ # directly. The only Cheaper Inference-specific configuration is the base
21
+ # URL and API key, which live in CheaperInference::Options.
22
+ #
23
+ # @example Configuration in active_agent.yml
24
+ # cheaper_inference:
25
+ # service: "CheaperInference"
26
+ # api_key: <%= ENV["CHEAPER_INFERENCE_API_KEY"] %>
27
+ # model: "gpt-5.4-mini"
28
+ #
29
+ # @see OpenAI::ChatProvider
30
+ # @see https://cheaperinference.com/docs
31
+ class CheaperInferenceProvider < OpenAI::ChatProvider
32
+ # @return [String]
33
+ def self.service_name
34
+ "CheaperInference"
35
+ end
36
+
37
+ # @return [Class]
38
+ def self.options_klass
39
+ CheaperInference::Options
40
+ end
41
+
42
+ # @return [ActiveModel::Type::Value]
43
+ def self.prompt_request_type
44
+ OpenAI::Chat::RequestType.new
45
+ end
46
+
47
+ # @return [ActiveModel::Type::Value]
48
+ def self.embed_request_type
49
+ OpenAI::Embedding::RequestType.new
50
+ end
51
+
52
+ protected
53
+
54
+ # Executes an embedding request via Cheaper Inference's OpenAI-compatible API.
55
+ #
56
+ # @param parameters [Hash]
57
+ # @return [Hash] symbolized API response
58
+ def api_embed_execute(parameters)
59
+ client.embeddings.create(**parameters).as_json.deep_symbolize_keys
60
+ end
61
+
62
+ # @see BaseProvider#api_response_normalize
63
+ # @param api_response [OpenAI::Models::ChatCompletion]
64
+ # @return [Hash] normalized response hash
65
+ def api_response_normalize(api_response)
66
+ return api_response unless api_response
67
+
68
+ OpenAI::Chat::Transforms.gem_to_hash(api_response)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -148,7 +148,8 @@ module ActiveAgent
148
148
  cached_tokens: sum_optional(self.cached_tokens, other.cached_tokens),
149
149
  cache_creation_tokens: sum_optional(self.cache_creation_tokens, other.cache_creation_tokens),
150
150
  reasoning_tokens: sum_optional(self.reasoning_tokens, other.reasoning_tokens),
151
- audio_tokens: sum_optional(self.audio_tokens, other.audio_tokens)
151
+ audio_tokens: sum_optional(self.audio_tokens, other.audio_tokens),
152
+ duration_ms: sum_optional(self.duration_ms, other.duration_ms)
152
153
  )
153
154
  end
154
155
 
@@ -263,6 +264,10 @@ module ActiveAgent
263
264
  **usage.slice(:input_tokens, :output_tokens, :service_tier),
264
265
  input_tokens: usage[:input_tokens] || 0,
265
266
  output_tokens: usage[:output_tokens] || 0,
267
+ # Anthropic's input_tokens excludes cache reads and writes, which are
268
+ # reported separately, so they are added to get every token processed.
269
+ total_tokens: (usage[:input_tokens] || 0) + (usage[:output_tokens] || 0) +
270
+ (usage[:cache_read_input_tokens] || 0) + (usage[:cache_creation_input_tokens] || 0),
266
271
  cached_tokens: usage[:cache_read_input_tokens],
267
272
  cache_creation_tokens: usage[:cache_creation_input_tokens],
268
273
  provider_details: usage.slice(:cache_creation, :server_tool_use).compact