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.
- checksums.yaml +4 -4
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +59 -54
- data/app/controllers/action_agent/api/agents_controller.rb +19 -3
- data/app/controllers/action_agent/api/code_sessions_controller.rb +168 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +74 -131
- data/app/controllers/action_agent/api/github_connections_controller.rb +147 -0
- data/app/controllers/action_agent/api/mcp_controller.rb +38 -5
- data/app/controllers/action_agent/api/mcp_servers_controller.rb +10 -1
- data/app/controllers/action_agent/api/provider_keys_controller.rb +54 -3
- data/app/controllers/action_agent/api/provider_models_controller.rb +10 -6
- data/app/controllers/action_agent/api/sandboxes_controller.rb +105 -6
- data/app/controllers/concerns/action_agent/api/evaluation_run_starting.rb +93 -0
- data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +509 -0
- data/app/controllers/concerns/action_agent/api/run_sandbox.rb +65 -0
- data/app/jobs/action_agent/code_session_job.rb +171 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +80 -11
- data/app/jobs/action_agent/sandbox_provision_job.rb +122 -14
- data/app/jobs/action_agent/sandbox_run_job.rb +10 -3
- data/app/models/action_agent/agent.rb +49 -6
- data/app/models/action_agent/agent_run.rb +20 -1
- data/app/models/action_agent/code_session.rb +145 -0
- data/app/models/action_agent/evaluation.rb +64 -0
- data/app/models/action_agent/evaluation_run.rb +166 -54
- data/app/models/action_agent/evaluation_scenario_result.rb +15 -3
- data/app/models/action_agent/github_connection.rb +75 -0
- data/app/models/action_agent/model_pricing.rb +214 -34
- data/app/models/action_agent/provider_key.rb +148 -10
- data/app/models/action_agent/sandbox_session.rb +194 -17
- data/app/serializers/action_agent/evaluation_serializer.rb +149 -0
- data/app/serializers/action_agent/telemetry_trace_serializer.rb +10 -2
- data/app/services/action_agent/agent_execution_service.rb +4 -2
- data/app/services/action_agent/agent_scorecard.rb +50 -21
- data/app/services/action_agent/agent_tool_roster.rb +20 -9
- data/app/services/action_agent/claude_code_auth.rb +86 -0
- data/app/services/action_agent/codex_session_events.rb +37 -0
- data/app/services/action_agent/dashboard_assistant_service.rb +47 -5
- data/app/services/action_agent/evaluation_report_import.rb +84 -5
- data/app/services/action_agent/evaluation_run_cost.rb +609 -0
- data/app/services/action_agent/evaluation_runner_service.rb +25 -5
- data/app/services/action_agent/evaluation_standing.rb +145 -0
- data/app/services/action_agent/evaluation_tool_resolver.rb +18 -0
- data/app/services/action_agent/github_client.rb +111 -0
- data/app/services/action_agent/local_sandbox_backend.rb +1715 -0
- data/app/services/action_agent/local_sandbox_databases.rb +257 -0
- data/app/services/action_agent/mcp_client.rb +5 -1
- data/app/services/action_agent/mcp_tool_dispatcher.rb +137 -17
- data/app/services/action_agent/mock_sandbox_backend.rb +39 -0
- data/app/services/action_agent/ollama_host_probe.rb +75 -0
- data/app/services/action_agent/payload_bounds.rb +36 -0
- data/app/services/action_agent/sandbox_manifest.rb +67 -0
- data/app/services/action_agent/sandbox_orchestrator.rb +82 -14
- data/app/services/action_agent/scenario_evaluation_runner.rb +52 -5
- data/app/services/action_agent/secret_scrubber.rb +37 -0
- data/app/services/action_agent/tool_discovery.rb +19 -5
- data/config/routes.rb +23 -4
- data/lib/action_agent/engine.rb +1 -0
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +153 -3
- data/lib/generators/action_agent/install_generator.rb +35 -5
- data/lib/generators/action_agent/templates/action_agent.rb.erb +47 -0
- data/lib/generators/action_agent/templates/add_code_session_runner.rb.erb +9 -0
- data/lib/generators/action_agent/templates/add_provider_key_api_key.rb.erb +25 -0
- data/lib/generators/action_agent/templates/create_active_agent_code_sessions.rb.erb +59 -0
- data/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb +2 -0
- data/lib/generators/action_agent/templates/create_active_agent_github_connections.rb.erb +61 -0
- data/lib/tasks/claude_code.rake +16 -0
- data/lib/tasks/sandbox.rake +26 -0
- 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
|
|
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
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
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
|
-
#
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
return
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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
|
|
11
|
-
# keys are never rendered back to the client —
|
|
12
|
-
# hosts are not secret and are shown in full
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|