actionagent 1.7.2 → 1.8.1

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 (69) 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 +59 -54
  4. data/app/controllers/action_agent/api/agents_controller.rb +19 -3
  5. data/app/controllers/action_agent/api/code_sessions_controller.rb +168 -0
  6. data/app/controllers/action_agent/api/evaluations_controller.rb +74 -131
  7. data/app/controllers/action_agent/api/github_connections_controller.rb +147 -0
  8. data/app/controllers/action_agent/api/mcp_controller.rb +38 -5
  9. data/app/controllers/action_agent/api/mcp_servers_controller.rb +10 -1
  10. data/app/controllers/action_agent/api/provider_keys_controller.rb +54 -3
  11. data/app/controllers/action_agent/api/provider_models_controller.rb +10 -6
  12. data/app/controllers/action_agent/api/sandboxes_controller.rb +105 -6
  13. data/app/controllers/concerns/action_agent/api/evaluation_run_starting.rb +93 -0
  14. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +509 -0
  15. data/app/controllers/concerns/action_agent/api/run_sandbox.rb +65 -0
  16. data/app/jobs/action_agent/code_session_job.rb +171 -0
  17. data/app/jobs/action_agent/sandbox_cleanup_job.rb +80 -11
  18. data/app/jobs/action_agent/sandbox_provision_job.rb +122 -14
  19. data/app/jobs/action_agent/sandbox_run_job.rb +10 -3
  20. data/app/models/action_agent/agent.rb +49 -6
  21. data/app/models/action_agent/agent_run.rb +20 -1
  22. data/app/models/action_agent/code_session.rb +145 -0
  23. data/app/models/action_agent/evaluation.rb +64 -0
  24. data/app/models/action_agent/evaluation_run.rb +166 -54
  25. data/app/models/action_agent/evaluation_scenario_result.rb +15 -3
  26. data/app/models/action_agent/github_connection.rb +75 -0
  27. data/app/models/action_agent/model_pricing.rb +214 -34
  28. data/app/models/action_agent/provider_key.rb +148 -10
  29. data/app/models/action_agent/sandbox_session.rb +194 -17
  30. data/app/serializers/action_agent/evaluation_serializer.rb +149 -0
  31. data/app/serializers/action_agent/telemetry_trace_serializer.rb +10 -2
  32. data/app/services/action_agent/agent_execution_service.rb +4 -2
  33. data/app/services/action_agent/agent_scorecard.rb +50 -21
  34. data/app/services/action_agent/agent_tool_roster.rb +20 -9
  35. data/app/services/action_agent/claude_code_auth.rb +86 -0
  36. data/app/services/action_agent/codex_session_events.rb +37 -0
  37. data/app/services/action_agent/dashboard_assistant_service.rb +47 -5
  38. data/app/services/action_agent/evaluation_report_import.rb +84 -5
  39. data/app/services/action_agent/evaluation_run_cost.rb +609 -0
  40. data/app/services/action_agent/evaluation_runner_service.rb +25 -5
  41. data/app/services/action_agent/evaluation_standing.rb +145 -0
  42. data/app/services/action_agent/evaluation_tool_resolver.rb +18 -0
  43. data/app/services/action_agent/github_client.rb +111 -0
  44. data/app/services/action_agent/local_sandbox_backend.rb +1715 -0
  45. data/app/services/action_agent/local_sandbox_databases.rb +257 -0
  46. data/app/services/action_agent/mcp_client.rb +5 -1
  47. data/app/services/action_agent/mcp_tool_dispatcher.rb +137 -17
  48. data/app/services/action_agent/mock_sandbox_backend.rb +39 -0
  49. data/app/services/action_agent/ollama_host_probe.rb +75 -0
  50. data/app/services/action_agent/payload_bounds.rb +36 -0
  51. data/app/services/action_agent/sandbox_manifest.rb +67 -0
  52. data/app/services/action_agent/sandbox_orchestrator.rb +82 -14
  53. data/app/services/action_agent/scenario_evaluation_runner.rb +52 -5
  54. data/app/services/action_agent/secret_scrubber.rb +37 -0
  55. data/app/services/action_agent/tool_discovery.rb +19 -5
  56. data/config/routes.rb +23 -4
  57. data/lib/action_agent/engine.rb +1 -0
  58. data/lib/action_agent/version.rb +1 -1
  59. data/lib/action_agent.rb +153 -3
  60. data/lib/generators/action_agent/install_generator.rb +35 -5
  61. data/lib/generators/action_agent/templates/action_agent.rb.erb +47 -0
  62. data/lib/generators/action_agent/templates/add_code_session_runner.rb.erb +9 -0
  63. data/lib/generators/action_agent/templates/add_provider_key_api_key.rb.erb +25 -0
  64. data/lib/generators/action_agent/templates/create_active_agent_code_sessions.rb.erb +59 -0
  65. data/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb +2 -0
  66. data/lib/generators/action_agent/templates/create_active_agent_github_connections.rb.erb +61 -0
  67. data/lib/tasks/claude_code.rake +16 -0
  68. data/lib/tasks/sandbox.rake +26 -0
  69. metadata +28 -2
