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
@@ -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.
@@ -110,6 +110,7 @@ module ActiveAgent
110
110
  # reasoning progress, kept from the reasoning item once it is done
111
111
  # - `:"response.content_part.done"` - content part completed
112
112
  # - `:"response.output_item.done"` - message, reasoning or function call completed
113
+ # - `:error`, `:"response.failed"` - provider failure
113
114
  # - `:"response.completed"` - response finished
114
115
  # - `:"response.incomplete"` - response cut short (e.g. max_output_tokens)
115
116
  #
@@ -174,6 +175,12 @@ module ActiveAgent
174
175
  when :"response.output_item.done"
175
176
  process_stream_output_item_done(api_response_event)
176
177
 
178
+ # Errors arrive as events even though the HTTP request succeeded.
179
+ when :error, :"response.failed"
180
+ error = api_response_event.type == :error ? api_response_event : api_response_event.response.error
181
+ message = [ error&.code, error&.message ].compact.join(": ")
182
+ raise Errors::ProviderError.new(message.presence || "OpenAI response failed", provider_tag: tag_name)
183
+
177
184
  # Response Completed, or cut short (e.g. by max_output_tokens) with
178
185
  # the output it has so far
179
186
  when :"response.completed", :"response.incomplete"
@@ -50,6 +50,14 @@ module ActiveAgent
50
50
 
51
51
  protected
52
52
 
53
+ # Executes an embedding request via Requesty's OpenAI-compatible API.
54
+ #
55
+ # @param parameters [Hash]
56
+ # @return [Hash] symbolized API response
57
+ def api_embed_execute(parameters)
58
+ client.embeddings.create(**parameters).as_json.deep_symbolize_keys
59
+ end
60
+
53
61
  # @see BaseProvider#api_response_normalize
54
62
  # @param api_response [OpenAI::Models::ChatCompletion]
55
63
  # @return [Hash] normalized response hash
@@ -32,6 +32,21 @@ module ActiveAgent
32
32
  alias_method :declared_parameters, :parameters
33
33
  alias_method :provider_options, :provider_params
34
34
 
35
+ # RubyLLM 2.2 registers a tool under its qualified name (namespace
36
+ # plus name) and asks each tool whether it defers. ActiveAgent's
37
+ # tools have no namespace and run when called.
38
+ def namespace
39
+ nil
40
+ end
41
+
42
+ def qualified_name
43
+ name
44
+ end
45
+
46
+ def deferred?
47
+ false
48
+ end
49
+
35
50
  private
36
51
 
37
52
  def deep_stringify(obj)
@@ -152,15 +152,16 @@ module ActiveAgent
152
152
  # Handle tool calls in chunk
153
153
  if chunk.tool_calls&.any?
154
154
  message[:tool_calls] ||= []
155
- chunk.tool_calls.each do |key, tool_call|
155
+ each_tool_call(chunk.tool_calls) do |key, tool_call|
156
156
  # Only the first delta of a call has its ID and name; the rest
157
157
  # are fragments of its arguments. A fragment belongs to the call
158
158
  # that shares its stream key (the index RubyLLM 2 numbers calls
159
159
  # by), or, without one as RubyLLM 1.16 leaves it for OpenAI, to
160
160
  # the latest call. RubyLLM 1.16 cannot preserve interleaved
161
161
  # OpenAI calls: its parser drops their indices and fragments.
162
- existing = if tool_call.id
163
- message[:tool_calls].find { |tc| tc[:id] == tool_call.id }
162
+ call_id = streamed_tool_call_id(tool_call)
163
+ existing = if call_id
164
+ message[:tool_calls].find { |tc| tc[:id] == call_id }
164
165
  else
165
166
  @stream_tool_calls[key] || message[:tool_calls].last
166
167
  end
@@ -169,7 +170,7 @@ module ActiveAgent
169
170
  existing[:function][:arguments] += fragment
170
171
  else
171
172
  existing = {
172
- id: tool_call.id,
173
+ id: call_id,
173
174
  type: "function",
174
175
  function: {
175
176
  name: tool_call.name,
@@ -492,7 +493,7 @@ module ActiveAgent
492
493
 
493
494
  # Handle tool calls
494
495
  if response.tool_calls&.any?
495
- hash[:tool_calls] = response.tool_calls.map do |id, tc|
496
+ hash[:tool_calls] = each_tool_call(response.tool_calls).map do |id, tc|
496
497
  {
497
498
  id: id,
498
499
  type: "function",
@@ -523,6 +524,41 @@ module ActiveAgent
523
524
  hash
524
525
  end
525
526
 
527
+ # Yields each of a message's tool calls with its key. RubyLLM 1.x and
528
+ # 2.0 to 2.1 keep them in a Hash keyed by call ID (or, on a streamed
529
+ # chunk, by stream index), so iterating yields pairs. RubyLLM 2.2's
530
+ # `ToolCall::Collection` yields the calls themselves and keeps the
531
+ # keys behind `each_key`, in the same order.
532
+ #
533
+ # @param calls [Hash, #each_key, nil]
534
+ # @return [Enumerator, Array] key and call pairs
535
+ def each_tool_call(calls, &block)
536
+ pairs = if calls.nil?
537
+ []
538
+ elsif calls.is_a?(Hash)
539
+ calls.to_a
540
+ elsif calls.respond_to?(:each_key)
541
+ calls.each_key.to_a.zip(calls.each.to_a)
542
+ else
543
+ calls.each.to_a.map { |call| [ call.id, call ] }
544
+ end
545
+ block ? pairs.each(&block) : pairs
546
+ end
547
+
548
+ # The provider's ID for a streamed tool call, present on its first
549
+ # delta and nil on the fragments. RubyLLM 2.2 keeps it in
550
+ # `provider_call_id` and leaves `id` (its own identifier, assigned when
551
+ # the message completes) nil on a chunk; earlier releases put the
552
+ # provider's ID in `id`.
553
+ #
554
+ # @param tool_call [RubyLLM::ToolCall]
555
+ # @return [String, nil]
556
+ def streamed_tool_call_id(tool_call)
557
+ provider_id = tool_call.provider_call_id if tool_call.respond_to?(:provider_call_id)
558
+ provider_id = nil if provider_id.to_s.empty?
559
+ provider_id || tool_call.id
560
+ end
561
+
526
562
  # A streamed tool call's arguments as the text they add to the JSON
527
563
  # string the call builds up. Most APIs stream that string; Anthropic
528
564
  # opens a call with an empty Hash for its input, and Gemini sends the
@@ -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 Runcrate
9
+ # ActiveModel type for casting and serializing Runcrate requests.
10
+ #
11
+ # Runcrate is OpenAI-compatible, so requests use the same shape as the
12
+ # OpenAI Chat API. This delegates entirely to
13
+ # OpenAI::Chat::RequestType.
14
+ RequestType = ActiveAgent::Providers::OpenAI::Chat::RequestType
15
+ end
16
+ end
17
+ end