rails_console_ai 0.35.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 37ef99b1cc4572e89283e5210b15e6110bac2b0adc758a1b7d222c603c4557c2
4
- data.tar.gz: 511f76003fdc00ce0104b506eaa71d7de918e3cf1f62eebd395308461caeefd1
3
+ metadata.gz: ae704dc4b0f74645775e86661d9cb6d004df5c1e0b338ab012564d87317bff4c
4
+ data.tar.gz: 0f0f9c24eb4c7aad94175bde55c36571df238d3b067bbe124a49d341f7df0e03
5
5
  SHA512:
6
- metadata.gz: e6cb2ccc4f03fbcafff6d7edcace1c80fd76a99692404f7e05aa039e9276665b07e03ab70b12043635b37742c5e05ffe1fa9fd42f77e25de6daff329c90e0dfc
7
- data.tar.gz: ee6acd8f33f2d13c921832fd1d4470e9d08bd8a795ec45d7ebb3c7b67ae7ca3fb6475b46796192dcd4a0b2a48d66b0bbb8bc2b2e47e498e9785c12dad7eefba4
6
+ metadata.gz: 77f9088650c56287f17f4ef4e498537fdb6ddfc2a62b558085aa0cd717f02ddaa44379d427416bcc5b4ac2a6826105d361b1e9a25caacb400f25f60f369c4d43
7
+ data.tar.gz: 03f1b9c35af746872ac7ec9b9a4cfb5e630bcf66b006cef254501efdef0b4d04187be161fd544ffc52248cee85fe45a1ec3e01fc62f371db5c3a453b60705971
data/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.36.0]
6
+
7
+ - Add an OpenRouter provider with access to 400+ models
8
+ - Report exact costs when a provider returns them
9
+ - Run any skill or agent as a slash command from the console
10
+ - Add a live completion menu for slash commands
11
+ - Support choosing between the Reline and Readline line editors
12
+ - Fix cost reporting in the Slack bot
13
+
5
14
  ## [0.35.0]
6
15
 
7
16
  - Extend prompt caching to the full conversation history
data/README.md CHANGED
@@ -88,6 +88,33 @@ end
88
88
  | `/system` | Show the system prompt |
89
89
  | `/name <label>` | Name the session for easy resume |
90
90
 
