rails_console_ai 0.34.0 → 0.36.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.
@@ -1,6 +1,6 @@
1
1
  module RailsConsoleAi
2
2
  class Configuration
3
- PROVIDERS = %i[anthropic openai local bedrock].freeze
3
+ PROVIDERS = %i[anthropic openai openrouter local bedrock].freeze
4
4
 
5
5
  # Per-family model attributes, matched by substring so one entry covers every
6
6
  # ID variant of a family: bare Anthropic IDs (claude-sonnet-5), dated
@@ -11,17 +11,23 @@ module RailsConsoleAi
11
11
  # Cache pricing is derived: read = 0.1x input, write = 1.25x input.
12
12
  # temperature: false marks families that reject the `temperature` parameter
13
13
  # (removed on opus-4-7+, sonnet-5, and fable-5).
14
+ # max_tokens is the OUTPUT cap we request; context is the total window.
14
15
  MODEL_FAMILIES = {
15
- 'claude-fable-5' => { input: 10.0, output: 50.0, max_tokens: 16_000, temperature: false },
16
- 'claude-opus-5' => { input: 5.0, output: 25.0, max_tokens: 16_000, temperature: false },
17
- 'claude-opus-4-8' => { input: 5.0, output: 25.0, max_tokens: 16_000, temperature: false },
18
- 'claude-opus-4-7' => { input: 5.0, output: 25.0, max_tokens: 16_000, temperature: false },
19
- 'claude-opus-4-6' => { input: 5.0, output: 25.0, max_tokens: 16_000, temperature: true },
20
- 'claude-sonnet-5' => { input: 3.0, output: 15.0, max_tokens: 16_000, temperature: false },
21
- 'claude-sonnet-4-6' => { input: 3.0, output: 15.0, max_tokens: 16_000, temperature: true },
22
- 'claude-haiku-4-5' => { input: 1.0, output: 5.0, max_tokens: 16_000, temperature: true },
16
+ 'claude-fable-5' => { input: 10.0, output: 50.0, max_tokens: 16_000, context: 1_000_000, temperature: false },
17
+ 'claude-opus-5' => { input: 5.0, output: 25.0, max_tokens: 16_000, context: 1_000_000, temperature: false },
18
+ 'claude-opus-4-8' => { input: 5.0, output: 25.0, max_tokens: 16_000, context: 1_000_000, temperature: false },
19
+ 'claude-opus-4-7' => { input: 5.0, output: 25.0, max_tokens: 16_000, context: 1_000_000, temperature: false },
20
+ 'claude-opus-4-6' => { input: 5.0, output: 25.0, max_tokens: 16_000, context: 1_000_000, temperature: true },
21
+ 'claude-sonnet-5' => { input: 2.0, output: 10.0, max_tokens: 16_000, context: 1_000_000, temperature: false },
22
+ 'claude-sonnet-4-6' => { input: 3.0, output: 15.0, max_tokens: 16_000, context: 1_000_000, temperature: true },
23
+ 'claude-haiku-4-5' => { input: 1.0, output: 5.0, max_tokens: 16_000, context: 200_000, temperature: true },
23
24
  }.freeze
24
25
 
26
+ # Assumed context window for models with no family entry — local models and
27
+ # anything newer than this table. Deliberately small: under-guessing warns a
28
+ # little early, over-guessing means no warning before the request is rejected.
29
+ DEFAULT_CONTEXT_WINDOW = 200_000
30
+
25
31
  # Family keys sorted longest-first so a more specific family always wins
26
32
  # if keys ever overlap (e.g. a future 'claude-sonnet-5-5' entry would match
27
33
  # before 'claude-sonnet-5').
@@ -30,13 +36,16 @@ module RailsConsoleAi
30
36
  # Returns the family attributes for a model ID, or nil for unknown models.
31
37
  def self.model_family(model_id)
32
38
  return nil unless model_id
33
- key = MODEL_FAMILY_KEYS.find { |k| model_id.include?(k) }
39
+ normalized = model_id.gsub(/(?<=\d)\.(?=\d)/, '-')
40
+ key = MODEL_FAMILY_KEYS.find { |k| normalized.include?(k) }
34
41
  key && MODEL_FAMILIES[key]
35
42
  end
36
43
 
37
44
  # Per-token pricing for a model ID, matched by family. Returns
38
45
  # { input:, output:, cache_read:, cache_write: } or nil for unknown models.
39
- def self.pricing_for(model_id)
46
+ # Cache reads bill at 0.1x the base input rate; cache writes at 1.25x for the
47
+ # 5-minute cache and 2x for the 1-hour cache.
48
+ def self.pricing_for(model_id, cache_ttl: nil)
40
49
  family = model_family(model_id)
41
50
  return nil unless family
42
51
  input = family[:input] / 1_000_000
