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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +436 -28
- data/README.md +6 -0
- data/lib/active_agent/base.rb +1 -0
- data/lib/active_agent/cli/eval_commands.rb +3 -3
- data/lib/active_agent/concerns/tooling.rb +9 -0
- data/lib/active_agent/evals/capture.rb +275 -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/providers/_base_provider.rb +17 -1
- 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/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/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/railtie.rb +4 -0
- data/lib/active_agent/version.rb +1 -1
- data/lib/tasks/active_agent/evals.rake +28 -0
- metadata +16 -3
- 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
|
|
@@ -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.
|
data/lib/active_agent/railtie.rb
CHANGED
|
@@ -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
|
data/lib/active_agent/version.rb
CHANGED
|
@@ -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
|