actionagent 1.8.0 → 1.9.0

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 (168) 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 +76 -73
  4. data/app/assets/builds/action_agent_recorder.js +78 -0
  5. data/app/assets/builds/action_agent_replay.js +226 -0
  6. data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
  7. data/app/controllers/action_agent/api/agents_controller.rb +1 -0
  8. data/app/controllers/action_agent/api/api_keys_controller.rb +12 -2
  9. data/app/controllers/action_agent/api/base_controller.rb +63 -2
  10. data/app/controllers/action_agent/api/claude_logins_controller.rb +93 -0
  11. data/app/controllers/action_agent/api/code_sessions_controller.rb +71 -12
  12. data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +2 -2
  13. data/app/controllers/action_agent/api/draft_pull_requests_controller.rb +248 -0
  14. data/app/controllers/action_agent/api/evaluation_fixes_controller.rb +27 -0
  15. data/app/controllers/action_agent/api/evaluations_controller.rb +58 -7
  16. data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
  17. data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
  18. data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
  19. data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
  20. data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
  21. data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
  22. data/app/controllers/action_agent/api/members_controller.rb +36 -0
  23. data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
  24. data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
  25. data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
  26. data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
  27. data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
  28. data/app/controllers/action_agent/api/projects_controller.rb +512 -0
  29. data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
  30. data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
  31. data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
  32. data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
  33. data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
  34. data/app/controllers/action_agent/api/sandboxes_controller.rb +148 -8
  35. data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
  36. data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
  37. data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
  38. data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
  39. data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
  40. data/app/controllers/action_agent/dashboard_controller.rb +17 -6
  41. data/app/controllers/action_agent/session_player_controller.rb +58 -0
  42. data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
  43. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +627 -20
  44. data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
  45. data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
  46. data/app/jobs/action_agent/agent_execution_job.rb +9 -39
  47. data/app/jobs/action_agent/agent_resume_job.rb +84 -0
  48. data/app/jobs/action_agent/code_session_job.rb +39 -10
  49. data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
  50. data/app/jobs/action_agent/exploration_job.rb +80 -0
  51. data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
  52. data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
  53. data/app/jobs/action_agent/project_setup_job.rb +21 -0
  54. data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
  55. data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
  56. data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
  57. data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
  58. data/app/models/action_agent/agent.rb +72 -26
  59. data/app/models/action_agent/agent_run.rb +134 -23
  60. data/app/models/action_agent/agent_template.rb +35 -1
  61. data/app/models/action_agent/api_key.rb +14 -0
  62. data/app/models/action_agent/catalog_scenario.rb +75 -0
  63. data/app/models/action_agent/code_session.rb +21 -5
  64. data/app/models/action_agent/draft_pull_request.rb +131 -0
  65. data/app/models/action_agent/evaluation.rb +148 -0
  66. data/app/models/action_agent/evaluation_run.rb +143 -53
  67. data/app/models/action_agent/evaluation_scenario_result.rb +15 -3
  68. data/app/models/action_agent/exploration.rb +847 -0
  69. data/app/models/action_agent/github_installation.rb +159 -0
  70. data/app/models/action_agent/input_request.rb +291 -0
  71. data/app/models/action_agent/model_pricing.rb +214 -34
  72. data/app/models/action_agent/project.rb +789 -0
  73. data/app/models/action_agent/project_secret.rb +339 -0
  74. data/app/models/action_agent/provider_key.rb +207 -4
  75. data/app/models/action_agent/recording_event.rb +145 -0
  76. data/app/models/action_agent/recording_snapshot.rb +7 -7
  77. data/app/models/action_agent/sandbox_session.rb +300 -16
  78. data/app/models/action_agent/scenario_catalog.rb +172 -0
  79. data/app/models/action_agent/scenario_product.rb +57 -0
  80. data/app/models/action_agent/scenario_set.rb +138 -0
  81. data/app/models/action_agent/session_recording.rb +121 -14
  82. data/app/models/action_agent/telemetry_trace.rb +74 -19
  83. data/app/models/concerns/action_agent/ownable.rb +84 -13
  84. data/app/serializers/action_agent/evaluation_serializer.rb +41 -10
  85. data/app/serializers/action_agent/input_request_serializer.rb +47 -0
  86. data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
  87. data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
  88. data/app/services/action_agent/agent_execution_service.rb +450 -72
  89. data/app/services/action_agent/agent_scorecard.rb +50 -21
  90. data/app/services/action_agent/agent_sync.rb +5 -2
  91. data/app/services/action_agent/agent_tool_roster.rb +19 -4
  92. data/app/services/action_agent/agent_toolbox.rb +215 -9
  93. data/app/services/action_agent/browser_live_ticket.rb +78 -0
  94. data/app/services/action_agent/browser_sidecar.rb +213 -0
  95. data/app/services/action_agent/browser_sign_in.rb +163 -0
  96. data/app/services/action_agent/browser_tool_redaction.rb +92 -0
  97. data/app/services/action_agent/claude_code_auth.rb +70 -4
  98. data/app/services/action_agent/codex_session_events.rb +37 -0
  99. data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
  100. data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
  101. data/app/services/action_agent/evaluation_fix.rb +101 -0
  102. data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
  103. data/app/services/action_agent/evaluation_report_import.rb +84 -5
  104. data/app/services/action_agent/evaluation_run_cost.rb +609 -0
  105. data/app/services/action_agent/evaluation_runner_service.rb +29 -10
  106. data/app/services/action_agent/evaluation_standing.rb +152 -0
  107. data/app/services/action_agent/explorer_execution_service.rb +517 -0
  108. data/app/services/action_agent/github_client.rb +405 -64
  109. data/app/services/action_agent/live_updates.rb +31 -0
  110. data/app/services/action_agent/local_sandbox_backend.rb +1263 -102
  111. data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
  112. data/app/services/action_agent/mcp_client.rb +6 -2
  113. data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
  114. data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
  115. data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
  116. data/app/services/action_agent/playwright_mcp_client.rb +7 -0
  117. data/app/services/action_agent/project_boot_fix.rb +309 -0
  118. data/app/services/action_agent/project_capabilities.rb +200 -0
  119. data/app/services/action_agent/project_install_pull_request.rb +225 -0
  120. data/app/services/action_agent/project_preflight.rb +178 -0
  121. data/app/services/action_agent/project_secret_discovery.rb +142 -0
  122. data/app/services/action_agent/project_setup.rb +429 -0
  123. data/app/services/action_agent/provider_credentials.rb +102 -0
  124. data/app/services/action_agent/recording_event_ingest.rb +181 -0
  125. data/app/services/action_agent/runtime_tool_roster.rb +52 -0
  126. data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
  127. data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
  128. data/app/services/action_agent/sandbox_browser.rb +299 -0
  129. data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
  130. data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
  131. data/app/services/action_agent/sandbox_mail.rb +103 -0
  132. data/app/services/action_agent/sandbox_manifest.rb +166 -13
  133. data/app/services/action_agent/sandbox_orchestrator.rb +290 -23
  134. data/app/services/action_agent/sandbox_patch.rb +253 -0
  135. data/app/services/action_agent/scenario_catalog_import.rb +255 -0
  136. data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
  137. data/app/services/action_agent/scenario_evaluation_runner.rb +121 -17
  138. data/app/services/action_agent/secret_scrubber.rb +33 -4
  139. data/app/services/action_agent/session_index.rb +270 -0
  140. data/app/services/action_agent/session_recording_service.rb +11 -0
  141. data/app/services/action_agent/session_timeline.rb +505 -0
  142. data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
  143. data/app/views/action_agent/session_player/show.html.erb +19 -0
  144. data/config/routes.rb +153 -2
  145. data/lib/action_agent/claude_login_process.rb +107 -0
  146. data/lib/action_agent/engine.rb +55 -5
  147. data/lib/action_agent/secret_requests.rb +80 -0
  148. data/lib/action_agent/version.rb +1 -1
  149. data/lib/action_agent.rb +513 -22
  150. data/lib/generators/action_agent/install_generator.rb +45 -5
  151. data/lib/generators/action_agent/templates/action_agent.rb.erb +161 -0
  152. data/lib/generators/action_agent/templates/add_code_session_runner.rb.erb +9 -0
  153. data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
  154. data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
  155. data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
  156. data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
  157. data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
  158. data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
  159. data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
  160. data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
  161. data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
  162. data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
  163. data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
  164. data/lib/tasks/action_agent.rake +33 -0
  165. data/lib/tasks/browser.rake +22 -0
  166. data/lib/tasks/sandbox.rake +1 -1
  167. data/lib/tasks/scenario_catalogs.rake +71 -0
  168. metadata +100 -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