91
+ #### Running skills and agents from the prompt
92
+
93
+ Every skill and agent is also a slash command, under a slug built from its name —
94
+ "Restart user trial" becomes `/restart-user-trial`. Type `/` and a menu of everything
95
+ available appears as you type; press Enter on a bare `/` to print the full list.
96
+
97
+ ```
98
+ ai> /restart-user-trial user 4821
99
+ ai> /find-shard jess@example.com
100
+ ```
101
+
102
+ The two behave differently on purpose:
103
+
104
+ - **A skill runs here.** Its recipe is prepended to your request and its guard
105
+ bypasses are installed on the live executor, so the assistant follows the
106
+ procedure in the current conversation with full context.
107
+ - **An agent runs elsewhere.** It gets its own context and tool loop, and only its
108
+ summary comes back into the conversation — the same isolation `delegate_task`
109
+ gives the model, now available to you directly. Its tokens are billed to the session.
110
+
111
+ Anything after the command name is passed along as your request. A skill invoked
112
+ bare (`/restart-user-trial`) just runs its procedure.
113
+
114
+ Completion needs [Reline](https://github.com/ruby/reline), bundled with Ruby >= 2.7.
115
+ Without it the gem falls back to Readline, where Tab completes instead of a live menu.
116
+ Force one or the other with `c.line_editor = :reline` / `:readline` (default `:auto`).
117
+
91
118
  Prefix input with `>` to run Ruby directly (no LLM round-trip). The result is added to conversation context.
92
119
 
93
120
  Say "think harder" in any query to auto-upgrade to the thinking model for that session. After 5+ tool rounds, you'll also be prompted to switch.
@@ -282,7 +309,7 @@ Skills and global `bypass_guards_for_methods` coexist — use config-level bypas
282
309
 
283
310
  ## LLM Providers
284
311
 
285
- RailsConsoleAi supports four LLM providers. Each uses a two-tier model system: a default model for speed/cost, and a thinking model activated via `/think` or by saying "think harder".
312
+ RailsConsoleAi supports five LLM providers. Each uses a two-tier model system: a default model for speed/cost, and a thinking model activated via `/think` or by saying "think harder".
286
313
 
287
314
  ### Anthropic (default)
288
315
 
@@ -306,6 +333,29 @@ end
306
333
 
307
334
  Default model: `gpt-5.3-codex`. OpenAI applies prompt caching automatically on their end for prompts over 1024 tokens.
308
335
 
336
+ ### OpenRouter
337
+
338
+ Access 400+ models (Claude, GPT, Gemini, DeepSeek, Llama, etc.) via OpenRouter with unified OpenAI-compatible API. Automatic prompt caching for Anthropic models, real-time cost tracking, and provider sticky routing for cache hits.
339
+
340
+ ```ruby
341
+ RailsConsoleAi.configure do |config|
342
+ config.provider = :openrouter
343
+ config.api_key = 'sk-or-...' # or set OPENROUTER_API_KEY env var
344
+ # config.model = 'anthropic/claude-sonnet-5' # default
345
+ # config.openrouter_site_url = 'https://yoursite.com' # attribution
346
+ # config.openrouter_app_name = 'MyApp' # shown on openrouter.ai
347
+ end
348
+ ```
349
+
350
+ Default model: `anthropic/claude-sonnet-5`. Thinking model: `anthropic/claude-opus-5`.
351
+
352
+ **Features:**
353
+ - **Exact cost reporting**: The gem reports the actual USD cost from OpenRouter (`$0.0431` vs estimated `~$0.04`)
354
+ - **Automatic prompt caching**: Anthropic-family models (`anthropic/claude-*`) get a top-level `cache_control` breakpoint for multi-turn cache hits
355
+ - **Sticky routing**: Session-based routing keeps requests on the same provider endpoint for cache warmth across the tool-use loop
356
+
357
+ **Timeout note:** OpenRouter adds latency from request queueing and provider fallbacks. If you're routing to reasoning models, you may want to raise `config.timeout` above the 30s default.
358
+
309
359
  ### AWS Bedrock
310
360
 
311
361
  Access frontier models (Claude, Mistral, DeepSeek, Llama) via your AWS account with pay-per-token pricing. No API key needed — authentication uses the AWS SDK credential chain (IAM roles, env vars, `~/.aws/credentials`).
@@ -369,6 +419,9 @@ Before adopting a new Claude model, smoke-test it against the Anthropic or Bedro
369
419
  # Anthropic — provider inferred from the `claude-` prefix
370
420
  ANTHROPIC_API_KEY=sk-ant-... bin/smoke_model.rb --model claude-opus-4-8
371
421
 
422
+ # OpenRouter — provider inferred from the `provider/model` format
423
+ OPENROUTER_API_KEY=sk-or-... bin/smoke_model.rb --model anthropic/claude-opus-4
424
+
372
425
  # Bedrock — provider inferred from the regional `us.anthropic.` prefix.
373
426
  # Requires the aws-sdk-bedrockruntime gem and AWS credentials in the environment.
374
427
  bin/smoke_model.rb --model us.anthropic.claude-opus-4-8
@@ -391,13 +444,14 @@ Pricing, default max tokens, and parameter support (e.g. which families reject `
391
444
 
392
445
  ```ruby
393
446
  RailsConsoleAi.configure do |config|
394
- config.provider = :anthropic # :anthropic, :openai, :bedrock, :local
447
+ config.provider = :anthropic # :anthropic, :openai, :openrouter, :bedrock, :local
395
448
  config.auto_execute = false # true to skip confirmations
396
449
  config.session_logging = true # requires ai_db_setup
397
450
  config.temperature = 0.2
398
451
  config.timeout = 30 # HTTP timeout in seconds
399
452
  config.max_tool_rounds = 200 # safety cap on tool-use loops
400
453
  config.code_search_paths = %w[app] # directories for list_files / search_code
454
+ config.line_editor = :auto # :auto, :reline (live "/" menu), :readline
401
455
 
402
456
  # Runaway-loop circuit breakers (see "Runaway sessions" below)
403
457
  config.token_nudge_threshold = 500_000 # input tokens in one tool loop → nudge model to wrap up (nil disables)
@@ -1,17 +1,17 @@
1
1
  module RailsConsoleAi
2
2
  module SessionsHelper
3
- # All four usage buckets bill at their own rate. `input_tokens` is the uncached
4
- # remainder only on a cached session it is a tiny fraction of the prompt, so
5
- # pricing input+output alone reads as near-zero for the sessions that actually
6
- # cost the most.
3
+ # The cache buckets have to be included: `input_tokens` is the uncached
4
+ # remainder only, so on a well-cached session pricing input+output alone reads
5
+ # as near-zero for exactly the sessions that cost the most.
7
6
  def estimated_cost(session)
8
- pricing = Configuration.pricing_for(session.model)
9
- return nil unless pricing
10
-
11
- (session.input_tokens * pricing[:input]) +
12
- (session.output_tokens * pricing[:output]) +
13
- (session.try(:cache_read_tokens).to_i * pricing[:cache_read]) +
14
- (session.try(:cache_write_tokens).to_i * pricing[:cache_write])
7
+ Configuration.estimate_cost(
8
+ session.model,
9
+ input: session.input_tokens,
10
+ output: session.output_tokens,
11
+ cache_read: session.try(:cache_read_tokens).to_i,
12
+ cache_write: session.try(:cache_write_tokens).to_i,
13
+ cache_ttl: RailsConsoleAi.configuration.resolved_cache_ttl
14
+ )
15
15
  end
16
16
 
17
17
  def format_cost(session)
@@ -1,5 +1,5 @@
1
1
  RailsConsoleAi.configure do |config|
2
- # LLM provider: :anthropic, :openai, or :local
2
+ # LLM provider: :anthropic, :openai, :openrouter, :bedrock, or :local
3
3
  config.provider = :anthropic
4
4
 
5
5
  # API key (or set ANTHROPIC_API_KEY / OPENAI_API_KEY env var)
@@ -32,6 +32,12 @@ RailsConsoleAi.configure do |config|
32
32
  # config.local_model = 'qwen2.5:7b'
33
33
  # config.local_api_key = nil
34
34
 
35
+ # OpenRouter provider (400+ models via unified API):
36
+ # config.provider = :openrouter
37
+ # config.model = 'anthropic/claude-sonnet-5'
38
+ # config.openrouter_site_url = 'https://yoursite.com' # optional, for attribution
39
+ # config.openrouter_app_name = 'MyApp' # optional, shown on openrouter.ai
40
+
35
41
  # Slack: which users the bot responds to (legacy — prefer channels config below)
36
42
  # config.slack_allowed_usernames = ['alice', 'bob']
37
43
 
@@ -1,5 +1,6 @@
1
- require 'readline'
2
1
  require 'rails_console_ai/channel/base'
2
+ require 'rails_console_ai/line_editor'
3
+ require 'rails_console_ai/slash_commands'
3
4
 
4
5
  module RailsConsoleAi
5
6
  module Channel
@@ -186,6 +187,16 @@ module RailsConsoleAi
186
187
  @interactive_console_capture = StringIO.new
187
188
  @real_stdout = $stdout
188
189
  $stdout = TeeIO.new(@real_stdout, @interactive_console_capture)
190
+ init_slash_commands
191
+ end
192
+
193
+ def init_slash_commands
194
+ @slash_commands = SlashCommands.new
195
+ # Point the editor at the real stdout: Reline draws its completion menu
196
+ # through a Ruby IO, and $stdout is a TeeIO right now — without this the
197
+ # menu's escape codes get written into the saved session log.
198
+ @editor = LineEditor.resolve(RailsConsoleAi.configuration.line_editor, output: @real_stdout)
199
+ @editor.complete_with { @slash_commands.completion_entries }
189
200
  end
190
201
 
191
202
  def run_interactive_loop
@@ -196,14 +207,13 @@ module RailsConsoleAi
196
207
  config = RailsConsoleAi.configuration
197
208
  @real_stdout.puts "\e[2m Provider: #{config.provider} | Model: #{config.resolved_model}\e[0m"
198
209
  safe_info = guards.empty? ? '' : " | Safe mode: #{guards.enabled? ? 'ON' : 'OFF'} (/danger to toggle)"
199
- @real_stdout.puts "\e[2m Auto-execute: #{auto ? 'ON' : 'OFF'} (Shift-Tab or /auto to toggle)#{safe_info} | > code | /usage | /cost | /compact | /think | /name <label>\e[0m"
210
+ @real_stdout.puts "\e[2m Auto-execute: #{auto ? 'ON' : 'OFF'} (Shift-Tab or /auto to toggle)#{safe_info} | > code\e[0m"
211
+ @real_stdout.puts "\e[2m Type / for commands, skills and agents#{@editor.is_a?(LineEditor::Reline_) ? ' (menu appears as you type)' : ' (Tab to complete)'}\e[0m"
200
212
 
201
- if Readline.respond_to?(:parse_and_bind)
202
- Readline.parse_and_bind('"\e[Z": "\C-a\C-k/auto\C-m"')
203
- end
213
+ @editor.bind_auto_toggle
204
214
 
205
215
  loop do
206
- input = Readline.readline("\001\e[33m\002ai> \001\e[0m\002", false)
216
+ input = @editor.readline(@editor.prompt('ai> ', "\e[33m"))
207
217
  break if input.nil?
208
218
 
209
219
  input = input.strip
@@ -211,8 +221,13 @@ module RailsConsoleAi
211
221
  break if input.downcase == 'exit' || input.downcase == 'quit'
212
222
  next if input.empty?
213
223
 
214
- handled = handle_slash_command(input)
215
- next if handled
224
+ # What the user typed, kept for history/logging even when a skill
225
+ # command expands into a much longer prompt.
226
+ typed = input
227
+
228
+ outcome = handle_slash_command(input)
229
+ next if outcome == :handled
230
+ input = outcome if outcome.is_a?(String)
216
231
 
217
232
  # Direct code execution with ">" prefix
218
233
  if input.start_with?('>') && !input.start_with?('>=')
@@ -220,14 +235,13 @@ module RailsConsoleAi
220
235
  next
221
236
  end
222
237
 
223
- # Add to Readline history
224
- Readline::HISTORY.push(input) unless input == Readline::HISTORY.to_a.last
238
+ @editor.push_history(typed)
225
239
 
226
240
  @engine.maybe_auto_upgrade_thinking(input)
227
241
 
228
- @engine.set_interactive_query(input)
242
+ @engine.set_interactive_query(typed)
229
243
  @engine.add_user_message(input)
230
- @interactive_console_capture.write("ai> #{input}\n")
244
+ @interactive_console_capture.write("ai> #{typed}\n")
231
245
  @engine.log_interactive_turn
232
246
 
233
247
  expected_stdout = $stdout
@@ -254,6 +268,9 @@ module RailsConsoleAi
254
268
 
255
269
  @engine.log_interactive_turn
256
270
  @engine.warn_if_history_large
271
+ # A turn may have created a skill or agent via save_skill/save_agent —
272
+ # drop the memo so the next "/" reflects it.
273
+ @slash_commands.refresh!
257
274
  end
258
275
 
259
276
  $stdout = @real_stdout
@@ -269,6 +286,9 @@ module RailsConsoleAi
269
286
  $stderr.puts "\e[31mRailsConsoleAi Error: #{e.class}: #{e.message}\e[0m"
270
287
  end
271
288
 
289
+ # Returns :handled when the command did its own thing, a String when it
290
+ # should be sent to the model as this turn's message, or nil when the input
291
+ # isn't a slash command at all.
272
292
  def handle_slash_command(input)
273
293
  case input
274
294
  when '?', '/'
@@ -314,9 +334,47 @@ module RailsConsoleAi
314
334
  when /\A\/name/
315
335
  handle_name_command(input)
316
336
  else
317
- return false
337
+ return dispatch_registry_command(input)
338
+ end
339
+ :handled
340
+ end
341
+
342
+ # Skills and agents are addressed by a slug derived from their name:
343
+ # "Restart user trial" -> /restart-user-trial.
344
+ def dispatch_registry_command(input)
345
+ return nil unless input.start_with?('/')
346
+
347
+ slug, _, args = input[1..].partition(/\s+/)
348
+ command = @slash_commands.find(slug)
349
+
350
+ unless command
351
+ # Only claim the input if it actually looks like a command. A message
352
+ # that merely opens with a path ("/tmp/foo.log has the error") must
353
+ # still reach the model untouched.
354
+ return nil unless slug =~ /\A[a-z0-9][a-z0-9_-]*\z/
355
+ warn_unknown_command(slug)
356
+ return :handled
357
+ end
358
+
359
+ case command.kind
360
+ when :skill
361
+ @engine.skill_command_prompt(command.record, args)
362
+ when :agent
363
+ @engine.run_agent_command(command.record, args)
364
+ :handled
365
+ else
366
+ nil # a built-in, already handled by the case above
367
+ end
368
+ end
369
+
370
+ def warn_unknown_command(slug)
371
+ @real_stdout.puts "\e[33m Unknown command: /#{slug}\e[0m"
372
+ near = @slash_commands.candidates.select { |c| c.start_with?("/#{slug[0, 3]}") }
373
+ if near.empty?
374
+ @real_stdout.puts "\e[2m Type / to see everything available.\e[0m"
375
+ else
376
+ @real_stdout.puts "\e[2m Did you mean: #{near.first(5).join(', ')}?\e[0m"
318
377
  end
319
- true
320
378
  end
321
379
 
322
380
  def retry_last_code
@@ -325,7 +383,7 @@ module RailsConsoleAi
325
383
 
326
384
  def handle_direct_execution(input)
327
385
  raw_code = input.sub(/\A>\s?/, '')
328
- Readline::HISTORY.push(input) unless input == Readline::HISTORY.to_a.last
386
+ @editor.push_history(input)
329
387
  @interactive_console_capture.write("ai> #{input}\n")
330
388
  @engine.execute_direct(raw_code)
331
389
  @engine.log_interactive_turn
@@ -383,6 +441,7 @@ module RailsConsoleAi
383
441
  end
384
442
  @real_stdout.puts "\e[2m Bedrock region: #{config.bedrock_region}\e[0m" if config.provider == :bedrock
385
443
  @real_stdout.puts "\e[2m Local URL: #{config.local_url}\e[0m" if config.provider == :local
444
+ @real_stdout.puts "\e[2m OpenRouter URL: #{config.openrouter_url}\e[0m" if config.openrouter_url
386
445
  end
387
446
 
388
447
  def handle_name_command(input)
@@ -424,6 +483,52 @@ module RailsConsoleAi
424
483
  @real_stdout.puts "\e[2m > code Execute Ruby directly (skip LLM)\e[0m"
425
484
  @real_stdout.puts "\e[2m Ctrl-C Cancel the current operation\e[0m"
426
485
  @real_stdout.puts "\e[2m exit/quit Leave interactive mode\e[0m"
486
+ display_registry_help
487
+ end
488
+
489
+ # Skills and agents, listed the same way as the built-ins so "/" answers
490
+ # "what can I run?" in one place.
491
+ def display_registry_help
492
+ @slash_commands.refresh!
493
+
494
+ skills = @slash_commands.skills
495
+ agents = @slash_commands.agents
496
+ # Size the column to what's actually listed, and don't let one very long
497
+ # name push every description off the right edge.
498
+ width = [(skills + agents).map { |c| c.slug.length }.max.to_i + 2, 34].min
499
+
500
+ unless skills.empty?
501
+ @real_stdout.puts
502
+ @real_stdout.puts "\e[36m Skills:\e[0m \e[2m(runs here, following the recipe)\e[0m"
503
+ skills.each { |c| @real_stdout.puts registry_help_row(c, width) }
504
+ end
505
+
506
+ unless agents.empty?
507
+ @real_stdout.puts
508
+ @real_stdout.puts "\e[36m Agents:\e[0m \e[2m(runs in a separate context, reports back)\e[0m"
509
+ agents.each { |c| @real_stdout.puts registry_help_row(c, width) }
510
+ end
511
+
512
+ return if skills.empty? && agents.empty?
513
+ @real_stdout.puts
514
+ @real_stdout.puts "\e[2m Pass a request after the name: /#{(skills.first || agents.first).slug} <what you want>\e[0m"
515
+ end
516
+
517
+ # One "/slug description" line, clipped so a long description wraps into
518
+ # the next row instead of making the list unreadable.
519
+ def registry_help_row(command, width)
520
+ room = terminal_width - width - 6
521
+ desc = command.description.to_s
522
+ desc = "#{desc[0, room - 1]}\u2026" if room > 10 && desc.length > room
523
+ "\e[2m /#{command.slug.ljust(width)} #{desc}\e[0m"
524
+ end
525
+
526
+ def terminal_width
527
+ require 'io/console'
528
+ cols = IO.console && IO.console.winsize.last.to_i
529
+ cols && cols > 40 ? cols : 100
530
+ rescue StandardError
531
+ 100
427
532
  end
428
533
 
429
534
  def display_exit_info
@@ -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
@@ -36,7 +36,8 @@ module RailsConsoleAi
36
36
  # Returns the family attributes for a model ID, or nil for unknown models.
37
37
  def self.model_family(model_id)
38
38
  return nil unless model_id
39
- 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) }
40
41
  key && MODEL_FAMILIES[key]