@@ -1,16 +1,40 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+
3
5
  module ActionAgent
4
6
  # Estimates LLM spend from token counts. The activeagent gem's telemetry
5
7
  # records tokens only; the platform layers pricing on top for the cost
6
- # figures shown in Traces and Metrics.
8
+ # figures shown in Traces, Metrics and Evaluations.
7
9
  #
8
10
  # Rates come from RubyLLM's model registry (USD per million tokens,
9
- # maintained upstream per model) when the model is known there; the static
10
- # pattern table below is the fallback for aliases/self-hosted models, and
11
- # a conservative blended rate covers everything else so totals stay
12
- # meaningful. Costs are always presented as estimates.
11
+ # maintained upstream per model) when the model is known there — looked
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
17
+ # conservative blended rate so totals stay meaningful. Costs are always
18
+ # presented as estimates, and every rate says where it came from
19
+ # (`source`), so a figure worked out at a fallback rate can say so.
13
20
  class ModelPricing
21
+ # Exact rates for models the pattern table would otherwise misprice —
22
+ # claude-sonnet-5 is not a $3/$15 Sonnet, and the gpt-5 family is not
23
+ # one price — keyed by the name with its vendor prefix and date suffix
24
+ # removed and its dots and dashes normalised (see .normalize).
25
+ STATIC_RATES = {
26
+ "claude-sonnet-5" => [ 2.00, 10.00 ],
27
+ "gpt-5-5" => [ 5.00, 30.00 ],
28
+ "gpt-5-5-mini" => [ 0.25, 2.00 ],
29
+ "gpt-5-5-nano" => [ 0.05, 0.40 ],
30
+ "gpt-5-1" => [ 1.25, 10.00 ],
31
+ "gpt-5-1-mini" => [ 0.25, 2.00 ],
32
+ "gpt-5-1-nano" => [ 0.05, 0.40 ],
33
+ "gpt-5" => [ 1.25, 10.00 ],
34
+ "gpt-5-mini" => [ 0.25, 2.00 ],
35
+ "gpt-5-nano" => [ 0.05, 0.40 ]
36
+ }.freeze
37
+
14
38
  PRICES = [
15
39
  # [pattern, input $/1M, output $/1M]
16
40
  [ /gpt-4o-mini/i, 0.15, 0.60 ],
@@ -18,10 +42,15 @@ module ActionAgent
18
42
  [ /gpt-4\.1-nano/i, 0.10, 0.40 ],
19
43
  [ /gpt-4\.1-mini/i, 0.40, 1.60 ],
20
44
  [ /gpt-4\.1/i, 2.00, 8.00 ],
45
+ [ /gpt-5\.?5/i, 5.00, 30.00 ],
46
+ [ /gpt-5(\.\d+)?-nano/i, 0.05, 0.40 ],
47
+ [ /gpt-5(\.\d+)?-mini/i, 0.25, 2.00 ],
48
+ [ /gpt-5/i, 1.25, 10.00 ],
21
49
  [ /o3-mini|o4-mini/i, 1.10, 4.40 ],
22
50
  [ /claude.*(fable|mythos)/i, 10.00, 50.00 ],
23
51
  [ /claude.*haiku-?4/i, 1.00, 5.00 ],
24
52
  [ /claude.*(haiku)/i, 0.80, 4.00 ],
53
+ [ /claude.*sonnet-?5/i, 2.00, 10.00 ],
25
54
  [ /claude.*(sonnet)/i, 3.00, 15.00 ],
26
55
  [ /claude.*opus-(5|4-[5-9])/i, 5.00, 25.00 ],
27
56
  [ /claude.*(opus)/i, 15.00, 75.00 ],
@@ -36,45 +65,196 @@ module ActionAgent
36
65
  # Fallback blended rate for unknown models ($/1M input, $/1M output)
37
66
  DEFAULT_RATE = [ 1.00, 4.00 ].freeze
38
67
 
39
- # @return [Float, nil] estimated USD cost, nil when there is nothing to price
40
- def self.estimate(model:, input_tokens:, output_tokens:)
41
- input = input_tokens.to_i
42
- output = output_tokens.to_i
43
- return nil if input.zero? && output.zero?
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.
72
+ SOURCES = %w[catalog remote pattern default].freeze
73
+ FALLBACK_SOURCES = %w[pattern default].freeze
44
74
 
45
- input_rate, output_rate = rate_for(model)
46
- ((input * input_rate) + (output * output_rate)) / 1_000_000.0
47
- end
75
+ # A gateway's prefix on a model name, which names the provider rather
76
+ # than the model.
77
+ GATEWAY_PREFIXES = %w[openrouter requesty].freeze
78
+ # Vendor prefixes a gateway (or a host) puts before a model name, and
79
+ # the provider the bare name is listed under.
80
+ VENDOR_PROVIDERS = {
81
+ "openai" => "openai", "anthropic" => "anthropic", "google" => "gemini", "gemini" => "gemini",
82
+ "meta-llama" => nil, "mistralai" => "mistral", "mistral" => "mistral", "deepseek" => "deepseek",
83
+ "qwen" => nil, "x-ai" => "xai", "xai" => "xai", "cohere" => nil, "perplexity" => "perplexity"
84
+ }.freeze
48
85
 
49
- def self.rate_for(model)
50
- return DEFAULT_RATE if model.blank?
86
+ class << self
87
+ # @return [Float, nil] estimated USD cost, nil when there is nothing to price
88
+ def estimate(model:, input_tokens:, output_tokens:, provider: nil)
89
+ estimate_detailed(model: model, input_tokens: input_tokens, output_tokens: output_tokens, provider: provider)&.fetch(:cost)
90
+ end
51
91
 
52
- registry_rate(model) || static_rate(model)
53
- end
92
+ # The estimate with its working: `{ cost:, input_rate:, output_rate:,
93
+ # source: }`, rates in $ per million tokens. nil when both token
94
+ # counts are zero — a $0.00 is the caller's to decide on, since
95
+ # nothing generated and nothing recorded look alike here.
96
+ def estimate_detailed(model:, input_tokens:, output_tokens:, provider: nil)
97
+ input = input_tokens.to_i
98
+ output = output_tokens.to_i
99
+ return nil if input.zero? && output.zero?
100
+
101
+ rate = rate_detail(model, provider: provider)
102
+ {
103
+ cost: ((input * rate[:input]) + (output * rate[:output])) / 1_000_000.0,
104
+ input_rate: rate[:input],
105
+ output_rate: rate[:output],
106
+ source: rate[:source]
107
+ }
108
+ end
109
+
110
+ # @return [Array(Float, Float)] input and output rates in $ per million tokens
111
+ def rate_for(model, provider = nil)
112
+ rate = rate_detail(model, provider: provider)
113
+ [ rate[:input], rate[:output] ]
114
+ end
115
+
116
+ # `{ input:, output:, source: }` for a model under a provider, memoized
117
+ # per [provider, model]: the registry scan is not free and trace
118
+ # serialization asks per row.
119
+ def rate_detail(model, provider: nil)
120
+ return { input: DEFAULT_RATE[0], output: DEFAULT_RATE[1], source: "default" } if model.blank?
121
+
122
+ key = [ provider.to_s, model.to_s ]
123
+ @rates ||= {}
124
+ return @rates[key] if @rates.key?(key)
125
+
126
+ @rates[key] = registry_rate(model.to_s, provider.to_s.presence) || static_rate(model.to_s)
127
+ end
54
128
 
55
- # Exact per-model rates from RubyLLM's registry. Lookups are memoized —
56
- # the registry scan is not free and trace serialization calls this per
57
- # row.
58
- def self.registry_rate(model)
59
- @registry_rates ||= {}
60
- return @registry_rates[model] if @registry_rates.key?(model)
61
-
62
- @registry_rates[model] = begin
63
- info = RubyLLM.models.find(model.to_s)
64
- tokens = info&.pricing&.text_tokens
65
- if tokens&.input && tokens&.output
66
- [ tokens.input, tokens.output ]
129
+ # Forgets memoized rates — after RubyLLM is loaded, or in tests.
130
+ def reset!
131
+ @rates = {}
132
+ end
133
+
134
+ # Names the rate tables in force, so a cache of figures worked out at
135
+ # them is invalidated when they change: the static rows, the default,
136
+ # and the version of the registry the install has (or none).
137
+ def fingerprint
138
+ 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 ]
140
+ Digest::SHA256.hexdigest(JSON.generate(tables))[0, 12]
141
+ end
142
+
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.
147
+ def normalize(model)
148
+ name = model.to_s.strip.downcase
149
+ name = name.split("/").last.to_s
150
+ name = name.sub(/-\d{8}\z/, "").sub(/-\d{4}-\d{2}-\d{2}\z/, "")
151
+ name.tr(".", "-")
152
+ end
153
+
154
+ private
155
+
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.
160
+ def registry_rate(model, provider)
161
+ return nil unless registry_available?
162
+
163
+ lookup_candidates(model, provider).each do |id, candidate_provider|
164
+ info = registry_find(id, candidate_provider)
165
+ tokens = info&.pricing&.text_tokens
166
+ next unless tokens.respond_to?(:input) && tokens.input && tokens.output
167
+
168
+ return { input: tokens.input.to_f, output: tokens.output.to_f, source: registry_source }
67
169
  end
