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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/action_agent.css +1 -1
  3. data/app/assets/builds/action_agent.js +61 -60
  4. data/app/controllers/action_agent/api/agents_controller.rb +15 -6
  5. data/app/controllers/action_agent/api/base_controller.rb +3 -2
  6. data/app/controllers/action_agent/api/mcp_server_configs_controller.rb +114 -0
  7. data/app/controllers/action_agent/api/mcp_servers_controller.rb +7 -4
  8. data/app/controllers/action_agent/api/metrics_controller.rb +5 -4
  9. data/app/controllers/action_agent/api/provider_keys_controller.rb +1 -1
  10. data/app/controllers/action_agent/api/provider_models_controller.rb +17 -5
  11. data/app/controllers/action_agent/traces_controller.rb +3 -2
  12. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +4 -2
  13. data/app/jobs/action_agent/agent_execution_job.rb +26 -6
  14. data/app/jobs/action_agent/evaluation_judge_job.rb +3 -2
  15. data/app/models/action_agent/catalog_scenario.rb +8 -2
  16. data/app/models/action_agent/evaluation_run.rb +11 -3
  17. data/app/models/action_agent/evaluation_scenario_result.rb +12 -3
  18. data/app/models/action_agent/mcp_server_config.rb +273 -0
  19. data/app/models/action_agent/model_pricing.rb +135 -24
  20. data/app/models/action_agent/telemetry_trace.rb +35 -14
  21. data/app/queries/action_agent/agent_executions.rb +4 -1
  22. data/app/queries/action_agent/metrics_report.rb +1 -1
  23. data/app/serializers/action_agent/telemetry_trace_serializer.rb +1 -0
  24. data/app/services/action_agent/agent_execution_service.rb +4 -2
  25. data/app/services/action_agent/agent_scorecard.rb +16 -12
  26. data/app/services/action_agent/agent_tool_roster.rb +5 -2
  27. data/app/services/action_agent/evaluation_fix_comparison.rb +7 -3
  28. data/app/services/action_agent/evaluation_report_import.rb +14 -2
  29. data/app/services/action_agent/evaluation_run_cost.rb +6 -5
  30. data/app/services/action_agent/evaluation_tool_resolver.rb +35 -7
  31. data/app/services/action_agent/mcp_client.rb +36 -4
  32. data/app/services/action_agent/mcp_host_guard.rb +129 -0
  33. data/app/services/action_agent/mcp_tool_dispatcher.rb +15 -5
  34. data/app/services/action_agent/ollama_host_probe.rb +36 -4
  35. data/app/services/action_agent/scenario_catalog_import.rb +2 -0
  36. data/app/services/action_agent/scenario_evaluation_runner.rb +69 -13
  37. data/app/services/action_agent/tool_discovery.rb +5 -1
  38. data/config/routes.rb +6 -0
  39. data/lib/action_agent/engine.rb +10 -0
  40. data/lib/action_agent/version.rb +1 -1
  41. data/lib/action_agent.rb +17 -0
  42. data/lib/generators/action_agent/templates/action_agent.rb.erb +11 -0
  43. data/lib/generators/action_agent/templates/migrations/030_add_evaluation_scoring_leases.rb.erb +8 -0
  44. data/lib/generators/action_agent/templates/migrations/031_add_catalog_scenario_metadata.rb.erb +24 -0
  45. data/lib/generators/action_agent/templates/migrations/032_create_active_agent_mcp_server_configs.rb.erb +53 -0
  46. 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
- # The static tables below are the fallback for aliases, self-hosted
15
- # models and an install without RubyLLM: exact rows (STATIC_RATES) for
16
- # the current frontier models, then name patterns (PRICES), then a
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: RubyLLM's bundled catalog, a registry the host
69
- # keeps itself (RubyLLM's model table or a refreshed listing), a row of
70
- # STATIC_RATES or PRICES, or DEFAULT_RATE. The last two are a guess at
71
- # the model's price rather than its listing.
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
- key = [ provider.to_s, model.to_s ]
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, provider.to_s.presence) || static_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 = [ STATIC_RATES, PRICES.map { |pattern, input, output| [ pattern.source, input, output ] }, DEFAULT_RATE, registry ]
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 vendor prefix, a date suffix or a
145
- # colon-tagged size, with dots as dashes, so the same model under two
146
- # spellings keys one row.
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: the name as
157
- # given under the provider it ran on, then bare, then with a gateway
158
- # or vendor prefix stripped (under the vendor's own provider), each
159
- # with its dots and dashes swapped and its date suffix dropped.
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
- # An exact row for the normalised name, else the first pattern the
247
- # name as given matches, else the default.
248
- def static_rate(model)
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
- llm = Array(spans).find { |span| span.is_a?(Hash) && span["type"].to_s == "llm" }
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: run.output_metadata&.dig("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)
@@ -55,6 +55,7 @@ module ActionAgent
55
55
  },
56
56
  estimated_cost: ModelPricing.estimate(
57
57
  model: @trace.model,
58
+ provider: @trace.provider,
58
59
  input_tokens: @trace.total_input_tokens,
59
60
  output_tokens: @trace.total_output_tokens
60
61
  )
@@ -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, and the
699
- # runtime and browser tokens of the sandbox the run reaches.
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)