activeagent 1.9.6 → 1.9.7

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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +436 -28
  3. data/README.md +6 -0
  4. data/lib/active_agent/base.rb +1 -0
  5. data/lib/active_agent/cli/eval_commands.rb +3 -3
  6. data/lib/active_agent/concerns/tooling.rb +9 -0
  7. data/lib/active_agent/evals/capture.rb +275 -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/providers/_base_provider.rb +17 -1
  27. data/lib/active_agent/providers/atlas_cloud/options.rb +35 -0
  28. data/lib/active_agent/providers/atlas_cloud_provider.rb +45 -0
  29. data/lib/active_agent/providers/cheaper_inference/_types.rb +17 -0
  30. data/lib/active_agent/providers/cheaper_inference/options.rb +39 -0
  31. data/lib/active_agent/providers/cheaper_inference_provider.rb +72 -0
  32. data/lib/active_agent/providers/ollama/_types.rb +1 -0
  33. data/lib/active_agent/providers/ollama/catalog.rb +221 -0
  34. data/lib/active_agent/providers/ollama/cloud.rb +109 -0
  35. data/lib/active_agent/providers/ollama/options.rb +66 -2
  36. data/lib/active_agent/providers/ollama_provider.rb +55 -3
  37. data/lib/active_agent/railtie.rb +4 -0
  38. data/lib/active_agent/version.rb +1 -1
  39. data/lib/tasks/active_agent/evals.rake +28 -0
  40. metadata +16 -3
  41. data/lib/tasks/activeagent_tasks.rake +0 -4
@@ -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
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "options"
4
+ require_relative "catalog"
4
5
  require_relative "chat/_types"