170
+ nil
68
171
  rescue StandardError
69
172
  nil
70
173
  end
71
- end
72
174
 
73
- def self.static_rate(model)
74
- PRICES.each do |pattern, input_rate, output_rate|
75
- return [ input_rate, output_rate ] if model.to_s.match?(pattern)
175
+ def registry_available?
176
+ defined?(::RubyLLM) && ::RubyLLM.respond_to?(:models)
177
+ end
178
+
179
+ # A host that keeps its own model registry (RubyLLM's model table or
180
+ # a refreshed listing) prices from it rather than from the gem's
181
+ # bundled catalog.
182
+ def registry_source
183
+ config = ::RubyLLM.respond_to?(:config) ? ::RubyLLM.config : nil
184
+ return "remote" if config.respond_to?(:model_registry_class) && config.model_registry_class.present?
185
+
186
+ "catalog"
187
+ end
188
+
189
+ # RubyLLM 2 takes the provider as a keyword; 1.x took it positionally.
190
+ def registry_find(id, provider)
191
+ models = ::RubyLLM.models
192
+ return models.find(id) if provider.blank?
193
+
194
+ if registry_find_keyword?(models)
195
+ models.find(id, provider: provider)
196
+ else
197
+ models.find(id, provider)
198
+ end
199
+ rescue StandardError
200
+ nil
201
+ end
202
+
203
+ def registry_find_keyword?(models)
204
+ models.method(:find).parameters.any? { |type, name| name == :provider && %i[key keyreq].include?(type) }
205
+ end
206
+
207
+ # [[id, provider], ...] to try in order, without repeats.
208
+ def lookup_candidates(model, provider)
209
+ name = model.strip
210
+ provider = provider&.downcase
211
+ candidates = []
212
+
213
+ head, rest = name.split("/", 2)
214
+ if rest.present? && GATEWAY_PREFIXES.include?(head.downcase)
215
+ provider ||= head.downcase
216
+ name = rest
217
+ head, rest = name.split("/", 2)
218
+ end
219
+
220
+ names = [ name ]
221
+ if rest.present? && VENDOR_PROVIDERS.key?(head.downcase)
222
+ names << rest
223
+ vendor_provider = VENDOR_PROVIDERS[head.downcase]
224
+ end
225
+
226
+ names.each do |candidate|
227
+ spellings(candidate).each do |spelling|
228
+ candidates << [ spelling, provider ] if provider
229
+ candidates << [ spelling, vendor_provider ] if vendor_provider && vendor_provider != provider && candidate == rest
230
+ candidates << [ spelling, nil ]
231
+ end
232
+ end
233
+ candidates.uniq
234
+ end
235
+
236
+ # The name as given, then with its date suffix dropped, each with its
237
+ # dots and dashes swapped: "claude-sonnet-4.5" and "claude-sonnet-4-5"
238
+ # are one model.
239
+ def spellings(name)
240
+ undated = name.sub(/-\d{8}\z/, "").sub(/-\d{4}-\d{2}-\d{2}\z/, "")
241
+ [ name, undated ].uniq.flat_map do |spelling|
242
+ [ spelling, spelling.gsub(/(\d)\.(\d)/, '\1-\2'), spelling.gsub(/(\d)-(\d)/, '\1.\2') ]
243
+ end.uniq
244
+ end
245
+
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)
249
+ if (exact = STATIC_RATES[normalize(model)])
250
+ return { input: exact[0], output: exact[1], source: "pattern" }
251
+ end
252
+
253
+ PRICES.each do |pattern, input_rate, output_rate|
254
+ return { input: input_rate, output: output_rate, source: "pattern" } if model.match?(pattern)
255
+ end
256
+ { input: DEFAULT_RATE[0], output: DEFAULT_RATE[1], source: "default" }
76
257
  end