@@ -44,10 +53,32 @@ module RailsConsoleAi
44
53
  input: input,
45
54
  output: family[:output] / 1_000_000,
46
55
  cache_read: input * 0.1,
47
- cache_write: input * 1.25,
56
+ cache_write: input * (cache_ttl.to_s == '1h' ? 2.0 : 1.25),
48
57
  }
49
58
  end
50
59
 
60
+ # The four usage buckets each bill at their own rate. The API's `input_tokens`
61
+ # is the UNCACHED remainder — cached tokens are reported separately and are not
62
+ # part of it (total prompt size is input + cache_read + cache_write), so
63
+ # discounting cache_read out of input double-counts and can drive a cost
64
+ # negative. Every estimated cost readout goes through here. Providers that
65
+ # report real dollars (OpenRouter) are preferred over this estimate.
66
+ def self.estimate_cost(model, input:, output:, cache_read: 0, cache_write: 0, cache_ttl: nil)
67
+ pricing = pricing_for(model, cache_ttl: cache_ttl)
68
+ return nil unless pricing
69
+
70
+ ((input || 0) * pricing[:input]) +
71
+ ((output || 0) * pricing[:output]) +
72
+ ((cache_read || 0) * pricing[:cache_read]) +
73
+ ((cache_write || 0) * pricing[:cache_write])
74
+ end
75
+
76
+ # Total context window for a model ID, matched by family.
77
+ def self.context_window_for(model_id)
78
+ family = model_family(model_id)
79
+ (family && family[:context]) || DEFAULT_CONTEXT_WINDOW
80
+ end
81
+
51
82
  # Known environment-level failures the executor recognizes and explains to the
52
83
  # LLM on the FIRST occurrence, so it doesn't burn rounds rediscovering them
53
84
  # through trial and error. Each entry: { name:, pattern:, hint: }.
@@ -69,22 +100,25 @@ module RailsConsoleAi
69
100
 
70
101
  attr_accessor :provider, :api_key, :model, :thinking_model, :max_tokens,
71
102
  :auto_execute, :temperature,
72
- :timeout, :debug, :max_tool_rounds,
103
+ :timeout, :open_timeout, :max_retries, :debug, :max_tool_rounds,
73
104
  :error_hints,
74
105
  :token_nudge_threshold, :token_stop_threshold,
106
+ :cache_ttl,
75
107
  :storage_adapter, :memories_enabled,
76
108
  :session_logging, :connection_class,
77
109
  :admin_username, :admin_password,
78
110
  :authenticate,
79
111
  :slack_bot_token, :slack_app_token, :slack_channel_ids, :slack_allowed_usernames,
80
112
  :local_url, :local_model, :local_api_key,
113
+ :openrouter_url, :openrouter_app_name, :openrouter_site_url,
81
114
  :bedrock_region,
82
115
  :code_search_paths,
83
116
  :channels,
84
117
  :bypass_guards_for_methods,
85
118
  :user_extra_info,
86
119
  :sub_agent_max_rounds,
87
- :sub_agent_model
120
+ :sub_agent_model,
121
+ :line_editor
88
122
 
89
123
  def initialize
90
124
  @provider = :anthropic
@@ -94,12 +128,29 @@ module RailsConsoleAi
94
128
  @max_tokens = nil
95
129
  @auto_execute = false
96
130
  @temperature = 0.2
97
- @timeout = 30
131
+ # Read timeout for one provider request. Adaptive thinking plus a large output
132
+ # cap means a single agentic call can legitimately run for minutes; the old 30s
133
+ # cut those off mid-generation, which loses the turn and the tokens already
134
+ # spent on it, and sends the user back to re-ask from a cold cache.
135
+ @timeout = 300
136
+ @open_timeout = 10 # establishing the connection, not generating the response
137
+ @max_retries = 2 # transient failures only — see Providers::Base#with_retries
98
138
  @debug = false
99
139
  @max_tool_rounds = 200
100
140
  @error_hints = DEFAULT_ERROR_HINTS.dup
101
- @token_nudge_threshold = 500_000 # input tokens in one tool loop → nudge model to wrap up (nil disables)
102
- @token_stop_threshold = 1_000_000 # input tokens in one tool loop → force a final answer (nil disables)
141
+ # Measured against total prompt tokens sent in one tool loop — uncached input
142
+ # plus cache reads plus cache writes. Not the API's `input_tokens` alone:
143
+ # that is only the uncached remainder, so with caching on it stays near zero
144
+ # regardless of conversation size and neither guard would ever fire.
145
+ @token_nudge_threshold = 500_000 # prompt tokens in one tool loop → nudge model to wrap up (nil disables)
146
+ @token_stop_threshold = 1_000_000 # prompt tokens in one tool loop → force a final answer (nil disables)
147
+ # Prompt cache lifetime: nil/'5m' for the 5-minute default, '1h' for the
148
+ # 1-hour cache. Within a tool loop, rounds are seconds apart and 5m is
149
+ # strictly cheaper (a read refreshes the entry, and the write costs 1.25x
150
+ # vs 2x). '1h' pays off when a human sits between turns for more than five
151
+ # minutes — long interactive console sessions and Slack threads — because
152
+ # a miss there resends the whole conversation at full price.
153
+ @cache_ttl = nil
103
154
  @storage_adapter = nil
