actionagent 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/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +61 -60
- data/app/controllers/action_agent/api/agents_controller.rb +15 -6
- data/app/controllers/action_agent/api/base_controller.rb +3 -2
- data/app/controllers/action_agent/api/mcp_server_configs_controller.rb +114 -0
- data/app/controllers/action_agent/api/mcp_servers_controller.rb +7 -4
- data/app/controllers/action_agent/api/metrics_controller.rb +5 -4
- data/app/controllers/action_agent/api/provider_keys_controller.rb +1 -1
- data/app/controllers/action_agent/api/provider_models_controller.rb +17 -5
- data/app/controllers/action_agent/traces_controller.rb +3 -2
- data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +4 -2
- data/app/jobs/action_agent/agent_execution_job.rb +26 -6
- data/app/jobs/action_agent/evaluation_judge_job.rb +3 -2
- data/app/models/action_agent/catalog_scenario.rb +8 -2
- data/app/models/action_agent/evaluation_run.rb +11 -3
- data/app/models/action_agent/evaluation_scenario_result.rb +12 -3
- data/app/models/action_agent/mcp_server_config.rb +273 -0
- data/app/models/action_agent/model_pricing.rb +135 -24
- data/app/models/action_agent/telemetry_trace.rb +35 -14
- data/app/queries/action_agent/agent_executions.rb +4 -1
- data/app/queries/action_agent/metrics_report.rb +1 -1
- data/app/serializers/action_agent/telemetry_trace_serializer.rb +1 -0
- data/app/services/action_agent/agent_execution_service.rb +4 -2
- data/app/services/action_agent/agent_scorecard.rb +16 -12
- data/app/services/action_agent/agent_tool_roster.rb +5 -2
- data/app/services/action_agent/evaluation_fix_comparison.rb +7 -3
- data/app/services/action_agent/evaluation_report_import.rb +14 -2
- data/app/services/action_agent/evaluation_run_cost.rb +6 -5
- data/app/services/action_agent/evaluation_tool_resolver.rb +35 -7
- data/app/services/action_agent/mcp_client.rb +36 -4
- data/app/services/action_agent/mcp_host_guard.rb +129 -0
- data/app/services/action_agent/mcp_tool_dispatcher.rb +15 -5
- data/app/services/action_agent/ollama_host_probe.rb +36 -4
- data/app/services/action_agent/scenario_catalog_import.rb +2 -0
- data/app/services/action_agent/scenario_evaluation_runner.rb +69 -13
- data/app/services/action_agent/tool_discovery.rb +5 -1
- data/config/routes.rb +6 -0
- data/lib/action_agent/engine.rb +10 -0
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +17 -0
- data/lib/generators/action_agent/templates/action_agent.rb.erb +11 -0
- data/lib/generators/action_agent/templates/migrations/030_add_evaluation_scoring_leases.rb.erb +8 -0
- data/lib/generators/action_agent/templates/migrations/031_add_catalog_scenario_metadata.rb.erb +24 -0
- data/lib/generators/action_agent/templates/migrations/032_create_active_agent_mcp_server_configs.rb.erb +53 -0
- metadata +8 -2
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# A remote MCP server a dashboard user added on the MCP Services page, so
|
|
5
|
+
# their agents can call a server neither the built-in catalog nor the host
|
|
6
|
+
# app's ActionAgent.mcp_catalog lists: their own app's MCP endpoint, say.
|
|
7
|
+
#
|
|
8
|
+
# An agent enables one under its key, "custom:<id>", the way it enables a
|
|
9
|
+
# catalog server under its slug. MCPToolDispatcher resolves the key among
|
|
10
|
+
# the agent's owner's configurations only (.server_entry), so naming
|
|
11
|
+
# another owner's id reaches nothing, and an agent run or a scenario
|
|
12
|
+
# evaluation run calls its tools like a catalog server's.
|
|
13
|
+
#
|
|
14
|
+
# The server is reached over Streamable HTTP at +url+. Request headers
|
|
15
|
+
# (an Authorization header, an API key header) are kept in +secret_headers+,
|
|
16
|
+
# a JSON object encrypted at rest with the provider keys. Their values are
|
|
17
|
+
# never rendered back to the browser: #header_hints names each header with
|
|
18
|
+
# a masked value, and #assign_headers keeps a header's stored value when
|
|
19
|
+
# an update leaves it blank.
|
|
20
|
+
#
|
|
21
|
+
# The URL may not reach a non-public address unless the host app sets
|
|
22
|
+
# ActionAgent.allow_private_mcp_hosts (see MCPHostGuard). A disabled
|
|
23
|
+
# configuration stays listed but is not dispatched to.
|
|
24
|
+
class MCPServerConfig < ApplicationRecord
|
|
25
|
+
include Ownable
|
|
26
|
+
owned_by :account, :user
|
|
27
|
+
|
|
28
|
+
# The key an agent's mcp_servers names a configuration by.
|
|
29
|
+
KEY_PREFIX = "custom:"
|
|
30
|
+
KEY_PATTERN = /\Acustom:(\d+)\z/
|
|
31
|
+
|
|
32
|
+
MAX_HEADERS = 20
|
|
33
|
+
MAX_HEADER_VALUE_LENGTH = 4096
|
|
34
|
+
# An HTTP header field name (RFC 9110 token).
|
|
35
|
+
HEADER_NAME = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/
|
|
36
|
+
# Headers MCPClient sets itself, which a configuration cannot replace
|
|
37
|
+
# without breaking the protocol it speaks.
|
|
38
|
+
RESERVED_HEADERS = %w[accept content-type content-length host mcp-session-id user-agent connection transfer-encoding].freeze
|
|
39
|
+
# How many tool names a test records.
|
|
40
|
+
MAX_TOOL_NAMES = 500
|
|
41
|
+
|
|
42
|
+
TEST_OK = "ok"
|
|
43
|
+
TEST_FAILED = "failed"
|
|
44
|
+
|
|
45
|
+
encrypts :secret_headers if ActionAgent.encrypt_credentials
|
|
46
|
+
|
|
47
|
+
validates :name, presence: true, length: { maximum: 100 }
|
|
48
|
+
validates :url, presence: true, length: { maximum: 2048 }
|
|
49
|
+
validate :url_is_reachable_address
|
|
50
|
+
validate :headers_are_valid
|
|
51
|
+
|
|
52
|
+
scope :enabled, -> { where(enabled: true) }
|
|
53
|
+
|
|
54
|
+
class << self
|
|
55
|
+
# Whether +key+ names a configuration ("custom:<id>").
|
|
56
|
+
def key?(key)
|
|
57
|
+
KEY_PATTERN.match?(key.to_s.strip)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The configuration +key+ names, looked up among +owner+'s only, or nil.
|
|
61
|
+
#
|
|
62
|
+
# @param key [String]
|
|
63
|
+
# @param owner [Object, nil] the agent's owner
|
|
64
|
+
# @return [MCPServerConfig, nil]
|
|
65
|
+
def find_by_key(key, owner:)
|
|
66
|
+
id = key.to_s.strip[KEY_PATTERN, 1] or return nil
|
|
67
|
+
|
|
68
|
+
for_owner(owner).find_by(id: id)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The connection MCPToolDispatcher calls, headers included, for the
|
|
72
|
+
# configuration +key+ names among +owner+'s. Nil when there is none or
|
|
73
|
+
# it is disabled.
|
|
74
|
+
#
|
|
75
|
+
# @return [Hash, nil]
|
|
76
|
+
def server_entry(key, owner:)
|
|
77
|
+
find_by_key(key, owner: owner)&.server_entry
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The configurations in +scope+ (a relation already scoped to an owner)
|
|
81
|
+
# as MCP server listings: the catalog entry shape MCPCatalog serves,
|
|
82
|
+
# without headers. ToolDiscovery and AgentToolRoster list them beside
|
|
83
|
+
# the catalog, as they list checkout runtimes.
|
|
84
|
+
#
|
|
85
|
+
# @return [Array<Hash>]
|
|
86
|
+
def listings(scope)
|
|
87
|
+
scope.order(:name, :id).map(&:listing)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def server_key
|
|
92
|
+
"#{KEY_PREFIX}#{id}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The request headers, name => value. Empty when none are set or the
|
|
96
|
+
# stored value is not a JSON object.
|
|
97
|
+
#
|
|
98
|
+
# @return [Hash{String => String}]
|
|
99
|
+
def headers
|
|
100
|
+
parsed = JSON.parse(secret_headers.presence || "{}")
|
|
101
|
+
parsed.is_a?(Hash) ? parsed.transform_values(&:to_s) : {}
|
|
102
|
+
rescue JSON::ParserError
|
|
103
|
+
{}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def headers=(value)
|
|
107
|
+
self.secret_headers = value.to_h.transform_keys(&:to_s).transform_values(&:to_s).to_json
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Replaces the headers with +submitted+, a list of { name, value } pairs.
|
|
111
|
+
# A header submitted with a blank value keeps the value stored under the
|
|
112
|
+
# same name (compared without case); a header left out is removed. A new
|
|
113
|
+
# header with a blank value is kept blank, so validation refuses it.
|
|
114
|
+
#
|
|
115
|
+
# @param submitted [Array<Hash>]
|
|
116
|
+
def assign_headers(submitted)
|
|
117
|
+
stored = headers.transform_keys(&:downcase)
|
|
118
|
+
|
|
119
|
+
self.headers = Array(submitted).each_with_object({}) do |header, kept|
|
|
120
|
+
header = header.to_h.stringify_keys
|
|
121
|
+
name = header["name"].to_s.strip
|
|
122
|
+
next if name.blank?
|
|
123
|
+
|
|
124
|
+
value = header["value"].to_s
|
|
125
|
+
kept[name] = value.strip.empty? ? stored[name.downcase].to_s : value
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Each header's name with a masked value, for the browser.
|
|
130
|
+
#
|
|
131
|
+
# @return [Array<Hash>] +{ name:, hint: }+
|
|
132
|
+
def header_hints
|
|
133
|
+
headers.map { |name, value| { name: name, hint: mask(value) } }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Every header value, for scrubbing from recorded output.
|
|
137
|
+
def secret_values
|
|
138
|
+
headers.values.reject(&:blank?)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# The connection MCPToolDispatcher calls, headers included, or nil when
|
|
142
|
+
# the configuration is disabled. Never serialized to the browser.
|
|
143
|
+
#
|
|
144
|
+
# @return [Hash, nil]
|
|
145
|
+
def server_entry
|
|
146
|
+
return nil unless enabled?
|
|
147
|
+
|
|
148
|
+
listing.merge(url: url, headers: headers, public_only: !MCPHostGuard.allow_private?)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# This configuration as a header-free MCP server listing.
|
|
152
|
+
#
|
|
153
|
+
# @return [Hash]
|
|
154
|
+
def listing
|
|
155
|
+
{
|
|
156
|
+
key: server_key,
|
|
157
|
+
name: name,
|
|
158
|
+
description: description.presence || "Your MCP server at #{display_url}",
|
|
159
|
+
transport: "streamable_http",
|
|
160
|
+
url: display_url,
|
|
161
|
+
command: nil, package: nil, categories: [ "custom" ], docs_url: nil,
|
|
162
|
+
sandbox: false, sandbox_type: nil, first_party: false,
|
|
163
|
+
requires_credentials: [], tools: tool_names_list,
|
|
164
|
+
custom: true, enabled: enabled?
|
|
165
|
+
}
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# The tool names the last successful test discovered.
|
|
169
|
+
#
|
|
170
|
+
# @return [Array<String>]
|
|
171
|
+
def tool_names_list
|
|
172
|
+
Array(tool_names).map(&:to_s)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Calls tools/list on the server and records the outcome: the discovered
|
|
176
|
+
# tool names on success, the error on failure. Uses the configuration as
|
|
177
|
+
# it stands, saved or not, and saves it only when it is persisted.
|
|
178
|
+
#
|
|
179
|
+
# @param client [MCPClient, nil] the client to call, for tests
|
|
180
|
+
# @return [Hash] +{ ok:, tools:, error:, latency_ms: }+
|
|
181
|
+
def test_connection!(client: nil)
|
|
182
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
183
|
+
client ||= MCPClient.new(url: url, label: name, headers: headers, public_only: !MCPHostGuard.allow_private?)
|
|
184
|
+
names = client.list_tools.filter_map { |tool| (tool[:name] || tool["name"]).to_s.presence }.uniq.first(MAX_TOOL_NAMES)
|
|
185
|
+
record_test(TEST_OK, tools: names)
|
|
186
|
+
{ ok: true, tools: names, error: nil, latency_ms: elapsed_ms(started) }
|
|
187
|
+
rescue MCPClient::Error, URI::Error => e
|
|
188
|
+
record_test(TEST_FAILED, error: e.message)
|
|
189
|
+
{ ok: false, tools: tool_names_list, error: e.message, latency_ms: elapsed_ms(started) }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# The configuration for the browser: never a header value.
|
|
193
|
+
#
|
|
194
|
+
# @return [Hash]
|
|
195
|
+
def as_api_json
|
|
196
|
+
{
|
|
197
|
+
id: id,
|
|
198
|
+
key: server_key,
|
|
199
|
+
name: name,
|
|
200
|
+
description: description,
|
|
201
|
+
url: url,
|
|
202
|
+
enabled: enabled?,
|
|
203
|
+
headers: header_hints,
|
|
204
|
+
tools: tool_names_list,
|
|
205
|
+
last_tested_at: last_tested_at&.iso8601,
|
|
206
|
+
last_test_status: last_test_status,
|
|
207
|
+
last_test_error: last_test_error,
|
|
208
|
+
updated_at: updated_at&.iso8601
|
|
209
|
+
}
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
private
|
|
213
|
+
|
|
214
|
+
# The URL without its query string, which can carry a token.
|
|
215
|
+
def display_url
|
|
216
|
+
uri = URI.parse(url.to_s)
|
|
217
|
+
uri.query = nil
|
|
218
|
+
uri.fragment = nil
|
|
219
|
+
uri.to_s
|
|
220
|
+
rescue URI::InvalidURIError
|
|
221
|
+
url.to_s
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def record_test(status, tools: nil, error: nil)
|
|
225
|
+
self.last_tested_at = Time.current
|
|
226
|
+
self.last_test_status = status
|
|
227
|
+
self.last_test_error = error.to_s.first(1000).presence
|
|
228
|
+
self.tool_names = tools unless tools.nil?
|
|
229
|
+
save!(validate: false) if persisted?
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def elapsed_ms(started)
|
|
233
|
+
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# "••••a1b2" for a long value, "••••" for a short one, whose last
|
|
237
|
+
# characters would give away too much of it.
|
|
238
|
+
def mask(value)
|
|
239
|
+
value.to_s.length >= 16 ? "••••#{value.to_s.last(4)}" : "••••"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def url_is_reachable_address
|
|
243
|
+
return if url.blank?
|
|
244
|
+
|
|
245
|
+
reason = MCPHostGuard.url_refusal(url)
|
|
246
|
+
errors.add(:url, reason) if reason
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def headers_are_valid
|
|
250
|
+
current = headers
|
|
251
|
+
errors.add(:headers, "can hold at most #{MAX_HEADERS} entries") if current.size > MAX_HEADERS
|
|
252
|
+
|
|
253
|
+
seen = Set.new
|
|
254
|
+
current.each do |name, value|
|
|
255
|
+
if !HEADER_NAME.match?(name)
|
|
256
|
+
errors.add(:headers, "#{name.inspect} is not a valid header name")
|
|
257
|
+
elsif RESERVED_HEADERS.include?(name.downcase)
|
|
258
|
+
errors.add(:headers, "#{name} is set by the MCP client and cannot be replaced")
|
|
259
|
+
elsif !seen.add?(name.downcase)
|
|
260
|
+
errors.add(:headers, "#{name} is listed twice")
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
if value.strip.empty?
|
|
264
|
+
errors.add(:headers, "#{name} needs a value")
|
|
265
|
+
elsif value.length > MAX_HEADER_VALUE_LENGTH
|
|
266
|
+
errors.add(:headers, "#{name} is longer than #{MAX_HEADER_VALUE_LENGTH} characters")
|
|
267
|
+
elsif value.match?(/[\r\n\0]/)
|
|
268
|
+
errors.add(:headers, "#{name} must not contain line breaks")
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
@@ -10,10 +10,15 @@ module ActionAgent
|
|
|
10
10
|
# Rates come from RubyLLM's model registry (USD per million tokens,
|
|
11
11
|
# maintained upstream per model) when the model is known there — looked
|
|
12
12
|
# up under the provider it ran on when the caller knows it, since
|
|
13
|
-
# `gpt-5.5` through OpenRouter and `gpt-5.5` at OpenAI are two entries
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
13
|
+
# `gpt-5.5` through OpenRouter and `gpt-5.5` at OpenAI are two entries,
|
|
14
|
+
# and `deepseek-v4-flash` through Ollama Cloud is priced by Ollama, not
|
|
15
|
+
# by DeepSeek. A model run under Ollama is looked up in the registry's
|
|
16
|
+
# Ollama Cloud listing by the name the cloud uses, whichever way it was
|
|
17
|
+
# spelled (`gpt-oss:120b-cloud` through a local server is the cloud's
|
|
18
|
+
# `gpt-oss:120b`). The static tables below are the fallback for aliases,
|
|
19
|
+
# self-hosted models and an install without RubyLLM: exact rows
|
|
20
|
+
# (STATIC_RATES) for the current frontier models and Ollama Cloud's own
|
|
21
|
+
# price list (OLLAMA_CLOUD_RATES), then name patterns (PRICES), then a
|
|
17
22
|
# conservative blended rate so totals stay meaningful. Costs are always
|
|
18
23
|
# presented as estimates, and every rate says where it came from
|
|
19
24
|
# (`source`), so a figure worked out at a fallback rate can say so.
|
|
@@ -35,6 +40,37 @@ module ActionAgent
|
|
|
35
40
|
"gpt-5-nano" => [ 0.05, 0.40 ]
|
|
36
41
|
}.freeze
|
|
37
42
|
|
|
43
|
+
# Ollama Cloud's price list, by the name the cloud lists each model
|
|
44
|
+
# under: USD per million input and output tokens. The rows RubyLLM's
|
|
45
|
+
# registry carries under its `ollama_cloud` provider (RubyLLM 2.1.1,
|
|
46
|
+
# from models.dev), copied on 2026-10-30 for an install without the
|
|
47
|
+
# gem; DeepSeek's rows are its off-peak rates. A model the cloud lists
|
|
48
|
+
# at no price yet is left out, so it prices as any other Ollama model.
|
|
49
|
+
OLLAMA_CLOUD_RATES = {
|
|
50
|
+
"deepseek-v4-flash" => [ 0.22, 0.66 ],
|
|
51
|
+
"deepseek-v4-flash:0731" => [ 0.22, 0.66 ],
|
|
52
|
+
"deepseek-v4-pro" => [ 0.66, 1.98 ],
|
|
53
|
+
"deepseek-v4-pro:0813" => [ 0.66, 1.98 ],
|
|
54
|
+
"deepseek-v4.1-flash" => [ 0.15, 0.60 ],
|
|
55
|
+
"gemma4:31b" => [ 0.14, 0.40 ],
|
|
56
|
+
"glm-5.1" => [ 1.00, 3.20 ],
|
|
57
|
+
"glm-5.2" => [ 1.40, 4.40 ],
|
|
58
|
+
"glm-5.3" => [ 1.40, 4.40 ],
|
|
59
|
+
"glm-5.3-flash" => [ 0.15, 0.50 ],
|
|
60
|
+
"gpt-oss:120b" => [ 0.15, 0.60 ],
|
|
61
|
+
"gpt-oss:20b" => [ 0.07, 0.30 ],
|
|
62
|
+
"kimi-k2.6" => [ 0.95, 4.00 ],
|
|
63
|
+
"kimi-k2.7-code" => [ 0.95, 4.00 ],
|
|
64
|
+
"kimi-k3" => [ 3.00, 15.00 ],
|
|
65
|
+
"minimax-m2.7" => [ 0.30, 1.20 ],
|
|
66
|
+
"minimax-m3" => [ 0.60, 2.40 ],
|
|
67
|
+
"mistral-large-3:675b" => [ 0.50, 1.50 ],
|
|
68
|
+
"nemotron-3-nano:30b" => [ 0.06, 0.24 ],
|
|
69
|
+
"nemotron-3-super" => [ 0.015, 0.60 ],
|
|
70
|
+
"nemotron-3-ultra" => [ 0.10, 3.00 ],
|
|
71
|
+
"qwen3.5:397b" => [ 0.60, 3.60 ]
|
|
72
|
+
}.freeze
|
|
73
|
+
|
|
38
74
|
PRICES = [
|
|
39
75
|
# [pattern, input $/1M, output $/1M]
|
|
40
76
|
[ /gpt-4o-mini/i, 0.15, 0.60 ],
|
|
@@ -65,10 +101,12 @@ module ActionAgent
|
|
|
65
101
|
# Fallback blended rate for unknown models ($/1M input, $/1M output)
|
|
66
102
|
DEFAULT_RATE = [ 1.00, 4.00 ].freeze
|
|
67
103
|
|
|
68
|
-
# Where a rate came from:
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
104
|
+
# Where a rate came from: a listing of the model — RubyLLM's bundled
|
|
105
|
+
# catalog, or Ollama Cloud's price list bundled here (`catalog`), or a
|
|
106
|
+
# registry the host keeps itself, RubyLLM's model table or a refreshed
|
|
107
|
+
# listing (`remote`) — else a row of STATIC_RATES or PRICES, or
|
|
108
|
+
# DEFAULT_RATE. The last two are a guess at the model's price rather
|
|
109
|
+
# than its listing.
|
|
72
110
|
SOURCES = %w[catalog remote pattern default].freeze
|
|
73
111
|
FALLBACK_SOURCES = %w[pattern default].freeze
|
|
74
112
|
|
|
@@ -83,6 +121,22 @@ module ActionAgent
|
|
|
83
121
|
"qwen" => nil, "x-ai" => "xai", "xai" => "xai", "cohere" => nil, "perplexity" => "perplexity"
|
|
84
122
|
}.freeze
|
|
85
123
|
|
|
124
|
+
# Provider names as the registry spells them, for the ones a trace's
|
|
125
|
+
# `llm.provider` tag ("OpenAI::Chat", "Ollama") or a run's provider key
|
|
126
|
+
# ("open_router") spells otherwise once downcased and cut at "::".
|
|
127
|
+
PROVIDER_SLUGS = {
|
|
128
|
+
"open_router" => "openrouter", "open_ai" => "openai", "ollama-cloud" => "ollama_cloud",
|
|
129
|
+
"azure_openai" => "azure", "azureopenai" => "azure", "azure_open_ai" => "azure"
|
|
130
|
+
}.freeze
|
|
131
|
+
|
|
132
|
+
# The registry's provider for Ollama Cloud's listing, which prices a
|
|
133
|
+
# model run under Ollama whichever route served it.
|
|
134
|
+
OLLAMA_CLOUD_PROVIDER = "ollama_cloud"
|
|
135
|
+
OLLAMA_PROVIDERS = [ "ollama", OLLAMA_CLOUD_PROVIDER ].freeze
|
|
136
|
+
# A local Ollama server's spellings of a cloud model: the framework's
|
|
137
|
+
# ActiveAgent::Providers::Ollama::Cloud, kept in step.
|
|
138
|
+
OLLAMA_CLOUD_MARKERS = %w[-cloud :cloud].freeze
|
|
139
|
+
|
|
86
140
|
class << self
|
|
87
141
|
# @return [Float, nil] estimated USD cost, nil when there is nothing to price
|
|
88
142
|
def estimate(model:, input_tokens:, output_tokens:, provider: nil)
|
|
@@ -119,11 +173,12 @@ module ActionAgent
|
|
|
119
173
|
def rate_detail(model, provider: nil)
|
|
120
174
|
return { input: DEFAULT_RATE[0], output: DEFAULT_RATE[1], source: "default" } if model.blank?
|
|
121
175
|
|
|
122
|
-
|
|
176
|
+
slug = provider_slug(provider)
|
|
177
|
+
key = [ slug.to_s, model.to_s ]
|
|
123
178
|
@rates ||= {}
|
|
124
179
|
return @rates[key] if @rates.key?(key)
|
|
125
180
|
|
|
126
|
-
@rates[key] = registry_rate(model.to_s,
|
|
181
|
+
@rates[key] = registry_rate(model.to_s, slug) || static_rate(model.to_s, slug)
|
|
127
182
|
end
|
|
128
183
|
|
|
129
184
|
# Forgets memoized rates — after RubyLLM is loaded, or in tests.
|
|
@@ -136,27 +191,65 @@ module ActionAgent
|
|
|
136
191
|
# and the version of the registry the install has (or none).
|
|
137
192
|
def fingerprint
|
|
138
193
|
registry = defined?(::RubyLLM) && ::RubyLLM.const_defined?(:VERSION) ? ::RubyLLM::VERSION.to_s : "none"
|
|
139
|
-
tables = [
|
|
194
|
+
tables = [
|
|
195
|
+
STATIC_RATES, OLLAMA_CLOUD_RATES, PRICES.map { |pattern, input, output| [ pattern.source, input, output ] },
|
|
196
|
+
DEFAULT_RATE, registry
|
|
197
|
+
]
|
|
140
198
|
Digest::SHA256.hexdigest(JSON.generate(tables))[0, 12]
|
|
141
199
|
end
|
|
142
200
|
|
|
143
|
-
# "openrouter/anthropic/claude-sonnet-4-5-20250929" → "claude-sonnet-4-5"
|
|
144
|
-
# the name without a gateway or
|
|
145
|
-
#
|
|
146
|
-
#
|
|
201
|
+
# "openrouter/anthropic/claude-sonnet-4-5-20250929" → "claude-sonnet-4-5",
|
|
202
|
+
# "gpt-oss:120b-cloud" → "gpt-oss:120b": the name without a gateway or
|
|
203
|
+
# vendor prefix, a date suffix, a colon-tagged size or a local Ollama
|
|
204
|
+
# server's cloud marker, with dots as dashes, so the same model under
|
|
205
|
+
# two spellings keys one row.
|
|
147
206
|
def normalize(model)
|
|
148
|
-
name = model.to_s.strip.downcase
|
|
207
|
+
name = ollama_canonical(model.to_s.strip.downcase)
|
|
149
208
|
name = name.split("/").last.to_s
|
|
150
209
|
name = name.sub(/-\d{8}\z/, "").sub(/-\d{4}-\d{2}-\d{2}\z/, "")
|
|
151
210
|
name.tr(".", "-")
|
|
152
211
|
end
|
|
153
212
|
|
|
213
|
+
# The provider as the registry names it, from how a trace or a run
|
|
214
|
+
# recorded it: downcased, cut at "::" ("OpenAI::Chat" → "openai"),
|
|
215
|
+
# and the few other spellings mapped (PROVIDER_SLUGS). nil for none.
|
|
216
|
+
#
|
|
217
|
+
# @return [String, nil]
|
|
218
|
+
def provider_slug(provider)
|
|
219
|
+
slug = provider.to_s.strip.downcase.split("::").first.to_s
|
|
220
|
+
return nil if slug.empty?
|
|
221
|
+
|
|
222
|
+
PROVIDER_SLUGS.fetch(slug, slug)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Whether +model+ is an Ollama model: run under Ollama (either
|
|
226
|
+
# provider name), or spelled with a local server's cloud marker.
|
|
227
|
+
def ollama?(model, provider = nil)
|
|
228
|
+
OLLAMA_PROVIDERS.include?(provider_slug(provider)) || ollama_cloud_model?(model)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Whether +model+ carries a local Ollama server's cloud marker.
|
|
232
|
+
def ollama_cloud_model?(model)
|
|
233
|
+
name = model.to_s.strip
|
|
234
|
+
OLLAMA_CLOUD_MARKERS.any? { |marker| name.end_with?(marker) }
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# The name Ollama Cloud lists a model under: "gpt-oss:120b-cloud" →
|
|
238
|
+
# "gpt-oss:120b", "glm-4.6:cloud" → "glm-4.6"; any other name as given.
|
|
239
|
+
def ollama_canonical(model)
|
|
240
|
+
name = model.to_s.strip
|
|
241
|
+
OLLAMA_CLOUD_MARKERS.each { |marker| return name.delete_suffix(marker) if name.end_with?(marker) }
|
|
242
|
+
name
|
|
243
|
+
end
|
|
244
|
+
|
|
154
245
|
private
|
|
155
246
|
|
|
156
|
-
# The registry's rate for the first spelling it knows:
|
|
157
|
-
#
|
|
158
|
-
#
|
|
159
|
-
# with
|
|
247
|
+
# The registry's rate for the first spelling it knows: an Ollama
|
|
248
|
+
# model under the cloud's listing by the name the cloud uses, then
|
|
249
|
+
# the name as given under the provider it ran on, then bare, then
|
|
250
|
+
# with a gateway or vendor prefix stripped (under the vendor's own
|
|
251
|
+
# provider), each with its dots and dashes swapped and its date
|
|
252
|
+
# suffix dropped.
|
|
160
253
|
def registry_rate(model, provider)
|
|
161
254
|
return nil unless registry_available?
|
|
162
255
|
|
|
@@ -207,9 +300,15 @@ module ActionAgent
|
|
|
207
300
|
# [[id, provider], ...] to try in order, without repeats.
|
|
208
301
|
def lookup_candidates(model, provider)
|
|
209
302
|
name = model.strip
|
|
210
|
-
provider = provider&.downcase
|
|
211
303
|
candidates = []
|
|
212
304
|
|
|
305
|
+
if ollama?(name, provider)
|
|
306
|
+
ollama_names(name).each do |candidate|
|
|
307
|
+
candidates << [ candidate, OLLAMA_CLOUD_PROVIDER ]
|
|
308
|
+
candidates << [ candidate, "ollama" ]
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
213
312
|
head, rest = name.split("/", 2)
|
|
214
313
|
if rest.present? && GATEWAY_PREFIXES.include?(head.downcase)
|
|
215
314
|
provider ||= head.downcase
|
|
@@ -243,9 +342,21 @@ module ActionAgent
|
|
|
243
342
|
end.uniq
|
|
244
343
|
end
|
|
245
344
|
|
|
246
|
-
#
|
|
247
|
-
# name
|
|
248
|
-
def
|
|
345
|
+
# The names Ollama Cloud's listing may hold an Ollama model under: the
|
|
346
|
+
# name the cloud uses, then without a `:latest` tag.
|
|
347
|
+
def ollama_names(model)
|
|
348
|
+
canonical = ollama_canonical(model)
|
|
349
|
+
[ canonical, canonical.delete_suffix(":latest") ].uniq
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Ollama Cloud's own row for an Ollama model, else an exact row for
|
|
353
|
+
# the normalised name, else the first pattern the name as given
|
|
354
|
+
# matches, else the default.
|
|
355
|
+
def static_rate(model, provider = nil)
|
|
356
|
+
if ollama?(model, provider) && (row = ollama_names(model).filter_map { |name| OLLAMA_CLOUD_RATES[name] }.first)
|
|
357
|
+
return { input: row[0], output: row[1], source: "catalog" }
|
|
358
|
+
end
|
|
359
|
+
|
|
249
360
|
if (exact = STATIC_RATES[normalize(model)])
|
|
250
361
|
return { input: exact[0], output: exact[1], source: "pattern" }
|
|
251
362
|
end
|
|
@@ -85,26 +85,47 @@ module ActionAgent
|
|
|
85
85
|
# in Ruby, which costs more but keeps the dashboard adapter-agnostic.
|
|
86
86
|
def self.pluck_with_llm_model(scope, *columns)
|
|
87
87
|
if postgres?
|
|
88
|
-
scope.pluck(
|
|
89
|
-
Arel.sql(
|
|
90
|
-
# spans is cast rather than assumed to be jsonb: the column is
|
|
91
|
-
# json on every install created before the migration template
|
|
92
|
-
# started picking jsonb per adapter, and jsonb_array_elements
|
|
93
|
-
# rejects a json argument outright.
|
|
94
|
-
"(SELECT s.value -> 'attributes' ->> 'llm.model' " \
|
|
95
|
-
"FROM jsonb_array_elements(spans::jsonb) AS s " \
|
|
96
|
-
"WHERE s.value ->> 'type' = 'llm' LIMIT 1)"
|
|
97
|
-
),
|
|
98
|
-
*columns
|
|
99
|
-
)
|
|
88
|
+
scope.pluck(llm_span_attribute_sql("llm.model"), *columns)
|
|
100
89
|
else
|
|
101
90
|
scope.pluck(:spans, *columns).map do |spans, *rest|
|
|
102
|
-
|
|
103
|
-
[ llm&.dig("attributes", "llm.model"), *rest ]
|
|
91
|
+
[ first_llm_span(spans)&.dig("attributes", "llm.model"), *rest ]
|
|
104
92
|
end
|
|
105
93
|
end
|
|
106
94
|
end
|
|
107
95
|
|
|
96
|
+
# Plucks [llm_model, llm_provider, *columns] per trace: as
|
|
97
|
+
# pluck_with_llm_model, with the provider the first llm span recorded
|
|
98
|
+
# beside its model, for a caller that prices the tokens (ModelPricing
|
|
99
|
+
# looks a model up under the provider that served it).
|
|
100
|
+
def self.pluck_with_llm_model_and_provider(scope, *columns)
|
|
101
|
+
if postgres?
|
|
102
|
+
scope.pluck(llm_span_attribute_sql("llm.model"), llm_span_attribute_sql("llm.provider"), *columns)
|
|
103
|
+
else
|
|
104
|
+
scope.pluck(:spans, *columns).map do |spans, *rest|
|
|
105
|
+
llm = first_llm_span(spans)
|
|
106
|
+
[ llm&.dig("attributes", "llm.model"), llm&.dig("attributes", "llm.provider"), *rest ]
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The first llm span's +attribute+, read in SQL. spans is cast rather
|
|
112
|
+
# than assumed to be jsonb: the column is json on every install created
|
|
113
|
+
# before the migration template started picking jsonb per adapter, and
|
|
114
|
+
# jsonb_array_elements rejects a json argument outright.
|
|
115
|
+
def self.llm_span_attribute_sql(attribute)
|
|
116
|
+
Arel.sql(
|
|
117
|
+
"(SELECT s.value -> 'attributes' ->> '#{attribute}' " \
|
|
118
|
+
"FROM jsonb_array_elements(spans::jsonb) AS s " \
|
|
119
|
+
"WHERE s.value ->> 'type' = 'llm' LIMIT 1)"
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
private_class_method :llm_span_attribute_sql
|
|
123
|
+
|
|
124
|
+
def self.first_llm_span(spans)
|
|
125
|
+
Array(spans).find { |span| span.is_a?(Hash) && span["type"].to_s == "llm" }
|
|
126
|
+
end
|
|
127
|
+
private_class_method :first_llm_span
|
|
128
|
+
|
|
108
129
|
# The trace columns the Metrics report reads, in the order
|
|
109
130
|
# pluck_metrics_rows returns them (before the span-derived fields).
|
|
110
131
|
METRICS_COLUMNS = %i[
|
|
@@ -136,6 +136,7 @@ module ActionAgent
|
|
|
136
136
|
|
|
137
137
|
scope.map do |run|
|
|
138
138
|
model = run.output_metadata&.dig("model")
|
|
139
|
+
provider = run.output_metadata&.dig("provider")
|
|
139
140
|
Row.new(
|
|
140
141
|
id: run.id,
|
|
141
142
|
source: "dashboard",
|
|
@@ -147,13 +148,14 @@ module ActionAgent
|
|
|
147
148
|
output_tokens: run.output_tokens.to_i,
|
|
148
149
|
cost: ModelPricing.estimate(
|
|
149
150
|
model: model,
|
|
151
|
+
provider: provider,
|
|
150
152
|
input_tokens: run.input_tokens,
|
|
151
153
|
output_tokens: run.output_tokens
|
|
152
154
|
),
|
|
153
155
|
trace_id: run.trace_id,
|
|
154
156
|
agent_id: run.agent_id,
|
|
155
157
|
action_name: run.action_name,
|
|
156
|
-
provider:
|
|
158
|
+
provider: provider,
|
|
157
159
|
model: model,
|
|
158
160
|
input_preview: run.input_prompt.to_s.truncate(160),
|
|
159
161
|
output_preview: run.output.to_s.truncate(160).presence,
|
|
@@ -183,6 +185,7 @@ module ActionAgent
|
|
|
183
185
|
output_tokens: trace.total_output_tokens.to_i,
|
|
184
186
|
cost: ModelPricing.estimate(
|
|
185
187
|
model: model,
|
|
188
|
+
provider: trace.provider,
|
|
186
189
|
input_tokens: trace.total_input_tokens,
|
|
187
190
|
output_tokens: trace.total_output_tokens
|
|
188
191
|
),
|
|
@@ -407,7 +407,7 @@ module ActionAgent
|
|
|
407
407
|
end
|
|
408
408
|
|
|
409
409
|
def cost_for(row)
|
|
410
|
-
ModelPricing.estimate(model: row.model, input_tokens: row.input_tokens, output_tokens: row.output_tokens) || 0.0
|
|
410
|
+
ModelPricing.estimate(model: row.model, provider: row.provider, input_tokens: row.input_tokens, output_tokens: row.output_tokens) || 0.0
|
|
411
411
|
end
|
|
412
412
|
|
|
413
413
|
def percent_change(before, after)
|
|
@@ -695,14 +695,16 @@ module ActionAgent
|
|
|
695
695
|
end
|
|
696
696
|
|
|
697
697
|
# The credentials the run's owner holds, scrubbed from the browser
|
|
698
|
-
# actions the run records: provider keys, the GitHub token,
|
|
699
|
-
#
|
|
698
|
+
# actions the run records: provider keys, the GitHub token, the header
|
|
699
|
+
# values of the owner's own MCP servers, and the runtime and browser
|
|
700
|
+
# tokens of the sandbox the run reaches.
|
|
700
701
|
def recording_secrets
|
|
701
702
|
sandbox_id = @run.try(:sandbox_id)
|
|
702
703
|
sandbox = sandbox_id && SandboxSession.for_owner(owner).find_by(session_id: sandbox_id)
|
|
703
704
|
[
|
|
704
705
|
*ProviderKey.for_owner(owner).limit(RECORDING_SECRET_LOOKUP_LIMIT).pluck(:credential, :api_key).flatten,
|
|
705
706
|
*GithubConnection.for_owner(owner).limit(RECORDING_SECRET_LOOKUP_LIMIT).pluck(:access_token),
|
|
707
|
+
*MCPServerConfig.for_owner(owner).limit(RECORDING_SECRET_LOOKUP_LIMIT).flat_map(&:secret_values),
|
|
706
708
|
sandbox&.runtime_mcp_token,
|
|
707
709
|
sandbox&.browser_token,
|
|
708
710
|
*owner_provider_options(requested_provider).values_at(:access_token, :api_key)
|