77
- DEFAULT_RATE
78
258
  end
79
259
  end
80
260
  end
@@ -5,22 +5,53 @@ module ActionAgent
5
5
  # Generation runs (AgentExecutionService and the evaluation LLM judge)
6
6
  # prefer these over the platform's ENV-configured keys, so users can run
7
7
  # agents with their own OpenAI/Anthropic/OpenRouter accounts — or point
8
- # ollama at their own host (e.g. a tunnel to a locally running instance).
8
+ # ollama at their own host: a locally running instance, a tunnel to one, or
9
+ # a remote/cloud server that additionally needs a Bearer API key.
9
10
  #
10
- # The credential is encrypted at rest with Active Record Encryption. API
11
- # keys are never rendered back to the client — only a masked hint; ollama
12
- # hosts are not secret and are shown in full (see #display_hint).
11
+ # The credential (and the optional api_key) is encrypted at rest with
12
+ # Active Record Encryption. API keys are never rendered back to the client —
13
+ # only a masked hint; ollama hosts are not secret and are shown in full
14
+ # (see #display_hint).
15
+ #
16
+ # A connection credential (Claude Code) is stored the same way but is not a
17
+ # generation provider: no agent runs "on" it. It is handed to runtimes that
18
+ # need it — a checkout sandbox runs Claude Code sessions with it — through
19
+ # #runtime_environment. For Claude Code that is an Anthropic API key only
20
+ # (see CLAUDE_CODE_CREDENTIAL).
13
21
  class ProviderKey < ApplicationRecord