41
42
  end
42
43
 
@@ -56,6 +57,22 @@ module RailsConsoleAi
56
57
  }
57
58
  end
58
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
+
59
76
  # Total context window for a model ID, matched by family.
60
77
  def self.context_window_for(model_id)
61
78
  family = model_family(model_id)
@@ -93,13 +110,15 @@ module RailsConsoleAi
93
110
  :authenticate,
94
111
  :slack_bot_token, :slack_app_token, :slack_channel_ids, :slack_allowed_usernames,
95
112
  :local_url, :local_model, :local_api_key,
113
+ :openrouter_url, :openrouter_app_name, :openrouter_site_url,
96
114
  :bedrock_region,
97
115
  :code_search_paths,
98
116
  :channels,
99
117
  :bypass_guards_for_methods,
100
118
  :user_extra_info,
101
119
  :sub_agent_max_rounds,
102
- :sub_agent_model
120
+ :sub_agent_model,
121
+ :line_editor
103
122
 
104
123
  def initialize
105
124
  @provider = :anthropic
@@ -147,6 +166,9 @@ module RailsConsoleAi
147
166
  @local_url = 'http://localhost:11434'
148
167
  @local_model = 'qwen2.5:7b'
149
168
  @local_api_key = nil
169
+ @openrouter_url = nil
170
+ @openrouter_app_name = nil
171
+ @openrouter_site_url = nil
150
172
  @bedrock_region = nil
