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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +612 -28
- data/README.md +9 -0
- data/lib/active_agent/base.rb +1 -0
- data/lib/active_agent/cli/eval_commands.rb +50 -7
- data/lib/active_agent/concerns/tooling.rb +11 -0
- data/lib/active_agent/evals/capture.rb +286 -0
- data/lib/active_agent/evals/catalog.rb +150 -19
- data/lib/active_agent/evals/gate_check.rb +79 -0
- data/lib/active_agent/evals/pass_thresholds.rb +137 -0
- data/lib/active_agent/evals/patterns.rb +80 -0
- data/lib/active_agent/evals/publisher.rb +124 -8
- data/lib/active_agent/evals/report.rb +165 -20
- data/lib/active_agent/evals/report_html.rb +91 -17
- data/lib/active_agent/evals/report_markdown.rb +63 -6
- data/lib/active_agent/evals/result.rb +10 -2
- data/lib/active_agent/evals/rspec.rb +87 -0
- data/lib/active_agent/evals/ruby_llm.rb +105 -3
- data/lib/active_agent/evals/runner.rb +59 -16
- data/lib/active_agent/evals/scenario.rb +12 -4
- data/lib/active_agent/evals/scenario_parser.rb +9 -1
- data/lib/active_agent/evals/scorer.rb +53 -23
- data/lib/active_agent/evals/suite.rb +117 -6
- data/lib/active_agent/evals/test_helper.rb +286 -0
- data/lib/active_agent/evals.rb +9 -1
- data/lib/active_agent/model_capabilities.rb +1 -1
- data/lib/active_agent/providers/_base_provider.rb +18 -2
- data/lib/active_agent/providers/anthropic_provider.rb +10 -0
- data/lib/active_agent/providers/atlas_cloud/options.rb +35 -0
- data/lib/active_agent/providers/atlas_cloud_provider.rb +45 -0
- data/lib/active_agent/providers/bedrock/bearer_client.rb +2 -1
- data/lib/active_agent/providers/cheaper_inference/_types.rb +17 -0
- data/lib/active_agent/providers/cheaper_inference/options.rb +39 -0
- data/lib/active_agent/providers/cheaper_inference_provider.rb +72 -0
- data/lib/active_agent/providers/common/usage.rb +6 -1
- data/lib/active_agent/providers/ollama/_types.rb +1 -0
- data/lib/active_agent/providers/ollama/catalog.rb +221 -0
- data/lib/active_agent/providers/ollama/cloud.rb +109 -0
- data/lib/active_agent/providers/ollama/options.rb +66 -2
- data/lib/active_agent/providers/ollama_provider.rb +55 -3
- data/lib/active_agent/providers/open_ai/responses_provider.rb +7 -0
- data/lib/active_agent/providers/requesty_provider.rb +8 -0
- data/lib/active_agent/providers/ruby_llm/tool_proxy.rb +15 -0
- data/lib/active_agent/providers/ruby_llm_provider.rb +41 -5
- data/lib/active_agent/providers/runcrate/_types.rb +17 -0
- data/lib/active_agent/providers/runcrate/options.rb +39 -0
- data/lib/active_agent/providers/runcrate_provider.rb +72 -0
- data/lib/active_agent/railtie.rb +4 -0
- data/lib/active_agent/version.rb +1 -1
- data/lib/tasks/active_agent/evals.rake +28 -0
- metadata +27 -5
- data/lib/tasks/activeagent_tasks.rake +0 -4
|
@@ -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:
|
|
10
|
-
attribute :api_key, :string, fallback:
|
|
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
|
|
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
|
|
13
|
-
#
|
|
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
|
|
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
|
-
|
|
163
|
-
|
|
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:
|
|
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
|