104
155
  @memories_enabled = true
105
156
  @session_logging = true
@@ -115,6 +166,9 @@ module RailsConsoleAi
115
166
  @local_url = 'http://localhost:11434'
116
167
  @local_model = 'qwen2.5:7b'
117
168
  @local_api_key = nil
169
+ @openrouter_url = nil
170
+ @openrouter_app_name = nil
171
+ @openrouter_site_url = nil
118
172
  @bedrock_region = nil
119
173
  @code_search_paths = %w[app]
120
174
  @channels = {}
@@ -122,6 +176,9 @@ module RailsConsoleAi
122
176
  @user_extra_info = {}
123
177
  @sub_agent_max_rounds = 15
124
178
  @sub_agent_model = nil
179
+ # Interactive line editor: :auto prefers Reline (live "/" completion menu)
180
+ # and falls back to Readline. Force one with :reline or :readline.
181
+ @line_editor = :auto
125
182
  end
126
183
 
127
184
  def resolve_user_extra_info(username)
@@ -208,6 +265,8 @@ module RailsConsoleAi
208
265
  ENV['ANTHROPIC_API_KEY']
209
266
  when :openai
210
267
  ENV['OPENAI_API_KEY']
268
+ when :openrouter
269
+ ENV['OPENROUTER_API_KEY']
211
270
  when :local
212
271
  @local_api_key || 'no-key'
213
272
  when :bedrock
@@ -223,6 +282,8 @@ module RailsConsoleAi
223
282
  'claude-sonnet-5'
224
283
  when :openai
225
284
  'gpt-5.3-codex'
285
+ when :openrouter
286
+ 'anthropic/claude-sonnet-5'
226
287
  when :local
227
288
  @local_model
228
289
  when :bedrock
@@ -234,7 +295,9 @@ module RailsConsoleAi
234
295
  return @max_tokens if @max_tokens
235
296
 
236
297
  family = self.class.model_family(resolved_model)
237
- family ? family[:max_tokens] : 4096
298
+ return family[:max_tokens] if family
299
+
300
+ @provider == :openrouter ? 16_000 : 4096
238
301
  end
239
302
 
240
303
  # Returns nil for model families that reject the `temperature` parameter
@@ -245,6 +308,13 @@ module RailsConsoleAi
245
308
  @temperature
246
309
  end
247
310
 
311
+ # Returns '1h' when the 1-hour prompt cache is requested, else nil (the
312
+ # 5-minute default). Providers that offer only one cache duration ignore it.
313
+ def resolved_cache_ttl
314
+ return nil unless @cache_ttl
315
+ @cache_ttl.to_s == '1h' ? '1h' : nil
316
+ end
317
+
248
318
  def resolved_thinking_model
249
319
  return @thinking_model if @thinking_model && !@thinking_model.empty?
250
320
 
@@ -253,6 +323,8 @@ module RailsConsoleAi
253
323
  'claude-opus-5'
254
324
  when :openai
255
325
  'gpt-5.3-codex'
326
+ when :openrouter
327
+ 'anthropic/claude-opus-5'
256
328
  when :local
257
329
  @local_model
258
330
  when :bedrock
@@ -264,6 +336,12 @@ module RailsConsoleAi
264
336
  @provider == :local ? [@timeout, 300].max : @timeout
265
337
  end
266
338
 
339
+ ENV_KEYS = {
340
+ anthropic: 'ANTHROPIC_API_KEY',
341
+ openai: 'OPENAI_API_KEY',
342
+ openrouter: 'OPENROUTER_API_KEY'
343
+ }.freeze
344
+
267
345
  def validate!
268
346
  unless PROVIDERS.include?(@provider)
269
347
  raise ConfigurationError, "Unknown provider: #{@provider}. Valid: #{PROVIDERS.join(', ')}"
@@ -280,7 +358,7 @@ module RailsConsoleAi
280
358
  end
281
359
  else
282
360
  unless resolved_api_key
283
- env_var = @provider == :anthropic ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY'
361
+ env_var = ENV_KEYS[@provider] || 'API_KEY'
284
362
  raise ConfigurationError, "No API key. Set config.api_key or #{env_var} env var."
285
363
  end
286
364
  end