151
173
  @code_search_paths = %w[app]
152
174
  @channels = {}
@@ -154,6 +176,9 @@ module RailsConsoleAi
154
176
  @user_extra_info = {}
155
177
  @sub_agent_max_rounds = 15
156
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
157
182
  end
158
183
 
159
184
  def resolve_user_extra_info(username)
@@ -240,6 +265,8 @@ module RailsConsoleAi
240
265
  ENV['ANTHROPIC_API_KEY']
241
266
  when :openai
242
267
  ENV['OPENAI_API_KEY']
268
+ when :openrouter
269
+ ENV['OPENROUTER_API_KEY']
243
270
  when :local
244
271
  @local_api_key || 'no-key'
245
272
  when :bedrock
@@ -255,6 +282,8 @@ module RailsConsoleAi
255
282
  'claude-sonnet-5'
256
283
  when :openai
257
284
  'gpt-5.3-codex'
285
+ when :openrouter
286
+ 'anthropic/claude-sonnet-5'
258
287
  when :local
259
288
  @local_model
260
289
  when :bedrock
@@ -266,7 +295,9 @@ module RailsConsoleAi
266
295
  return @max_tokens if @max_tokens
267
296
 
268
297
  family = self.class.model_family(resolved_model)
269
- family ? family[:max_tokens] : 4096
298
+ return family[:max_tokens] if family
299
+
300
+ @provider == :openrouter ? 16_000 : 4096
270
301
  end
