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
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require 'rails_console_ai/slash_commands'
|
|
2
|
+
|
|
1
3
|
module RailsConsoleAi
|
|
2
4
|
class ConversationEngine
|
|
3
5
|
attr_reader :history, :total_input_tokens, :total_output_tokens,
|
|
@@ -19,6 +21,7 @@ module RailsConsoleAi
|
|
|
19
21
|
@slack_channel_name = slack_channel_name
|
|
20
22
|
@executor = Executor.new(binding_context, channel: channel)
|
|
21
23
|
@provider = nil
|
|
24
|
+
@routing_session_id = SecureRandom.hex(8)
|
|
22
25
|
@context_builder = nil
|
|
23
26
|
@context = nil
|
|
24
27
|
@history = []
|
|
@@ -255,6 +258,80 @@ module RailsConsoleAi
|
|
|
255
258
|
execute_direct(code)
|
|
256
259
|
end
|
|
257
260
|
|
|
261
|
+
# --- Slash-command invocation of skills and agents ---
|
|
262
|
+
|
|
263
|
+
# A skill is a recipe for *this* assistant, and its guard bypasses have to
|
|
264
|
+
# land on the live executor — so it runs as a normal turn with the recipe
|
|
265
|
+
# prepended, not in a sub-agent. Returns the user-turn text to send.
|
|
266
|
+
def skill_command_prompt(skill, request)
|
|
267
|
+
bypass_methods = Array(skill['bypass_guards_for_methods'])
|
|
268
|
+
@executor.activate_skill_bypasses(bypass_methods) unless bypass_methods.empty?
|
|
269
|
+
|
|
270
|
+
if skill['source'] == :db && skill['id']
|
|
271
|
+
RailsConsoleAi::Skill.record_use!(skill['id'])
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
@channel.display_status(" Activated skill: #{skill['name']}")
|
|
275
|
+
|
|
276
|
+
request = request.to_s.strip
|
|
277
|
+
task = request.empty? ? "Follow this skill now." : request
|
|
278
|
+
|
|
279
|
+
"The user invoked the \"#{skill['name']}\" skill. Follow its procedure.\n\n" \
|
|
280
|
+
"--- SKILL: #{skill['name']} ---\n#{skill['body']}\n--- END SKILL ---\n\n" \
|
|
281
|
+
"Their request: #{task}"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# An agent is a separate context by definition, so this mirrors delegate_task:
|
|
285
|
+
# run it, then fold only its summary back into the conversation.
|
|
286
|
+
def run_agent_command(agent, task)
|
|
287
|
+
require 'rails_console_ai/sub_agent'
|
|
288
|
+
|
|
289
|
+
task = task.to_s.strip
|
|
290
|
+
if task.empty?
|
|
291
|
+
@channel.display_warning(" /#{SlashCommands.slugify(agent['name'])} needs a task. Try: /#{SlashCommands.slugify(agent['name'])} <what to investigate>")
|
|
292
|
+
return
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
@interactive_query ||= "/#{SlashCommands.slugify(agent['name'])} #{task}"
|
|
296
|
+
log_interactive_turn(status: 'running')
|
|
297
|
+
|
|
298
|
+
if agent['source'] == :db && agent['id']
|
|
299
|
+
RailsConsoleAi::Agent.record_use!(agent['id'])
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
sub = SubAgent.new(
|
|
303
|
+
task: task,
|
|
304
|
+
agent_config: agent,
|
|
305
|
+
binding_context: @executor.binding_context,
|
|
306
|
+
parent_channel: @channel,
|
|
307
|
+
executor: @executor
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
begin
|
|
311
|
+
# Deliberately NOT wrapped in @channel.wrap_llm_call. That wrapper puts the
|
|
312
|
+
# terminal in raw mode to watch for Esc, which clears ONLCR — so anything
|
|
313
|
+
# printed inside it staircases down the screen. Everywhere else it wraps
|
|
314
|
+
# only the provider HTTP call, where nothing prints; a sub-agent run prints
|
|
315
|
+
# throughout. Ctrl-C still lands here as an Interrupt.
|
|
316
|
+
result = sub.run
|
|
317
|
+
rescue Interrupt
|
|
318
|
+
@channel.display_warning(" Cancelled.")
|
|
319
|
+
log_interactive_turn(status: 'ready')
|
|
320
|
+
return
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
record_sub_agent_usage(sub)
|
|
324
|
+
|
|
325
|
+
@history << {
|
|
326
|
+
role: :user,
|
|
327
|
+
content: "The user ran the \"#{agent['name']}\" agent with the task: #{task}\n\n" \
|
|
328
|
+
"The agent reported back:\n#{result}"
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
@channel.display(result.to_s)
|
|
332
|
+
log_interactive_turn(status: 'ready')
|
|
333
|
+
end
|
|
334
|
+
|
|
258
335
|
def execute_direct(raw_code)
|
|
259
336
|
@interactive_query ||= "> #{raw_code}"
|
|
260
337
|
log_interactive_turn(status: 'running')
|
|
@@ -424,6 +501,7 @@ module RailsConsoleAi
|
|
|
424
501
|
end
|
|
425
502
|
|
|
426
503
|
total_cost = 0.0
|
|
504
|
+
has_reported_cost = false
|
|
427
505
|
$stdout.puts "\e[36m Cost estimate:\e[0m"
|
|
428
506
|
|
|
429
507
|
@token_usage.each do |model, usage|
|
|
@@ -432,13 +510,25 @@ module RailsConsoleAi
|
|
|
432
510
|
input_str = "in: #{format_tokens(usage[:input])}"
|
|
433
511
|
output_str = "out: #{format_tokens(usage[:output])}"
|
|
434
512
|
|
|
435
|
-
|
|
513
|
+
# A provider that reports real dollars (OpenRouter) beats any estimate.
|
|
514
|
+
reported_cost = usage[:cost]
|
|
515
|
+
if reported_cost && reported_cost > 0
|
|
516
|
+
has_reported_cost = true
|
|
517
|
+
total_cost += reported_cost
|
|
518
|
+
cache_str = ""
|
|
436
519
|
cache_read = usage[:cache_read] || 0
|
|
437
520
|
cache_write = usage[:cache_write] || 0
|
|
438
|
-
|
|
439
|
-
|
|
521
|
+
cache_str = " cache r: #{format_tokens(cache_read)} w: #{format_tokens(cache_write)}" if cache_read > 0 || cache_write > 0
|
|
522
|
+
$stdout.puts "\e[2m #{model}: #{input_str} #{output_str}#{cache_str} $#{'%.4f' % reported_cost}\e[0m"
|
|
523
|
+
elsif pricing
|
|
524
|
+
cost = Configuration.estimate_cost(model,
|
|
525
|
+
input: usage[:input], output: usage[:output],
|
|
526
|
+
cache_read: usage[:cache_read] || 0, cache_write: usage[:cache_write] || 0,
|
|
527
|
+
cache_ttl: RailsConsoleAi.configuration.resolved_cache_ttl)
|
|
440
528
|
total_cost += cost
|
|
441
529
|
cache_str = ""
|
|
530
|
+
cache_read = usage[:cache_read] || 0
|
|
531
|
+
cache_write = usage[:cache_write] || 0
|
|
442
532
|
cache_str = " cache r: #{format_tokens(cache_read)} w: #{format_tokens(cache_write)}" if cache_read > 0 || cache_write > 0
|
|
443
533
|
$stdout.puts "\e[2m #{model}: #{input_str} #{output_str}#{cache_str} ~$#{'%.2f' % cost}\e[0m"
|
|
444
534
|
else
|
|
@@ -446,7 +536,8 @@ module RailsConsoleAi
|
|
|
446
536
|
end
|
|
447
537
|
end
|
|
448
538
|
|
|
449
|
-
|
|
539
|
+
label = has_reported_cost ? "Total:" : "Total: ~"
|
|
540
|
+
$stdout.puts "\e[36m #{label}$#{'%.2f' % total_cost}\e[0m"
|
|
450
541
|
end
|
|
451
542
|
|
|
452
543
|
def display_conversation
|
|
@@ -695,6 +786,19 @@ module RailsConsoleAi
|
|
|
695
786
|
|
|
696
787
|
private
|
|
697
788
|
|
|
789
|
+
def record_sub_agent_usage(sub)
|
|
790
|
+
absorb_sub_agent_usage(input: sub.input_tokens, output: sub.output_tokens, model: sub.model_used, cost: sub.cost)
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
def absorb_sub_agent_usage(usage)
|
|
794
|
+
@total_input_tokens += usage[:input] || 0
|
|
795
|
+
@total_output_tokens += usage[:output] || 0
|
|
796
|
+
return unless usage[:model]
|
|
797
|
+
@token_usage[usage[:model]][:input] += usage[:input] || 0
|
|
798
|
+
@token_usage[usage[:model]][:output] += usage[:output] || 0
|
|
799
|
+
@token_usage[usage[:model]][:cost] = (@token_usage[usage[:model]][:cost] || 0) + usage[:cost] if usage[:cost]
|
|
800
|
+
end
|
|
801
|
+
|
|
698
802
|
def safety_context
|
|
699
803
|
guards = RailsConsoleAi.configuration.safety_guards
|
|
700
804
|
return nil if guards.empty?
|
|
@@ -750,13 +854,15 @@ module RailsConsoleAi
|
|
|
750
854
|
|
|
751
855
|
def provider
|
|
752
856
|
@provider ||= begin
|
|
753
|
-
if @model_override
|
|
857
|
+
p = if @model_override
|
|
754
858
|
config = RailsConsoleAi.configuration.dup
|
|
755
859
|
config.model = @model_override
|
|
756
860
|
Providers.build(config)
|
|
757
861
|
else
|
|
758
862
|
Providers.build
|
|
759
863
|
end
|
|
864
|
+
p.routing_session_id = @routing_session_id if p.respond_to?(:routing_session_id=)
|
|
865
|
+
p
|
|
760
866
|
end
|
|
761
867
|
end
|
|
762
868
|
|
|
@@ -994,13 +1100,7 @@ module RailsConsoleAi
|
|
|
994
1100
|
|
|
995
1101
|
# Aggregate sub-agent token usage into parent's cost tracking
|
|
996
1102
|
if tc[:name] == 'delegate_task' && tools.last_sub_agent_usage
|
|
997
|
-
|
|
998
|
-
@total_input_tokens += sa[:input] || 0
|
|
999
|
-
@total_output_tokens += sa[:output] || 0
|
|
1000
|
-
if sa[:model]
|
|
1001
|
-
@token_usage[sa[:model]][:input] += sa[:input] || 0
|
|
1002
|
-
@token_usage[sa[:model]][:output] += sa[:output] || 0
|
|
1003
|
-
end
|
|
1103
|
+
absorb_sub_agent_usage(tools.last_sub_agent_usage)
|
|
1004
1104
|
end
|
|
1005
1105
|
|
|
1006
1106
|
if RailsConsoleAi.configuration.debug
|
|
@@ -1134,6 +1234,7 @@ module RailsConsoleAi
|
|
|
1134
1234
|
@token_usage[model][:output] += result.output_tokens || 0
|
|
1135
1235
|
@token_usage[model][:cache_read] = (@token_usage[model][:cache_read] || 0) + (result.cache_read_input_tokens || 0)
|
|
1136
1236
|
@token_usage[model][:cache_write] = (@token_usage[model][:cache_write] || 0) + (result.cache_write_input_tokens || 0)
|
|
1237
|
+
@token_usage[model][:cost] = (@token_usage[model][:cost] || 0) + (result.cost || 0) if result.cost
|
|
1137
1238
|
end
|
|
1138
1239
|
|
|
1139
1240
|
def display_usage(result, show_session: false)
|
|
@@ -1196,18 +1297,6 @@ module RailsConsoleAi
|
|
|
1196
1297
|
chars / 4
|
|
1197
1298
|
end
|
|
1198
1299
|
|
|
1199
|
-
# The four usage buckets each bill at their own rate. `input_tokens` from the
|
|
1200
|
-
# API is the UNCACHED remainder — cached tokens are reported separately and are
|
|
1201
|
-
# not part of it (total prompt size is input + cache_read + cache_write), so
|
|
1202
|
-
# discounting cache_read out of input double-counts and can drive a cost
|
|
1203
|
-
# negative. Every cost readout goes through here.
|
|
1204
|
-
def usage_cost(pricing, input:, output:, cache_read: 0, cache_write: 0)
|
|
1205
|
-
((input || 0) * pricing[:input]) +
|
|
1206
|
-
((output || 0) * pricing[:output]) +
|
|
1207
|
-
((cache_read || 0) * (pricing[:cache_read] || 0)) +
|
|
1208
|
-
((cache_write || 0) * (pricing[:cache_write] || 0))
|
|
1209
|
-
end
|
|
1210
|
-
|
|
1211
1300
|
def format_tokens(count)
|
|
1212
1301
|
if count >= 1_000_000
|
|
1213
1302
|
"#{(count / 1_000_000.0).round(1)}M"
|
|
@@ -1559,13 +1648,17 @@ module RailsConsoleAi
|
|
|
1559
1648
|
cache_r = result.cache_read_input_tokens || 0
|
|
1560
1649
|
cache_w = result.cache_write_input_tokens || 0
|
|
1561
1650
|
parts << "cache r: #{format_tokens(cache_r)} w: #{format_tokens(cache_w)}" if cache_r > 0 || cache_w > 0
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1651
|
+
|
|
1652
|
+
if result.cost
|
|
1653
|
+
parts << "$#{'%.4f' % result.cost}"
|
|
1654
|
+
else
|
|
1655
|
+
cost = Configuration.estimate_cost(effective_model,
|
|
1656
|
+
input: result.input_tokens || 0, output: result.output_tokens || 0,
|
|
1657
|
+
cache_read: cache_r, cache_write: cache_w,
|
|
1658
|
+
cache_ttl: RailsConsoleAi.configuration.resolved_cache_ttl)
|
|
1659
|
+
parts << "~$#{'%.4f' % cost}" if cost
|
|
1568
1660
|
end
|
|
1661
|
+
|
|
1569
1662
|
parts.join(' | ')
|
|
1570
1663
|
end
|
|
1571
1664
|
|
|
@@ -1597,11 +1690,18 @@ module RailsConsoleAi
|
|
|
1597
1690
|
parts = ["in: #{format_tokens(input_t)}", "out: #{format_tokens(output_t)}"]
|
|
1598
1691
|
parts << "cache r: #{format_tokens(cache_r)} w: #{format_tokens(cache_w)}" if cache_r > 0 || cache_w > 0
|
|
1599
1692
|
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1693
|
+
ttl = RailsConsoleAi.configuration.resolved_cache_ttl
|
|
1694
|
+
if result.cost
|
|
1695
|
+
parts << "$#{'%.4f' % result.cost}"
|
|
1696
|
+
session_has_reported = @token_usage.any? { |_, u| u[:cost] && u[:cost] > 0 }
|
|
1697
|
+
session_cost = session_has_reported ? @token_usage.sum { |_, u| u[:cost] || 0 } :
|
|
1698
|
+
Configuration.estimate_cost(model, input: total_input, output: total_output, cache_ttl: ttl) || 0
|
|
1699
|
+
$stderr.puts "\n#{d}[debug] ← response: #{parts.join(' | ')} (session: $#{'%.4f' % session_cost})#{r}"
|
|
1700
|
+
elsif pricing
|
|
1701
|
+
cost = Configuration.estimate_cost(model, input: input_t, output: output_t,
|
|
1702
|
+
cache_read: cache_r, cache_write: cache_w, cache_ttl: ttl)
|
|
1703
|
+
parts << "~$#{'%.4f' % cost}" if cost
|
|
1704
|
+
session_cost = Configuration.estimate_cost(model, input: total_input, output: total_output, cache_ttl: ttl) || 0
|
|
1605
1705
|
$stderr.puts "\n#{d}[debug] ← response: #{parts.join(' | ')} (session: ~$#{'%.4f' % session_cost})#{r}"
|
|
1606
1706
|
else
|
|
1607
1707
|
$stderr.puts "\n#{d}[debug] ← response: #{parts.join(' | ')}#{r}"
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
module RailsConsoleAi
|
|
2
|
+
# Thin adapters over the two line editors we can drive, so the interactive loop
|
|
3
|
+
# doesn't care which one is underneath.
|
|
4
|
+
#
|
|
5
|
+
# Reline (bundled with Ruby >= 2.7) is preferred: it renders a live completion
|
|
6
|
+
# dropdown as you type, which is what makes "/" discoverable. Readline is the
|
|
7
|
+
# fallback and only offers Tab completion.
|
|
8
|
+
#
|
|
9
|
+
# The two differ in four ways that matter here, all absorbed by the adapters:
|
|
10
|
+
# - prompt escaping: Readline needs \001..\002 around ANSI so it can compute
|
|
11
|
+
# the prompt width; Reline parses ANSI itself and would print those literally.
|
|
12
|
+
# - output: Reline writes through a Ruby IO, so it has to be pointed at the real
|
|
13
|
+
# stdout — otherwise the dropdown's escape codes land in the captured session log.
|
|
14
|
+
# Readline writes to its own C-level stream and bypasses $stdout entirely.
|
|
15
|
+
# - key binding: Readline has parse_and_bind, Reline has add_default_key_binding.
|
|
16
|
+
# - what a completion candidate may contain: see #matches in each adapter.
|
|
17
|
+
module LineEditor
|
|
18
|
+
# Shift-Tab: jump to line start, kill the line, type /auto, submit.
|
|
19
|
+
# Reline binds real bytes; Readline's inputrc parser wants the escapes
|
|
20
|
+
# un-interpreted, so it gets the backslash form verbatim.
|
|
21
|
+
SHIFT_TAB = "\e[Z".freeze
|
|
22
|
+
AUTO_MACRO = "\C-a\C-k/auto\C-m".freeze
|
|
23
|
+
INPUTRC_BIND = '"\e[Z": "\C-a\C-k/auto\C-m"'.freeze
|
|
24
|
+
|
|
25
|
+
def self.resolve(preference = nil, output: nil)
|
|
26
|
+
preference = (preference || :auto).to_sym
|
|
27
|
+
editor =
|
|
28
|
+
case preference
|
|
29
|
+
when :readline then readline_adapter
|
|
30
|
+
when :reline then reline_adapter || readline_adapter
|
|
31
|
+
else reline_adapter || readline_adapter
|
|
32
|
+
end
|
|
33
|
+
editor.output = output if output && editor.respond_to?(:output=)
|
|
34
|
+
editor
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.reline_adapter
|
|
38
|
+
require 'reline'
|
|
39
|
+
Reline.respond_to?(:autocompletion=) ? Reline_.new : nil
|
|
40
|
+
rescue LoadError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.readline_adapter
|
|
45
|
+
require 'readline'
|
|
46
|
+
Readline_.new
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class Base
|
|
50
|
+
def name; self.class.name.split('::').last.chomp('_').downcase; end
|
|
51
|
+
|
|
52
|
+
# Candidates come from a proc so the list stays live — skills and agents can
|
|
53
|
+
# be created mid-session. The proc returns [slug, label] pairs, where the
|
|
54
|
+
# label says what kind of thing the slug is ("command", "skill", "agent").
|
|
55
|
+
def complete_with(&candidates); @candidates = candidates; self; end
|
|
56
|
+
|
|
57
|
+
def matches(target)
|
|
58
|
+
matching(target).map(&:first)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def matching(target)
|
|
64
|
+
return [] unless target.to_s.start_with?('/')
|
|
65
|
+
entries = @candidates ? @candidates.call : []
|
|
66
|
+
entries.select { |slug, _| slug.start_with?(target) }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
class Reline_ < Base
|
|
71
|
+
def initialize
|
|
72
|
+
Reline.autocompletion = true
|
|
73
|
+
Reline.completion_append_character = ' '
|
|
74
|
+
Reline.completion_proc = ->(target) { matches(target) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def output=(io); Reline.output = io; end
|
|
78
|
+
|
|
79
|
+
def prompt(text, color)
|
|
80
|
+
"#{color}#{text}\e[0m"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def readline(prompt)
|
|
84
|
+
Reline.readline(prompt, false)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def push_history(line)
|
|
88
|
+
Reline::HISTORY.push(line) unless line == Reline::HISTORY.to_a.last
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def bind_auto_toggle
|
|
92
|
+
Reline.core.config.add_default_key_binding(SHIFT_TAB.bytes, AUTO_MACRO.bytes)
|
|
93
|
+
rescue StandardError
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Reline's menu inserts whichever row you arrow onto, verbatim, so a
|
|
98
|
+
# candidate has to be exactly the text that belongs in the buffer. No labels.
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
class Readline_ < Base
|
|
102
|
+
def initialize
|
|
103
|
+
Readline.completion_append_character = ' '
|
|
104
|
+
Readline.completion_proc = ->(target) { matches(target) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def prompt(text, color)
|
|
108
|
+
"\001#{color}\002#{text}\001\e[0m\002"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def readline(prompt)
|
|
112
|
+
Readline.readline(prompt, false)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def push_history(line)
|
|
116
|
+
Readline::HISTORY.push(line) unless line == Readline::HISTORY.to_a.last
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def bind_auto_toggle
|
|
120
|
+
return unless Readline.respond_to?(:parse_and_bind)
|
|
121
|
+
Readline.parse_and_bind(INPUTRC_BIND)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Readline only ever inserts the common prefix of the candidates it is given,
|
|
125
|
+
# and displays the rest for the eye alone — so when there is more than one
|
|
126
|
+
# match we can append a kind label without it ever reaching the buffer. That
|
|
127
|
+
# is what makes a built-in command distinguishable from a skill or an agent
|
|
128
|
+
# in the Tab list. The labels sit past the point where the slugs diverge, so
|
|
129
|
+
# they can't lengthen the common prefix either.
|
|
130
|
+
#
|
|
131
|
+
# A lone match is different: there the common prefix IS the whole candidate,
|
|
132
|
+
# so it must be the bare slug, and Readline appends its trailing space.
|
|
133
|
+
def matches(target)
|
|
134
|
+
found = matching(target)
|
|
135
|
+
return found.map(&:first) if found.size <= 1
|
|
136
|
+
|
|
137
|
+
width = found.map { |slug, _| slug.length }.max + 2
|
|
138
|
+
found.map { |slug, label| "#{slug.ljust(width)}#{label}" }
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -5,6 +5,7 @@ module RailsConsoleAi
|
|
|
5
5
|
module Providers
|
|
6
6
|
class Base
|
|
7
7
|
attr_reader :config
|
|
8
|
+
attr_accessor :routing_session_id
|
|
8
9
|
|
|
9
10
|
def initialize(config = RailsConsoleAi.configuration)
|
|
10
11
|
@config = config
|
|
@@ -150,7 +151,7 @@ module RailsConsoleAi
|
|
|
150
151
|
class ProviderError < StandardError; end
|
|
151
152
|
|
|
152
153
|
ChatResult = Struct.new(:text, :input_tokens, :output_tokens, :tool_calls, :stop_reason,
|
|
153
|
-
:cache_read_input_tokens, :cache_write_input_tokens, keyword_init: true) do
|
|
154
|
+
:cache_read_input_tokens, :cache_write_input_tokens, :cost, keyword_init: true) do
|
|
154
155
|
def total_tokens
|
|
155
156
|
(input_tokens || 0) + (output_tokens || 0)
|
|
156
157
|
end
|
|
@@ -168,6 +169,10 @@ module RailsConsoleAi
|
|
|
168
169
|
when :openai
|
|
169
170
|
require 'rails_console_ai/providers/openai'
|
|
170
171
|
OpenAI.new(config)
|
|
172
|
+
when :openrouter
|
|
173
|
+
require 'rails_console_ai/providers/openai'
|
|
174
|
+
require 'rails_console_ai/providers/openrouter'
|
|
175
|
+
OpenRouter.new(config)
|
|
171
176
|
when :local
|
|
172
177
|
require 'rails_console_ai/providers/openai'
|
|
173
178
|
require 'rails_console_ai/providers/local'
|
|
@@ -3,38 +3,19 @@ module RailsConsoleAi
|
|
|
3
3
|
class Local < OpenAI
|
|
4
4
|
private
|
|
5
5
|
|
|
6
|
-
def
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
headers = { 'Content-Type' => 'application/json' }
|
|
10
|
-
api_key = config.local_api_key
|
|
11
|
-
if api_key && api_key != 'no-key' && !api_key.empty?
|
|
12
|
-
headers['Authorization'] = "Bearer #{api_key}"
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
conn = build_connection(base_url, headers)
|
|
16
|
-
|
|
17
|
-
formatted = []
|
|
18
|
-
formatted << { role: 'system', content: system_prompt } if system_prompt
|
|
19
|
-
formatted.concat(format_messages(messages))
|
|
20
|
-
|
|
21
|
-
body = {
|
|
22
|
-
model: config.resolved_model,
|
|
23
|
-
max_tokens: config.resolved_max_tokens,
|
|
24
|
-
messages: formatted
|
|
25
|
-
}
|
|
26
|
-
temp = config.resolved_temperature
|
|
27
|
-
body[:temperature] = temp unless temp.nil?
|
|
28
|
-
body[:tools] = tools.to_openai_format if tools
|
|
6
|
+
def api_base
|
|
7
|
+
config.local_url
|
|
8
|
+
end
|
|
29
9
|
|
|
30
|
-
|
|
10
|
+
def request_headers
|
|
11
|
+
key = config.local_api_key
|
|
12
|
+
return {} if key.nil? || key.empty? || key == 'no-key'
|
|
13
|
+
{ 'Authorization' => "Bearer #{key}" }
|
|
14
|
+
end
|
|
31
15
|
|
|
32
|
-
|
|
33
|
-
debug_request("#{base_url}/v1/chat/completions", body)
|
|
34
|
-
response = conn.post('/v1/chat/completions', json_body)
|
|
35
|
-
debug_response(response.body)
|
|
36
|
-
data = parse_response(response)
|
|
16
|
+
def build_result(data, body:, tools: nil)
|
|
37
17
|
usage = data['usage'] || {}
|
|
18
|
+
estimated_input_tokens = estimate_tokens(body)
|
|
38
19
|
|
|
39
20
|
prompt_tokens = usage['prompt_tokens']
|
|
40
21
|
if prompt_tokens && estimated_input_tokens > 0 && prompt_tokens < estimated_input_tokens * 0.5
|
|
@@ -50,9 +31,6 @@ module RailsConsoleAi
|
|
|
50
31
|
|
|
51
32
|
tool_calls = extract_tool_calls(message)
|
|
52
33
|
|
|
53
|
-
# Fallback: some local models (e.g. Ollama) emit tool calls as JSON
|
|
54
|
-
# in the content field instead of using the structured tool_calls format.
|
|
55
|
-
# Only match when the JSON "name" is a known tool name to avoid false positives.
|
|
56
34
|
if tool_calls.empty? && tools
|
|
57
35
|
tool_names = tools.to_openai_format.map { |t| t.dig('function', 'name') }.compact
|
|
58
36
|
text_calls = extract_tool_calls_from_text(message['content'], tool_names)
|
|
@@ -74,10 +52,12 @@ module RailsConsoleAi
|
|
|
74
52
|
)
|
|
75
53
|
end
|
|
76
54
|
|
|
77
|
-
def estimate_tokens(
|
|
78
|
-
chars =
|
|
79
|
-
messages
|
|
80
|
-
|
|
55
|
+
def estimate_tokens(body)
|
|
56
|
+
chars = 0
|
|
57
|
+
(body[:messages] || []).each do |m|
|
|
58
|
+
chars += m[:content].to_s.length + (m[:tool_calls].to_s.length)
|
|
59
|
+
end
|
|
60
|
+
chars += body[:tools].to_s.length if body[:tools]
|
|
81
61
|
chars / 4
|
|
82
62
|
end
|
|
83
63
|
|
|
@@ -40,12 +40,35 @@ module RailsConsoleAi
|
|
|
40
40
|
private
|
|
41
41
|
|
|
42
42
|
def call_api(messages, system_prompt: nil, tools: nil)
|
|
43
|
-
conn = build_connection(
|
|
44
|
-
|
|
45
|
-
})
|
|
43
|
+
conn = build_connection(api_base, request_headers)
|
|
44
|
+
body = build_body(messages, system_prompt: system_prompt, tools: tools)
|
|
45
|
+
debug_request("#{api_base}#{endpoint_path}", body)
|
|
46
|
+
response = with_retries { conn.post(endpoint_path, JSON.generate(body)) }
|
|
47
|
+
debug_response(response.body)
|
|
48
|
+
build_result(parse_response(response), body: body, tools: tools)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def api_base
|
|
52
|
+
API_URL
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def endpoint_path
|
|
56
|
+
'/v1/chat/completions'
|
|
57
|
+
end
|
|
46
58
|
|
|
59
|
+
def request_headers
|
|
60
|
+
{ 'Authorization' => "Bearer #{config.resolved_api_key}" }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Overridable: providers that support explicit cache breakpoints emit
|
|
64
|
+
# multipart content here instead of a bare string.
|
|
65
|
+
def system_message(system_prompt)
|
|
66
|
+
{ role: 'system', content: system_prompt }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def build_body(messages, system_prompt:, tools:)
|
|
47
70
|
formatted = []
|
|
48
|
-
formatted <<
|
|
71
|
+
formatted << system_message(system_prompt) if system_prompt
|
|
49
72
|
formatted.concat(format_messages(messages))
|
|
50
73
|
|
|
51
74
|
body = {
|
|
@@ -56,12 +79,10 @@ module RailsConsoleAi
|
|
|
56
79
|
temp = config.resolved_temperature
|
|
57
80
|
body[:temperature] = temp unless temp.nil?
|
|
58
81
|
body[:tools] = tools.to_openai_format if tools
|
|
82
|
+
body
|
|
83
|
+
end
|
|
59
84
|
|
|
60
|
-
|
|
61
|
-
debug_request("#{API_URL}/v1/chat/completions", body)
|
|
62
|
-
response = with_retries { conn.post('/v1/chat/completions', json_body) }
|
|
63
|
-
debug_response(response.body)
|
|
64
|
-
data = parse_response(response)
|
|
85
|
+
def build_result(data, body:, tools: nil)
|
|
65
86
|
usage = data['usage'] || {}
|
|
66
87
|
|
|
67
88
|
choice = (data['choices'] || []).first || {}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
module RailsConsoleAi
|
|
2
|
+
module Providers
|
|
3
|
+
class OpenRouter < OpenAI
|
|
4
|
+
DEFAULT_URL = 'https://openrouter.ai'.freeze
|
|
5
|
+
ANTHROPIC_MODEL = /anthropic\/|claude/i
|
|
6
|
+
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def api_base
|
|
10
|
+
config.openrouter_url || DEFAULT_URL
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def endpoint_path
|
|
14
|
+
'/api/v1/chat/completions'
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def request_headers
|
|
18
|
+
h = { 'Authorization' => "Bearer #{config.resolved_api_key}" }
|
|
19
|
+
h['HTTP-Referer'] = config.openrouter_site_url if config.openrouter_site_url
|
|
20
|
+
h['X-Title'] = config.openrouter_app_name if config.openrouter_app_name
|
|
21
|
+
h
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def build_body(messages, system_prompt:, tools:)
|
|
25
|
+
body = super
|
|
26
|
+
# Root-level cache_control is OpenRouter's automatic mode: it places a
|
|
27
|
+
# breakpoint on the last cacheable block and moves it forward as the
|
|
28
|
+
# conversation grows, which is what a tool loop needs — otherwise every
|
|
29
|
+
# round re-bills the whole accumulated history at full input price.
|
|
30
|
+
body[:cache_control] = cache_control if cache_supported?
|
|
31
|
+
body[:session_id] = routing_session_id if routing_session_id
|
|
32
|
+
# OpenRouter only returns usage.cost — the real dollars every cost
|
|
33
|
+
# readout prefers over an estimate — when the request asks for it.
|
|
34
|
+
body[:usage] = { include: true }
|
|
35
|
+
body
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The static system prefix gets its own explicit breakpoint so it has a
|
|
39
|
+
# guaranteed read point no matter what happens later in `messages`; the
|
|
40
|
+
# automatic breakpoint above then covers the growing tail. OpenRouter
|
|
41
|
+
# expresses Anthropic breakpoints as OpenAI-style multipart content.
|
|
42
|
+
def system_message(system_prompt)
|
|
43
|
+
return super unless cache_supported?
|
|
44
|
+
|
|
45
|
+
{ role: 'system',
|
|
46
|
+
content: [{ type: 'text', text: system_prompt, cache_control: cache_control }] }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Same TTL on every breakpoint: entries with the longer TTL must precede
|
|
50
|
+
# shorter ones, and an explicit marker whose TTL differs from the root-level
|
|
51
|
+
# field's is rejected outright.
|
|
52
|
+
def cache_control
|
|
53
|
+
ttl = config.respond_to?(:resolved_cache_ttl) ? config.resolved_cache_ttl : nil
|
|
54
|
+
ttl ? { type: 'ephemeral', ttl: ttl } : { type: 'ephemeral' }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def build_result(data, body:, tools: nil)
|
|
58
|
+
raise_inline_error!(data)
|
|
59
|
+
result = super
|
|
60
|
+
usage = data['usage'] || {}
|
|
61
|
+
details = usage['prompt_tokens_details'] || {}
|
|
62
|
+
|
|
63
|
+
cache_read = details['cached_tokens'].to_i
|
|
64
|
+
cache_write = details['cache_write_tokens'].to_i
|
|
65
|
+
result.cache_read_input_tokens = cache_read
|
|
66
|
+
result.cache_write_input_tokens = cache_write
|
|
67
|
+
result.cost = usage['cost']
|
|
68
|
+
|
|
69
|
+
# OpenAI-shaped `prompt_tokens` is the WHOLE prompt, cached tokens
|
|
70
|
+
# included; Anthropic's `input_tokens` is the uncached remainder, and the
|
|
71
|
+
# engine is built on the Anthropic contract — it reconstructs total prompt
|
|
72
|
+
# volume as input + cache_read + cache_write for the runaway-loop budget
|
|
73
|
+
# breakers. Left as sent, a cached round counts twice and those breakers
|
|
74
|
+
# fire at half the volume they are set to.
|
|
75
|
+
if result.input_tokens
|
|
76
|
+
result.input_tokens = [result.input_tokens - cache_read - cache_write, 0].max
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
if result.tool_calls&.any?
|
|
80
|
+
result.stop_reason = :tool_use
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
result
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def cache_supported?
|
|
87
|
+
config.resolved_model.to_s.match?(ANTHROPIC_MODEL)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def raise_inline_error!(data)
|
|
91
|
+
err = data['error'] || (data['choices'] || []).first&.dig('error')
|
|
92
|
+
return unless err
|
|
93
|
+
|
|
94
|
+
msg = err.is_a?(Hash) ? (err['message'] || err.to_s) : err.to_s
|
|
95
|
+
code = err.is_a?(Hash) ? err['code'] : 'unknown'
|
|
96
|
+
raise ProviderError, "OpenRouter error (#{code}): #{msg}"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|