14
22
  # Providers that authenticate with an API key.
15
23
  KEY_PROVIDERS = %w[openai anthropic openrouter].freeze
16
- # Providers addressed by host URL instead of a key.
24
+ # Providers addressed by host URL instead of a key (with an optional key
25
+ # for remote servers).
17
26
  HOST_PROVIDERS = %w[ollama].freeze
18
- PROVIDERS = (KEY_PROVIDERS + HOST_PROVIDERS).freeze
27
+ # Tools connected with a credential, configured beside the providers
28
+ # (Settings -> Integrations) but never offered to the agent builder.
29
+ CONNECTION_PROVIDERS = %w[claude_code codex].freeze
30
+ PROVIDERS = (KEY_PROVIDERS + HOST_PROVIDERS + CONNECTION_PROVIDERS).freeze
31
+
32
+ # Only an Anthropic API key (sk-ant-api03-…, from the Claude Console or
33
+ # a supported cloud provider). Anthropic does not let third-party
34
+ # products collect, store or route requests through Claude.ai
35
+ # subscription credentials (a `claude setup-token` token, sk-ant-oat…):
36
+ # https://code.claude.com/docs/en/legal-and-compliance.md. A developer
37
+ # who wants their own subscription on their own machine uses
38
+ # ActionAgent.claude_code_auth = :local_login instead, where the
39
+ # dashboard never touches the credential.
40
+ CLAUDE_CODE_CREDENTIAL = /\Ask-ant-api\d{2}-[A-Za-z0-9_-]+\z/
41
+ CODEX_CREDENTIAL = /\Ask-(?!ant-|or-)[A-Za-z0-9_-]+\z/
42
+ # A Claude subscription token, as earlier versions stored. Recognized so
43
+ # a stored one is never handed out (see #needs_replacing?).
44
+ SUBSCRIPTION_TOKEN_PREFIX = "sk-ant-oat"
19
45
 