271
302
 
272
303
  # Returns nil for model families that reject the `temperature` parameter
@@ -292,6 +323,8 @@ module RailsConsoleAi
292
323
  'claude-opus-5'
293
324
  when :openai
294
325
  'gpt-5.3-codex'
326
+ when :openrouter
327
+ 'anthropic/claude-opus-5'
295
328
  when :local
296
329
  @local_model
297
330
  when :bedrock
@@ -303,6 +336,12 @@ module RailsConsoleAi
303
336
  @provider == :local ? [@timeout, 300].max : @timeout
304
337
  end
305
338
 
339
+ ENV_KEYS = {
340
+ anthropic: 'ANTHROPIC_API_KEY',
341
+ openai: 'OPENAI_API_KEY',
342
+ openrouter: 'OPENROUTER_API_KEY'
343
+ }.freeze
344
+
306
345
  def validate!
307
346
  unless PROVIDERS.include?(@provider)
308
347
  raise ConfigurationError, "Unknown provider: #{@provider}. Valid: #{PROVIDERS.join(', ')}"
@@ -319,7 +358,7 @@ module RailsConsoleAi
319
358
  end
320
359
  else
321
360
  unless resolved_api_key
322
- env_var = @provider == :anthropic ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY'
361
+ env_var = ENV_KEYS[@provider] || 'API_KEY'
323
362
  raise ConfigurationError, "No API key. Set config.api_key or #{env_var} env var."
324
363
  end
325
364
  end