5
6
  require_relative "embedding/_types"
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "uri"
7
+ require_relative "cloud"
8
+
9
+ module ActiveAgent
10
+ module Providers
11
+ module Ollama
12
+ # The models an Ollama host serves, from the host's own API: GET
13
+ # /api/tags lists them and POST /api/show describes one. Works against
14
+ # a local server and against Ollama Cloud alike. The host is the
15
+ # provider's base URL (".../v1"); the native API lives beside it.
16
+ #
17
+ # catalog = ActiveAgent::Providers::Ollama::Catalog.new(host: "https://ollama.com", api_key: ENV["OLLAMA_API_KEY"])
18
+ # catalog.models.map(&:name) # => ["gpt-oss:120b", "kimi-k3", ...]
19
+ # catalog.model_info("gpt-oss:120b") # => #<struct Model name="gpt-oss:120b", cloud=true, capabilities=["completion", "tools", "thinking"], ...>
20
+ #
21
+ # A local server lists the cloud models it relays (`gpt-oss:120b-cloud`)
22
+ # beside the ones it runs itself, and each Model says which it is. A
23
+ # server with no native API — an OpenAI-compatible proxy in front of
24
+ # one — is listed through GET /v1/models instead, which names models
25
+ # and nothing else.
26
+ #
27
+ # Prices are not here: the host's API does not publish them. The
28
+ # dashboard's ActionAgent::ModelPricing prices a cloud model by its
29
+ # canonical name.
30
+ class Catalog
31
+ class Error < StandardError; end
32
+ # The host could not be reached, or did not answer in time.
33
+ class ConnectionError < Error; end
34
+ # The host refused the API key (401 or 403).
35
+ class AuthenticationError < Error; end
36
+ # The host does not serve the model asked about.
37
+ class NotFoundError < Error; end
38
+
39
+ DEFAULT_TIMEOUT = 10
40
+
41
+ # One model, as the host lists (#models) or describes (#model_info)
42
+ # it. A listing carries details and size; a description adds the
43
+ # capabilities and the context length.
44
+ Model = Struct.new(:name, :cloud, :details, :capabilities, :context_length, :size, :modified_at, :raw,
45
+ keyword_init: true) do
46
+ # The name the cloud's catalog and price list use (Cloud.canonical_model).
47
+ def canonical_name
48
+ Cloud.canonical_model(name)
49
+ end
50
+
51
+ # Whether Ollama Cloud serves the model: listed by the cloud, or
52
+ # relayed to it by a local server.
53
+ def cloud?
54
+ cloud == true
55
+ end
56
+
57
+ def family
58
+ details["family"]
59
+ end
60
+
61
+ def parameter_size
62
+ details["parameter_size"]
63
+ end
64
+
65
+ def quantization_level
66
+ details["quantization_level"]
67
+ end
68
+
69
+ def to_h
70
+ {
71
+ name: name, canonical_name: canonical_name, cloud: cloud?, details: details,
72
+ capabilities: capabilities, context_length: context_length, size: size, modified_at: modified_at
73
+ }
74
+ end
75
+ end
76
+
77
+ attr_reader :host, :timeout
78
+
79
+ # @param host [String] the server address, bare or with its /v1 path
80
+ # @param api_key [String, nil] sent as a Bearer token; the local
81
+ # placeholder is not sent
82
+ # @param timeout [Numeric] seconds to connect and to read
83
+ def initialize(host:, api_key: nil, timeout: DEFAULT_TIMEOUT)
84
+ @host = Cloud.normalize_host(host) || Cloud::LOCAL_BASE_URL
85
+ key = api_key.to_s.strip
86
+ @api_key = key unless key.empty? || key == Cloud::PLACEHOLDER_API_KEY
87
+ @timeout = timeout
88
+ end
89
+
90
+ # @return [Boolean] whether the host is Ollama Cloud
91
+ def cloud?
92
+ Cloud.cloud_host?(host)
93
+ end
94
+
95
+ # The models the host serves.
96
+ #
97
+ # @return [Array<Model>]
98
+ # @raise [Error]
99
+ def models
100
+ response = request(Net::HTTP::Get, "/api/tags", allow_not_found: true)
101
+ return openai_models if response.code.to_i == 404
102
+
103
+ Array(parse(response, "/api/tags")["models"]).filter_map { |entry| from_listing(entry) }
104
+ end
105
+
106
+ # @return [Array<String>] the names of the models the host serves
107
+ def model_names
108
+ models.map(&:name)
109
+ end
110
+
111
+ # One model, described: its details, capabilities and context length.
112
+ #
113
+ # @param name [String]
114
+ # @return [Model]
115
+ # @raise [NotFoundError] when the host does not serve +name+
116
+ # @raise [Error]
117
+ def model_info(name)
118
+ response = request(Net::HTTP::Post, "/api/show", { model: name.to_s }, allow_not_found: true)
119
+ raise NotFoundError, "#{host} does not serve #{name}" if response.code.to_i == 404
120
+
121
+ from_show(name.to_s, parse(response, "/api/show"))
122
+ end
123
+
124
+ private
125
+
126
+ def openai_models
127
+ data = parse(request(Net::HTTP::Get, "/v1/models"), "/v1/models")
128
+ Array(data["data"]).filter_map do |entry|
129
+ id = entry.is_a?(Hash) ? entry["id"].to_s : ""
130
+ next if id.empty?
131
+
132
+ Model.new(name: id, cloud: cloud? || Cloud.cloud_model?(id), details: {}, capabilities: [], raw: entry)
133
+ end
134
+ end
135
+
136
+ def from_listing(entry)
137
+ return nil unless entry.is_a?(Hash)
138
+
139
+ name = (entry["name"] || entry["model"]).to_s
140
+ return nil if name.empty?
141
+
142
+ Model.new(
143
+ name: name,
144
+ cloud: cloud_model?(name, entry),
145
+ details: hash_at(entry, "details"),
146
+ capabilities: Array(entry["capabilities"]).map(&:to_s),
147
+ context_length: nil,
148
+ size: entry["size"],
149
+ modified_at: entry["modified_at"],
150
+ raw: entry
151
+ )
152
+ end
153
+
154
+ def from_show(name, data)
155
+ info = hash_at(data, "model_info")
156
+ architecture = info["general.architecture"]
157
+ context_length = info["#{architecture}.context_length"] ||
158
+ info.find { |key, _| key.to_s.end_with?(".context_length") }&.last
159
+
160
+ Model.new(
161
+ name: name,
162
+ cloud: cloud_model?(name, data),
163
+ details: hash_at(data, "details"),
164
+ capabilities: Array(data["capabilities"]).map(&:to_s),
165
+ context_length: context_length.is_a?(Numeric) ? context_length.to_i : nil,
166
+ size: nil,
167
+ modified_at: data["modified_at"],
168
+ raw: data
169
+ )
170
+ end
171
+
172
+ # Listed by the cloud itself, relayed to it by a local server (which
173
+ # names the remote host), or spelled with the cloud marker.
174
+ def cloud_model?(name, entry)
175
+ cloud? || !entry["remote_host"].to_s.empty? || Cloud.cloud_model?(name)
176
+ end
177
+
178
+ def hash_at(data, key)
179
+ value = data[key]
180
+ value.is_a?(Hash) ? value : {}
181
+ end
182
+
183
+ def request(method_class, path, body = nil, allow_not_found: false)
184
+ uri = URI.parse("#{Cloud.api_origin(host)}#{path}")
185
+ request = method_class.new(uri.request_uri, headers)
186
+ if body
187
+ request["Content-Type"] = "application/json"
188
+ request.body = JSON.generate(body)
189
+ end
190
+
191
+ response = Net::HTTP.start(
192
+ uri.host, uri.port,
193
+ use_ssl: uri.scheme == "https", open_timeout: timeout, read_timeout: timeout
194
+ ) { |http| http.request(request) }
195
+
196
+ status = response.code.to_i
197
+ raise AuthenticationError, "#{uri} refused the API key (#{status})" if [ 401, 403 ].include?(status)
198
+ return response if status == 200 || (status == 404 && allow_not_found)
199
+
200
+ raise Error, "#{uri} responded #{status}"
201
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::ECONNRESET, SocketError,
202
+ Net::OpenTimeout, Net::ReadTimeout, IOError, OpenSSL::SSL::SSLError => e
203
+ raise ConnectionError, "Could not reach #{host} (#{e.class.name.split('::').last}: #{e.message})"
204
+ end
205
+
206
+ def parse(response, path)
207
+ data = JSON.parse(response.body.to_s)
208
+ data.is_a?(Hash) ? data : {}
209
+ rescue JSON::ParserError
210
+ raise Error, "#{Cloud.api_origin(host)}#{path} did not return JSON"
211
+ end
212
+
213
+ def headers
214
+ headers = { "Accept" => "application/json" }
215
+ headers["Authorization"] = "Bearer #{@api_key}" if @api_key
216
+ headers
217
+ end
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module ActiveAgent
6
+ module Providers
7
+ module Ollama
8
+ # Ollama Cloud: the models Ollama serves from ollama.com, and the two
9
+ # routes a request takes to them.
10
+ #
11
+ # Directly, the host is https://ollama.com, every request carries an
12
+ # API key (https://ollama.com/settings/keys), and a model is named as
13
+ # the cloud lists it: `gpt-oss:120b`. Through a local server signed in
14
+ # to ollama.com (`ollama signin`), the host is the local one, no key is
15
+ # needed, and the same model carries the local server's cloud marker:
16
+ # `gpt-oss:120b-cloud`, or `glm-4.6:cloud`. These helpers tell the two
17
+ # routes apart and reduce either spelling to the name the cloud's
18
+ # catalog and price list use, so a trace or an evaluation prices the
19
+ # model the same way whichever route served it. The dashboard's
20
+ # ActionAgent::ModelPricing keeps a copy of the two naming rules, so a
21
+ # change here needs the same change there.
22
+ module Cloud
23
+ HOST = "https://ollama.com"
24
+ # The OpenAI-compatible endpoint the provider talks to.
25
+ BASE_URL = "#{HOST}/v1"
26
+ LOCAL_BASE_URL = "http://127.0.0.1:11434/v1"
27
+ # The port the ollama command assumes when a host names none.
28
+ DEFAULT_PORT = 11_434
29
+ # The key a local server is sent when none is configured; it ignores
30
+ # it, and the cloud refuses it.
31
+ PLACEHOLDER_API_KEY = "ollama"
32
+ # A local server's spellings of a cloud model.
33
+ MODEL_SUFFIX = "-cloud"
34
+ MODEL_TAG = ":cloud"
35
+
36
+ module_function
37
+
38
+ # Whether +url+ addresses Ollama Cloud.
39
+ #
40
+ # @param url [String, nil]
41
+ # @return [Boolean]
42
+ def cloud_host?(url)
43
+ host = URI.parse(url.to_s.strip).host.to_s.downcase
44
+ host == "ollama.com" || host.end_with?(".ollama.com")
45
+ rescue URI::InvalidURIError
46
+ false
47
+ end
48
+
49
+ # Whether +name+ carries a local server's cloud marker.
50
+ #
51
+ # @param name [String, nil]
52
+ # @return [Boolean]
53
+ def cloud_model?(name)
54
+ name = name.to_s.strip
55
+ name.end_with?(MODEL_SUFFIX) || name.end_with?(MODEL_TAG)
56
+ end
57
+
58
+ # The name the cloud lists a model under: "gpt-oss:120b-cloud" →
59
+ # "gpt-oss:120b", "glm-4.6:cloud" → "glm-4.6"; any other name as given.
60
+ #
61
+ # @param name [String, nil]
62
+ # @return [String]
63
+ def canonical_model(name)
64
+ name = name.to_s.strip
65
+ return name.delete_suffix(MODEL_TAG) if name.end_with?(MODEL_TAG)
66
+ return name.delete_suffix(MODEL_SUFFIX) if name.end_with?(MODEL_SUFFIX)
67
+
68
+ name
69
+ end
70
+
71
+ # A server address as the base URL of its OpenAI-compatible API, read
72
+ # the way the ollama command reads OLLAMA_HOST: a bare origin
73
+ # (http://localhost:11434, https://ollama.com) gets the /v1 path, a
74
+ # scheme-less "host[:port]" is http, an http address with no port is
75
+ # on 11434, trailing slashes go, and an explicit path is kept for a
76
+ # server behind a reverse proxy. nil for a blank value.
77
+ #
78
+ # @param value [String, nil]
79
+ # @return [String, nil]
80
+ def normalize_host(value)
81
+ host = value.to_s.strip.chomp("/")
82
+ return nil if host.empty?
83
+
84
+ unless host.match?(%r{\A[a-z][a-z0-9+.-]*://}i)
85
+ host = "#{cloud_host?("https://#{host}") ? 'https' : 'http'}://#{host}"
86
+ end
87
+ uri = URI.parse(host)
88
+ return host unless uri.is_a?(URI::HTTP)
89
+
90
+ uri.port = DEFAULT_PORT if uri.scheme == "http" && !host.match?(%r{\A[a-z][a-z0-9+.-]*://[^/]*:\d+}i)
91
+ uri.path = "/v1" if uri.path.empty? || uri.path == "/"
92
+ uri.to_s.chomp("/")
93
+ rescue URI::InvalidURIError
94
+ host
95
+ end
96
+
97
+ # Where the host's own API lives, beside its OpenAI-compatible one:
98
+ # "https://ollama.com/v1" → "https://ollama.com", and a reverse-proxied
99
+ # "https://ai.example.com/ollama/v1" → "https://ai.example.com/ollama".
100
+ #
101
+ # @param base_url [String]
102
+ # @return [String]
103
+ def api_origin(base_url)
104
+ base_url.to_s.strip.chomp("/").delete_suffix("/v1")
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -1,16 +1,80 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../open_ai/options"
4
+ require_relative "../errors"
5
+ require_relative "cloud"
4
6
 
5
7
  module ActiveAgent
6
8
  module Providers
7
9
  module Ollama
10
+ # Where the provider sends requests and how it signs them.
11
+ #
12
+ # The host is, in order: `host:` (also `base_url:` or `uri_base:`),
13
+ # `cloud: true` for Ollama Cloud, the OLLAMA_HOST variable the ollama
14
+ # command itself reads, and the local server at 127.0.0.1:11434. A bare
15
+ # origin is completed to its OpenAI-compatible /v1 endpoint
16
+ # ({Cloud.normalize_host}). The key is `api_key:` (also
17
+ # `access_token:`), else OLLAMA_API_KEY or OLLAMA_ACCESS_TOKEN, else a
18
+ # placeholder a local server ignores. Ollama Cloud refuses the
19
+ # placeholder, which {#require_cloud_api_key!} reports before a request
20
+ # is made.
21
+ #
22
+ # @example Ollama Cloud
23
+ # generate_with :ollama, cloud: true, model: "gpt-oss:120b" # key from OLLAMA_API_KEY
24
+ #
25
+ # @example A local server signed in to the cloud
26
+ # generate_with :ollama, model: "gpt-oss:120b-cloud" # no key needed
8
27
  class Options < ActiveAgent::Providers::OpenAI::Options
9
- attribute :base_url, :string, fallback: "http://127.0.0.1:11434/v1"
10
- attribute :api_key, :string, fallback: "ollama"
28
+ attribute :base_url, :string, fallback: Cloud::LOCAL_BASE_URL
29
+ attribute :api_key, :string, fallback: Cloud::PLACEHOLDER_API_KEY
30
+ # Ollama Cloud in one word: requests go to https://ollama.com/v1
31
+ # unless a host is given, and a key is expected.
32
+ attribute :cloud, :boolean
33
+
34
+ def initialize(kwargs = {})
35
+ kwargs = kwargs.deep_symbolize_keys if kwargs.respond_to?(:deep_symbolize_keys)
36
+
37
+ super(kwargs.except(:host, :uri_base).merge(base_url: resolve_base_url(kwargs)))
38
+ end
39
+
40
+ # @return [Boolean] whether requests go to Ollama Cloud
41
+ def cloud?
42
+ cloud == true || Cloud.cloud_host?(base_url)
43
+ end
44
+
45
+ # The OpenAI client's options: `cloud` is this provider's alone.
46
+ #
47
+ # @return [Hash]
48
+ def serialize
49
+ super.except(:cloud)
50
+ end
51
+
52
+ # Ollama Cloud signs every request with an API key. Raised ahead of
53
+ # the request the cloud would refuse with a 401, naming what to set.
54
+ #
55
+ # @return [void]
56
+ # @raise [Errors::AuthenticationFailed] on the cloud host without a key
57
+ def require_cloud_api_key!
58
+ return unless cloud?
59
+ return if api_key.present? && api_key != Cloud::PLACEHOLDER_API_KEY
60
+
61
+ raise Errors::AuthenticationFailed.new(
62
+ "Ollama Cloud (#{base_url}) needs an API key: set OLLAMA_API_KEY, or pass api_key: " \
63
+ "(keys are created at https://ollama.com/settings/keys)",
64
+ provider_tag: "Ollama"
65
+ )
66
+ end
11
67
 
12
68
  private
13
69
 
70
+ def resolve_base_url(kwargs)
71
+ explicit = kwargs[:base_url].presence || kwargs[:host].presence || kwargs[:uri_base].presence
72
+ return Cloud.normalize_host(explicit) if explicit
73
+ return Cloud::BASE_URL if ActiveModel::Type::Boolean.new.cast(kwargs[:cloud])
74
+
75
+ Cloud.normalize_host(ENV["OLLAMA_HOST"])
76
+ end
77
+
14
78
  def resolve_api_key(kwargs)
15
79
  kwargs[:api_key] ||
16
80
  kwargs[:access_token] ||
@@ -7,12 +7,24 @@ require_relative "ollama/_types"
7
7
 
8
8
  module ActiveAgent
9
9
  module Providers
10
- # Connects to local Ollama instances via OpenAI-compatible API.
10
+ # Connects to Ollama through its OpenAI-compatible API: a local server,
11
+ # a remote one, or Ollama Cloud (https://ollama.com).
11
12
  #
12
- # Provides chat completion and embedding functionality through locally-hosted
13
- # Ollama models. Handles connection errors specific to local deployments.
13
+ # Provides chat completion and embedding functionality, and lists and
14
+ # describes the models the host serves ({#models}, {#model_info}).
15
+ # Handles connection errors specific to local deployments, and asks for
16
+ # an API key before a request to the cloud that would be refused
17
+ # without one.
18
+ #
19
+ # @example A local server
20
+ # generate_with :ollama, model: "qwen3:8b"
21
+ #
22
+ # @example Ollama Cloud, the key from OLLAMA_API_KEY
23
+ # generate_with :ollama, cloud: true, model: "gpt-oss:120b"
14
24
  #
15
25
  # @see OpenAI::ChatProvider
26
+ # @see Ollama::Options
27
+ # @see Ollama::Cloud
16
28
  class OllamaProvider < OpenAI::ChatProvider
17
29
  # @return [String]
18
30
  def self.service_name
@@ -34,6 +46,46 @@ module ActiveAgent
34
46
  namespace::Embedding::RequestType.new
35
47
  end
36
48
 
49
+ # @return [Boolean] whether requests go to Ollama Cloud
50
+ def cloud?
51
+ options.cloud?
52
+ end
53
+
54
+ # The models the configured host serves, from the host's own API.
55
+ #
56
+ # @return [Ollama::Catalog]
57
+ def catalog
58
+ namespace::Catalog.new(host: options.base_url, api_key: options.api_key)
59
+ end
60
+
61
+ # Lists the models the configured host serves.
62
+ #
63
+ # @return [Array<Ollama::Catalog::Model>]
64
+ # @raise [Ollama::Catalog::Error] when the host cannot be read
65
+ def models
66
+ catalog.models
67
+ end
68
+
69
+ # Describes one model the configured host serves: its details,
70
+ # capabilities and context length.
71
+ #
72
+ # @param name [String]
73
+ # @return [Ollama::Catalog::Model]
74
+ # @raise [Ollama::Catalog::NotFoundError] when the host does not serve it
75
+ # @raise [Ollama::Catalog::Error] when the host cannot be read
76
+ def model_info(name)
77
+ catalog.model_info(name)
78
+ end
79
+
80
+ # The OpenAI client, once the cloud's key requirement is met.
81
+ #
82
+ # @see OpenAI::Base#client
83
+ # @raise [Errors::AuthenticationFailed] on the cloud host without a key
84
+ def client
85
+ options.require_cloud_api_key!
86
+ super
87
+ end
88
+
37
89
  protected
38
90
 
39
91
  # Executes chat completion request with Ollama-specific error handling.
@@ -15,6 +15,10 @@ module ActiveAgent
15
15
  config.active_agent.telemetry = ActiveSupport::OrderedOptions.new
16
16
  config.eager_load_namespaces << ActiveAgent
17
17
 
18
+ rake_tasks do
19
+ load File.expand_path("../tasks/active_agent/evals.rake", __dir__)
20
+ end
21
+
18
22
  initializer "active_agent.deprecator", before: :load_environment_config do |app|
19
23
  app.deprecators[:active_agent] = ActiveAgent.deprecator
20
24
  end
@@ -1,3 +1,3 @@
1
1
  module ActiveAgent
2
- VERSION = "1.9.6"
2
+ VERSION = "1.9.7"
3
3
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Evals are ordinary tests under test/evals that each call a model, so
4
+ # `rails test` skips them (ActiveAgent::Evals::TestCase checks EVALS) and this
5
+ # task runs them: it switches EVALS on and hands test/evals to the same runner
6
+ # `rails test` uses, so seeds, parallel workers and reporters behave the same.
7
+ #
8
+ # bin/rails test:evals
9
+ # bin/rails test:evals TEST=test/evals/support_agent_test.rb
10
+ # bin/rails test:evals TESTOPTS="--seed 1234 -v"
11
+ # EVALS=1 bin/rails test test/evals/support_agent_test.rb:12 # one eval, through rails test
12
+ # EVALS=1 bin/rails test # the whole suite, evals included
13
+ namespace :test do
14
+ desc "Run the agent evals in test/evals (switches EVALS on); TEST=path narrows, TESTOPTS passes options"
15
+ task :evals do
16
+ require "active_agent/evals/test_helper"
17
+ require "rails/test_unit/runner"
18
+
19
+ paths = Array(ENV["TEST"]).map(&:to_s).reject(&:empty?).presence || [ "test/evals" ]
20
+ # A path, a path:line or a glob, as `rails test` takes them.
21
+ missing = paths.reject { |path| File.exist?(path.sub(/:\d+\z/, "")) || Dir.glob(path).any? }
22
+ abort "No evals found: #{missing.join(", ")} does not exist. Evals are tests under test/evals." if missing.any?
23
+
24
+ ENV[ActiveAgent::Evals::TestHelper::ENV_KEY] = "1"
25
+ runner = Rails::TestUnit::Runner
26
+ runner.respond_to?(:run_from_rake) ? runner.run_from_rake("test", paths) : runner.rake_run(paths)
27
+ end
28
+ end