20
46
  include Ownable
21
47
  owned_by :account, :user
22
48
 
23
- encrypts :credential if ActionAgent.encrypt_credentials
49
+ if ActionAgent.encrypt_credentials
50
+ encrypts :credential
51
+ encrypts :api_key
52
+ end
53
+
54
+ before_validation :normalize_host_credential, if: :host_based?
24
55
 
25
56
  validates :provider, presence: true, inclusion: { in: PROVIDERS }
26
57
  # One credential per provider per owner; which column that means
@@ -29,26 +60,133 @@ module ActionAgent
29
60
  validates :credential, presence: true, length: { maximum: 500 }
30
61
  validates :credential, format: { with: %r{\Ahttps?://\S+\z}, message: "must be an http(s):// URL" },
31
62
  if: :host_based?
63
+ validates :credential, format: {
64
+ with: CLAUDE_CODE_CREDENTIAL,
65
+ message: "must be an Anthropic API key (sk-ant-api…) from the Claude Console (https://platform.claude.com). " \
66
+ "Claude subscription tokens (`claude setup-token`, sk-ant-oat…) cannot be stored: Anthropic does not allow " \
67
+ "third-party apps to hold Claude.ai credentials. To use your own Claude login on this machine, set " \
68
+ "ActionAgent.claude_code_auth = :local_login with the :local sandbox backend instead"
69
+ }, if: -> { provider == "claude_code" }
70
+ validates :credential, format: { with: CODEX_CREDENTIAL, message: "must be an OpenAI API key (sk-…)" },
71
+ if: -> { provider == "codex" }
72
+ validates :api_key, length: { maximum: 500 }, allow_nil: true
73
+
74
+ # Deletes every Claude Code connection that still holds a Claude
75
+ # subscription token (see #needs_replacing?), whoever owns it. The
76
+ # credential is encrypted, so each is read to tell. Their owners see
77
+ # Claude Code as not connected, and connect an API key again.
78
+ #
79
+ # @return [Integer] how many were deleted
80
+ def self.purge_subscription_tokens!
81
+ where(provider: "claude_code").find_each.count do |key|
82
+ key.needs_replacing? && key.destroy!
83
+ end
84
+ end
85
+
86
+ def self.kind_of_provider(provider)
87
+ if HOST_PROVIDERS.include?(provider) then "host"
88
+ elsif CONNECTION_PROVIDERS.include?(provider) then "connection"
89
+ else "key"
90
+ end
91
+ end
92
+
93
+ # Ollama's OpenAI-compatible API lives under /v1. Accept the bare server
94
+ # address people naturally paste (http://localhost:11434, a tunnel
95
+ # hostname) and add the path; trailing slashes are dropped so the client
96
+ # can join paths cleanly. An explicit non-root path is left alone, for
97
+ # servers behind a reverse proxy.
98
+ def self.normalize_host(value)
99
+ host = value.to_s.strip.chomp("/")
100
+ return host if host.blank?
101
+
102
+ uri = URI.parse(host)
103
+ return host unless uri.is_a?(URI::HTTP)
104
+
105
+ uri.path = "/v1" if uri.path.blank? || uri.path == "/"
106
+ uri.to_s.chomp("/")
107
+ rescue URI::InvalidURIError
108
+ host
109
+ end
32
110
 
33
111
  def host_based?
34
112
  HOST_PROVIDERS.include?(provider)
35
113
  end
36
114
 
115
+ def connection?
116
+ CONNECTION_PROVIDERS.include?(provider)
117
+ end
118
+
119
+ # Only host-based providers carry an optional key (a remote Ollama behind
120
+ # an authenticating proxy, or Ollama Cloud).
121
+ def api_key?
122
+ host_based? && api_key.present?
123
+ end
124
+
37
125
  # Options merged into generate_with for runs owned by this key's owner,
38
- # overriding the host app's config/active_agent.yml credentials.
126
+ # overriding the host app's config/active_agent.yml credentials. A
127
+ # connection credential configures no generation.
39
128
  def generation_options
40
- host_based? ? { host: credential } : { access_token: credential }
129
+ return {} if connection?
130
+ return { access_token: credential } unless host_based?
131
+
132
+ { host: credential, access_token: api_key.presence }.compact
133
+ end
134
+
135
+ # Environment variables a runtime needs to use this credential, for the
136
+ # credentials that are consumed by a process rather than a provider
137
+ # client: Claude Code reads an API key from ANTHROPIC_API_KEY.
138
+ #
139
+ # A subscription token stored before those were refused is never handed
140
+ # to a process: it yields nothing, as if Claude Code were not connected,
141
+ # until it is replaced with an API key.
142
+ #
143
+ # @return [Hash{String => String}]
144
+ def runtime_environment
145
+ if provider == "codex"
146
+ return CODEX_CREDENTIAL.match?(credential.to_s) ? { "CODEX_API_KEY" => credential } : {}
147
+ end
148
+ return {} unless provider == "claude_code"
149
+ return {} if needs_replacing? || !CLAUDE_CODE_CREDENTIAL.match?(credential.to_s)
150
+
151
+ { "ANTHROPIC_API_KEY" => credential }
152
+ end
153
+
154
+ # A Claude Code connection that still holds a Claude subscription token
155
+ # (sk-ant-oat…), stored by an earlier version. It is no longer used and
156
+ # must be replaced with an API key; `bin/rails
157
+ # action_agent:claude_code:purge_subscription_tokens` deletes them all.
158
+ def needs_replacing?
159
+ provider == "claude_code" && credential.to_s.start_with?(SUBSCRIPTION_TOKEN_PREFIX)
41
160
  end
42
161
 
43
162
  # "sk-a…Q2z9" for keys; hosts are shown in full.
44
163
  def display_hint
45
164
  return credential if host_based?
46
165
 
47
- "#{credential.first(4)}…#{credential.last(4)}"
166
+ mask(credential)
167
+ end
168
+
169
+ # Masked hint for the optional host-provider key, nil when none is set.
170
+ def api_key_hint
171
+ api_key? ? mask(api_key) : nil
172
+ end
173
+
174
+ # Reachability + served models for host-based providers.
175
+ def probe
176
+ OllamaHostProbe.call(host: credential, api_key: api_key)
48
177
  end
49
178
 
50
179
  private
51
180
 
181
+ def mask(value)
182
+ "#{value.first(4)}…#{value.last(4)}"
183
+ end
184
+
185
+ def normalize_host_credential
186
+ self.credential = self.class.normalize_host(credential) if credential.present?
187
+ self.api_key = api_key.presence&.strip
188
+ end
189
+
52
190
  def provider_unique_within_owner
53
191
  return if provider.blank?
54
192