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 +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +56 -2
- data/app/helpers/rails_console_ai/sessions_helper.rb +11 -11
- data/lib/generators/rails_console_ai/templates/initializer.rb +7 -1
- data/lib/rails_console_ai/channel/console.rb +120 -15
- data/lib/rails_console_ai/configuration.rb +44 -5
- data/lib/rails_console_ai/conversation_engine.rb +135 -35
- data/lib/rails_console_ai/line_editor.rb +142 -0
- data/lib/rails_console_ai/providers/base.rb +6 -1
- data/lib/rails_console_ai/providers/local.rb +16 -36
- data/lib/rails_console_ai/providers/openai.rb +30 -9
- data/lib/rails_console_ai/providers/openrouter.rb +100 -0
- data/lib/rails_console_ai/slack_bot.rb +18 -11
- data/lib/rails_console_ai/slash_commands.rb +149 -0
- data/lib/rails_console_ai/sub_agent.rb +7 -2
- data/lib/rails_console_ai/tools/registry.rb +2 -2
- data/lib/rails_console_ai/version.rb +1 -1
- data/lib/rails_console_ai.rb +1 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ae704dc4b0f74645775e86661d9cb6d004df5c1e0b338ab012564d87317bff4c
|
|
4
|
+
data.tar.gz: 0f0f9c24eb4c7aad94175bde55c36571df238d3b067bbe124a49d341f7df0e03
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
#
|
|
4
|
-
# remainder only
|
|
5
|
-
#
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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(
|
|
242
|
+
@engine.set_interactive_query(typed)
|
|
229
243
|
@engine.add_user_message(input)
|
|
230
|
-
@interactive_console_capture.write("ai> #{
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|