rails_console_ai 0.35.0 → 0.38.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 +25 -0
- data/README.md +102 -10
- data/app/controllers/rails_console_ai/artifacts_controller.rb +67 -0
- data/app/helpers/rails_console_ai/sessions_helper.rb +11 -11
- data/app/models/rails_console_ai/artifact.rb +231 -0
- data/app/views/layouts/rails_console_ai/application.html.erb +1 -0
- data/app/views/rails_console_ai/artifacts/index.html.erb +60 -0
- data/app/views/rails_console_ai/artifacts/show.html.erb +119 -0
- data/app/views/rails_console_ai/sessions/show.html.erb +6 -0
- data/config/routes.rb +8 -0
- data/lib/generators/rails_console_ai/templates/initializer.rb +12 -1
- data/lib/rails_console_ai/channel/console.rb +132 -18
- data/lib/rails_console_ai/channel/slack.rb +1 -1
- data/lib/rails_console_ai/configuration.rb +114 -15
- data/lib/rails_console_ai/context_builder.rb +48 -18
- data/lib/rails_console_ai/conversation_engine.rb +231 -61
- data/lib/rails_console_ai/line_editor.rb +142 -0
- data/lib/rails_console_ai/providers/anthropic.rb +3 -1
- data/lib/rails_console_ai/providers/base.rb +26 -1
- data/lib/rails_console_ai/providers/bedrock.rb +5 -2
- data/lib/rails_console_ai/providers/local.rb +17 -37
- data/lib/rails_console_ai/providers/openai.rb +31 -10
- data/lib/rails_console_ai/providers/openrouter.rb +108 -0
- data/lib/rails_console_ai/slack_bot.rb +58 -23
- data/lib/rails_console_ai/slash_commands.rb +150 -0
- data/lib/rails_console_ai/sub_agent.rb +35 -2
- data/lib/rails_console_ai/tools/registry.rb +233 -3
- data/lib/rails_console_ai/version.rb +1 -1
- data/lib/rails_console_ai.rb +223 -0
- metadata +8 -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,
|
|
@@ -11,6 +13,7 @@ module RailsConsoleAi
|
|
|
11
13
|
REPEAT_ERROR_WARN_THRESHOLD = 3 # same error signature (any args) → inject warning
|
|
12
14
|
REPEAT_ERROR_BREAK_THRESHOLD = 5 # same error signature (any args) → force wrap-up
|
|
13
15
|
CONTEXT_WARN_FRACTION = 0.7 # share of the model's context window → suggest /compact
|
|
16
|
+
MAX_TRUNCATION_RETRIES = 1 # responses cut off at the output cap → nudge, then stop
|
|
14
17
|
|
|
15
18
|
def initialize(binding_context:, channel:, slack_thread_ts: nil, slack_channel_name: nil)
|
|
16
19
|
@binding_context = binding_context
|
|
@@ -19,6 +22,7 @@ module RailsConsoleAi
|
|
|
19
22
|
@slack_channel_name = slack_channel_name
|
|
20
23
|
@executor = Executor.new(binding_context, channel: channel)
|
|
21
24
|
@provider = nil
|
|
25
|
+
@routing_session_id = SecureRandom.hex(8)
|
|
22
26
|
@context_builder = nil
|
|
23
27
|
@context = nil
|
|
24
28
|
@history = []
|
|
@@ -128,6 +132,16 @@ module RailsConsoleAi
|
|
|
128
132
|
end
|
|
129
133
|
end
|
|
130
134
|
|
|
135
|
+
# Creates the session row before the first turn runs, so its id can be
|
|
136
|
+
# handed out (the Slack bot links to it) while that turn is still going.
|
|
137
|
+
# Returns nil when session logging is off.
|
|
138
|
+
def ensure_session_record(query)
|
|
139
|
+
init_interactive unless @interactive_start
|
|
140
|
+
@interactive_query ||= query
|
|
141
|
+
log_interactive_turn(status: 'running') unless @interactive_session_id
|
|
142
|
+
@interactive_session_id
|
|
143
|
+
end
|
|
144
|
+
|
|
131
145
|
def init_guide
|
|
132
146
|
storage = RailsConsoleAi.storage
|
|
133
147
|
existing_guide = begin
|
|
@@ -255,6 +269,80 @@ module RailsConsoleAi
|
|
|
255
269
|
execute_direct(code)
|
|
256
270
|
end
|
|
257
271
|
|
|
272
|
+
# --- Slash-command invocation of skills and agents ---
|
|
273
|
+
|
|
274
|
+
# A skill is a recipe for *this* assistant, and its guard bypasses have to
|
|
275
|
+
# land on the live executor — so it runs as a normal turn with the recipe
|
|
276
|
+
# prepended, not in a sub-agent. Returns the user-turn text to send.
|
|
277
|
+
def skill_command_prompt(skill, request)
|
|
278
|
+
bypass_methods = Array(skill['bypass_guards_for_methods'])
|
|
279
|
+
@executor.activate_skill_bypasses(bypass_methods) unless bypass_methods.empty?
|
|
280
|
+
|
|
281
|
+
if skill['source'] == :db && skill['id']
|
|
282
|
+
RailsConsoleAi::Skill.record_use!(skill['id'])
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
@channel.display_status(" Activated skill: #{skill['name']}")
|
|
286
|
+
|
|
287
|
+
request = request.to_s.strip
|
|
288
|
+
task = request.empty? ? "Follow this skill now." : request
|
|
289
|
+
|
|
290
|
+
"The user invoked the \"#{skill['name']}\" skill. Follow its procedure.\n\n" \
|
|
291
|
+
"--- SKILL: #{skill['name']} ---\n#{skill['body']}\n--- END SKILL ---\n\n" \
|
|
292
|
+
"Their request: #{task}"
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# An agent is a separate context by definition, so this mirrors delegate_task:
|
|
296
|
+
# run it, then fold only its summary back into the conversation.
|
|
297
|
+
def run_agent_command(agent, task)
|
|
298
|
+
require 'rails_console_ai/sub_agent'
|
|
299
|
+
|
|
300
|
+
task = task.to_s.strip
|
|
301
|
+
if task.empty?
|
|
302
|
+
@channel.display_warning(" /#{SlashCommands.slugify(agent['name'])} needs a task. Try: /#{SlashCommands.slugify(agent['name'])} <what to investigate>")
|
|
303
|
+
return
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
@interactive_query ||= "/#{SlashCommands.slugify(agent['name'])} #{task}"
|
|
307
|
+
log_interactive_turn(status: 'running')
|
|
308
|
+
|
|
309
|
+
if agent['source'] == :db && agent['id']
|
|
310
|
+
RailsConsoleAi::Agent.record_use!(agent['id'])
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
sub = SubAgent.new(
|
|
314
|
+
task: task,
|
|
315
|
+
agent_config: agent,
|
|
316
|
+
binding_context: @executor.binding_context,
|
|
317
|
+
parent_channel: @channel,
|
|
318
|
+
executor: @executor
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
begin
|
|
322
|
+
# Deliberately NOT wrapped in @channel.wrap_llm_call. That wrapper puts the
|
|
323
|
+
# terminal in raw mode to watch for Esc, which clears ONLCR — so anything
|
|
324
|
+
# printed inside it staircases down the screen. Everywhere else it wraps
|
|
325
|
+
# only the provider HTTP call, where nothing prints; a sub-agent run prints
|
|
326
|
+
# throughout. Ctrl-C still lands here as an Interrupt.
|
|
327
|
+
result = sub.run
|
|
328
|
+
rescue Interrupt
|
|
329
|
+
@channel.display_warning(" Cancelled.")
|
|
330
|
+
log_interactive_turn(status: 'ready')
|
|
331
|
+
return
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
record_sub_agent_usage(sub)
|
|
335
|
+
|
|
336
|
+
@history << {
|
|
337
|
+
role: :user,
|
|
338
|
+
content: "The user ran the \"#{agent['name']}\" agent with the task: #{task}\n\n" \
|
|
339
|
+
"The agent reported back:\n#{result}"
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
@channel.display(result.to_s)
|
|
343
|
+
log_interactive_turn(status: 'ready')
|
|
344
|
+
end
|
|
345
|
+
|
|
258
346
|
def execute_direct(raw_code)
|
|
259
347
|
@interactive_query ||= "> #{raw_code}"
|
|
260
348
|
log_interactive_turn(status: 'running')
|
|
@@ -424,6 +512,7 @@ module RailsConsoleAi
|
|
|
424
512
|
end
|
|
425
513
|
|
|
426
514
|
total_cost = 0.0
|
|
515
|
+
has_reported_cost = false
|
|
427
516
|
$stdout.puts "\e[36m Cost estimate:\e[0m"
|
|
428
517
|
|
|
429
518
|
@token_usage.each do |model, usage|
|
|
@@ -432,13 +521,25 @@ module RailsConsoleAi
|
|
|
432
521
|
input_str = "in: #{format_tokens(usage[:input])}"
|
|
433
522
|
output_str = "out: #{format_tokens(usage[:output])}"
|
|
434
523
|
|
|
435
|
-
|
|
524
|
+
# A provider that reports real dollars (OpenRouter) beats any estimate.
|
|
525
|
+
reported_cost = usage[:cost]
|
|
526
|
+
if reported_cost && reported_cost > 0
|
|
527
|
+
has_reported_cost = true
|
|
528
|
+
total_cost += reported_cost
|
|
529
|
+
cache_str = ""
|
|
436
530
|
cache_read = usage[:cache_read] || 0
|
|
437
531
|
cache_write = usage[:cache_write] || 0
|
|
438
|
-
|
|
439
|
-
|
|
532
|
+
cache_str = " cache r: #{format_tokens(cache_read)} w: #{format_tokens(cache_write)}" if cache_read > 0 || cache_write > 0
|
|
533
|
+
$stdout.puts "\e[2m #{model}: #{input_str} #{output_str}#{cache_str} $#{'%.4f' % reported_cost}\e[0m"
|
|
534
|
+
elsif pricing
|
|
535
|
+
cost = Configuration.estimate_cost(model,
|
|
536
|
+
input: usage[:input], output: usage[:output],
|
|
537
|
+
cache_read: usage[:cache_read] || 0, cache_write: usage[:cache_write] || 0,
|
|
538
|
+
cache_ttl: RailsConsoleAi.configuration.resolved_cache_ttl)
|
|
440
539
|
total_cost += cost
|
|
441
540
|
cache_str = ""
|
|
541
|
+
cache_read = usage[:cache_read] || 0
|
|
542
|
+
cache_write = usage[:cache_write] || 0
|
|
442
543
|
cache_str = " cache r: #{format_tokens(cache_read)} w: #{format_tokens(cache_write)}" if cache_read > 0 || cache_write > 0
|
|
443
544
|
$stdout.puts "\e[2m #{model}: #{input_str} #{output_str}#{cache_str} ~$#{'%.2f' % cost}\e[0m"
|
|
444
545
|
else
|
|
@@ -446,7 +547,8 @@ module RailsConsoleAi
|
|
|
446
547
|
end
|
|
447
548
|
end
|
|
448
549
|
|
|
449
|
-
|
|
550
|
+
label = has_reported_cost ? "Total:" : "Total: ~"
|
|
551
|
+
$stdout.puts "\e[36m #{label}$#{'%.2f' % total_cost}\e[0m"
|
|
450
552
|
end
|
|
451
553
|
|
|
452
554
|
def display_conversation
|
|
@@ -505,38 +607,56 @@ module RailsConsoleAi
|
|
|
505
607
|
upgrade_to_thinking_model
|
|
506
608
|
end
|
|
507
609
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
thinking = config.resolved_thinking_model
|
|
610
|
+
# Three tiers a session can run on. :default and :thinking usually share a
|
|
611
|
+
# model and differ in effort, so a switch is tracked by tier, not by model ID.
|
|
612
|
+
MODEL_TIERS = %i[default thinking cheap].freeze
|
|
512
613
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
else
|
|
516
|
-
@model_override = thinking
|
|
517
|
-
@provider = nil
|
|
518
|
-
$stdout.puts "\e[36m Switched to thinking model: #{thinking}\e[0m"
|
|
519
|
-
end
|
|
520
|
-
effective_model
|
|
614
|
+
def upgrade_to_thinking_model
|
|
615
|
+
switch_model_tier(:thinking)
|
|
521
616
|
end
|
|
522
617
|
|
|
523
618
|
def downgrade_from_thinking_model
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
619
|
+
switch_model_tier(:default)
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
def switch_to_cheap_model
|
|
623
|
+
switch_model_tier(:cheap)
|
|
624
|
+
end
|
|
527
625
|
|
|
528
|
-
|
|
529
|
-
|
|
626
|
+
def switch_model_tier(tier)
|
|
627
|
+
raise ArgumentError, "Unknown model tier: #{tier.inspect}" unless MODEL_TIERS.include?(tier)
|
|
628
|
+
|
|
629
|
+
if tier == model_tier
|
|
630
|
+
$stdout.puts "\e[36m Already using #{tier} model (#{effective_model_label}).\e[0m"
|
|
530
631
|
else
|
|
531
|
-
@
|
|
632
|
+
@model_tier = tier
|
|
532
633
|
@provider = nil
|
|
533
|
-
$stdout.puts "\e[36m Switched
|
|
634
|
+
$stdout.puts "\e[36m Switched to #{tier} model: #{effective_model_label}\e[0m"
|
|
534
635
|
end
|
|
535
636
|
effective_model
|
|
536
637
|
end
|
|
537
638
|
|
|
639
|
+
def model_tier
|
|
640
|
+
@model_tier || :default
|
|
641
|
+
end
|
|
642
|
+
|
|
538
643
|
def effective_model
|
|
539
|
-
|
|
644
|
+
config = RailsConsoleAi.configuration
|
|
645
|
+
case model_tier
|
|
646
|
+
when :thinking then config.resolved_thinking_model
|
|
647
|
+
when :cheap then config.resolved_cheap_model
|
|
648
|
+
else config.resolved_model
|
|
649
|
+
end
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
def effective_effort
|
|
653
|
+
tier_config.resolved_effort
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
# "claude-opus-5-5 (high effort)", or the bare model when no effort is sent.
|
|
657
|
+
def effective_model_label
|
|
658
|
+
effort = effective_effort
|
|
659
|
+
effort ? "#{effective_model} (#{effort} effort)" : effective_model
|
|
540
660
|
end
|
|
541
661
|
|
|
542
662
|
def compact_history
|
|
@@ -653,6 +773,9 @@ module RailsConsoleAi
|
|
|
653
773
|
log_attrs[:user_name] = @channel.mode == 'slack' ? "slack:#{@channel.user_identity}" : @channel.user_identity
|
|
654
774
|
end
|
|
655
775
|
@interactive_session_id = SessionLogger.log(log_attrs)
|
|
776
|
+
# Tools that persist something (save_artifact) read this to point back at
|
|
777
|
+
# the conversation that produced it, without needing a handle on the engine.
|
|
778
|
+
RailsConsoleAi.current_session_id = @interactive_session_id
|
|
656
779
|
end
|
|
657
780
|
end
|
|
658
781
|
|
|
@@ -695,6 +818,19 @@ module RailsConsoleAi
|
|
|
695
818
|
|
|
696
819
|
private
|
|
697
820
|
|
|
821
|
+
def record_sub_agent_usage(sub)
|
|
822
|
+
absorb_sub_agent_usage(input: sub.input_tokens, output: sub.output_tokens, model: sub.model_used, cost: sub.cost)
|
|
823
|
+
end
|
|
824
|
+
|
|
825
|
+
def absorb_sub_agent_usage(usage)
|
|
826
|
+
@total_input_tokens += usage[:input] || 0
|
|
827
|
+
@total_output_tokens += usage[:output] || 0
|
|
828
|
+
return unless usage[:model]
|
|
829
|
+
@token_usage[usage[:model]][:input] += usage[:input] || 0
|
|
830
|
+
@token_usage[usage[:model]][:output] += usage[:output] || 0
|
|
831
|
+
@token_usage[usage[:model]][:cost] = (@token_usage[usage[:model]][:cost] || 0) + usage[:cost] if usage[:cost]
|
|
832
|
+
end
|
|
833
|
+
|
|
698
834
|
def safety_context
|
|
699
835
|
guards = RailsConsoleAi.configuration.safety_guards
|
|
700
836
|
return nil if guards.empty?
|
|
@@ -750,16 +886,24 @@ module RailsConsoleAi
|
|
|
750
886
|
|
|
751
887
|
def provider
|
|
752
888
|
@provider ||= begin
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
Providers.build(config)
|
|
757
|
-
else
|
|
758
|
-
Providers.build
|
|
759
|
-
end
|
|
889
|
+
p = model_tier == :default ? Providers.build : Providers.build(tier_config)
|
|
890
|
+
p.routing_session_id = @routing_session_id if p.respond_to?(:routing_session_id=)
|
|
891
|
+
p
|
|
760
892
|
end
|
|
761
893
|
end
|
|
762
894
|
|
|
895
|
+
# The configuration for the current tier. Off the default tier it is a copy,
|
|
896
|
+
# so a switch never leaks into the global singleton or other sessions.
|
|
897
|
+
def tier_config
|
|
898
|
+
config = RailsConsoleAi.configuration
|
|
899
|
+
return config if model_tier == :default
|
|
900
|
+
|
|
901
|
+
config = config.dup
|
|
902
|
+
config.model = effective_model
|
|
903
|
+
config.effort = model_tier == :thinking ? config.thinking_effort : nil
|
|
904
|
+
config
|
|
905
|
+
end
|
|
906
|
+
|
|
763
907
|
def context_builder
|
|
764
908
|
@context_builder ||= ContextBuilder.new(channel_mode: @channel.mode, user_name: @channel.user_identity)
|
|
765
909
|
end
|
|
@@ -880,6 +1024,7 @@ module RailsConsoleAi
|
|
|
880
1024
|
|
|
881
1025
|
exhausted = false
|
|
882
1026
|
wrap_up_reason = nil
|
|
1027
|
+
truncation_nudges = 0
|
|
883
1028
|
tool_call_counts = Hash.new(0)
|
|
884
1029
|
error_sig_counts = Hash.new(0)
|
|
885
1030
|
warned_error_sigs = Set.new
|
|
@@ -939,6 +1084,34 @@ module RailsConsoleAi
|
|
|
939
1084
|
debug_post_call(round, result, @total_input_tokens + total_input, @total_output_tokens + total_output)
|
|
940
1085
|
end
|
|
941
1086
|
|
|
1087
|
+
# A response cut off at the output cap has nothing usable in it: the tool
|
|
1088
|
+
# call it was in the middle of writing arrives with half its arguments, so
|
|
1089
|
+
# it is discarded, and only the sentence the model wrote before starting
|
|
1090
|
+
# the call survives. Left unhandled that looks exactly like a finished
|
|
1091
|
+
# turn, and the model retries the same oversized call every time it is
|
|
1092
|
+
# asked again — the identical truncation, forever, at full output cost.
|
|
1093
|
+
# Say so, once, with the way out; the second one ends the turn.
|
|
1094
|
+
if result.truncated?
|
|
1095
|
+
cap = RailsConsoleAi.configuration.resolved_max_tokens
|
|
1096
|
+
@channel.display_warning(" Response hit the #{format_tokens(cap)} output-token cap mid-generation — the incomplete tool call was discarded.")
|
|
1097
|
+
if truncation_nudges >= MAX_TRUNCATION_RETRIES
|
|
1098
|
+
@channel.display_error(" Truncated again. Stopping — see the guidance above, or raise c.max_tokens.")
|
|
1099
|
+
break
|
|
1100
|
+
end
|
|
1101
|
+
|
|
1102
|
+
truncation_nudges += 1
|
|
1103
|
+
add_nudge.call(
|
|
1104
|
+
"Your last response was cut off at the #{cap}-token output limit, so the tool call you were " \
|
|
1105
|
+
"writing was discarded and never ran. Retrying it unchanged will be cut off at exactly the same " \
|
|
1106
|
+
"point. You must make the next response SMALLER. If you were passing a large block of content as " \
|
|
1107
|
+
"a tool argument — the content of an artifact, a file, a long report — do not inline it: run " \
|
|
1108
|
+
"execute_code and have the Ruby build the string and save it directly, e.g. " \
|
|
1109
|
+
"`RailsConsoleAi.save_artifact(name: \"...\", content: some_string)`, which never sends those " \
|
|
1110
|
+
"bytes through your response at all. Otherwise, split the work across several smaller tool calls."
|
|
1111
|
+
)
|
|
1112
|
+
next
|
|
1113
|
+
end
|
|
1114
|
+
|
|
942
1115
|
break unless result.tool_use?
|
|
943
1116
|
|
|
944
1117
|
last_thinking = (result.text && !result.text.strip.empty?) ? result.text.strip : nil
|
|
@@ -994,13 +1167,7 @@ module RailsConsoleAi
|
|
|
994
1167
|
|
|
995
1168
|
# Aggregate sub-agent token usage into parent's cost tracking
|
|
996
1169
|
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
|
|
1170
|
+
absorb_sub_agent_usage(tools.last_sub_agent_usage)
|
|
1004
1171
|
end
|
|
1005
1172
|
|
|
1006
1173
|
if RailsConsoleAi.configuration.debug
|
|
@@ -1109,6 +1276,9 @@ module RailsConsoleAi
|
|
|
1109
1276
|
total_output += result.output_tokens || 0
|
|
1110
1277
|
total_cache_read += result.cache_read_input_tokens || 0
|
|
1111
1278
|
total_cache_write += result.cache_write_input_tokens || 0
|
|
1279
|
+
if result.truncated?
|
|
1280
|
+
@channel.display_warning(" The final answer was cut off at the #{format_tokens(RailsConsoleAi.configuration.resolved_max_tokens)} output-token cap — what follows is incomplete.")
|
|
1281
|
+
end
|
|
1112
1282
|
end
|
|
1113
1283
|
|
|
1114
1284
|
last_llm_stats = result ? format_llm_stats(result) : nil
|
|
@@ -1134,6 +1304,7 @@ module RailsConsoleAi
|
|
|
1134
1304
|
@token_usage[model][:output] += result.output_tokens || 0
|
|
1135
1305
|
@token_usage[model][:cache_read] = (@token_usage[model][:cache_read] || 0) + (result.cache_read_input_tokens || 0)
|
|
1136
1306
|
@token_usage[model][:cache_write] = (@token_usage[model][:cache_write] || 0) + (result.cache_write_input_tokens || 0)
|
|
1307
|
+
@token_usage[model][:cost] = (@token_usage[model][:cost] || 0) + (result.cost || 0) if result.cost
|
|
1137
1308
|
end
|
|
1138
1309
|
|
|
1139
1310
|
def display_usage(result, show_session: false)
|
|
@@ -1196,18 +1367,6 @@ module RailsConsoleAi
|
|
|
1196
1367
|
chars / 4
|
|
1197
1368
|
end
|
|
1198
1369
|
|
|
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
1370
|
def format_tokens(count)
|
|
1212
1371
|
if count >= 1_000_000
|
|
1213
1372
|
"#{(count / 1_000_000.0).round(1)}M"
|
|
@@ -1559,13 +1718,17 @@ module RailsConsoleAi
|
|
|
1559
1718
|
cache_r = result.cache_read_input_tokens || 0
|
|
1560
1719
|
cache_w = result.cache_write_input_tokens || 0
|
|
1561
1720
|
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
|
-
|
|
1721
|
+
|
|
1722
|
+
if result.cost
|
|
1723
|
+
parts << "$#{'%.4f' % result.cost}"
|
|
1724
|
+
else
|
|
1725
|
+
cost = Configuration.estimate_cost(effective_model,
|
|
1726
|
+
input: result.input_tokens || 0, output: result.output_tokens || 0,
|
|
1727
|
+
cache_read: cache_r, cache_write: cache_w,
|
|
1728
|
+
cache_ttl: RailsConsoleAi.configuration.resolved_cache_ttl)
|
|
1729
|
+
parts << "~$#{'%.4f' % cost}" if cost
|
|
1568
1730
|
end
|
|
1731
|
+
|
|
1569
1732
|
parts.join(' | ')
|
|
1570
1733
|
end
|
|
1571
1734
|
|
|
@@ -1597,11 +1760,18 @@ module RailsConsoleAi
|
|
|
1597
1760
|
parts = ["in: #{format_tokens(input_t)}", "out: #{format_tokens(output_t)}"]
|
|
1598
1761
|
parts << "cache r: #{format_tokens(cache_r)} w: #{format_tokens(cache_w)}" if cache_r > 0 || cache_w > 0
|
|
1599
1762
|
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1763
|
+
ttl = RailsConsoleAi.configuration.resolved_cache_ttl
|
|
1764
|
+
if result.cost
|
|
1765
|
+
parts << "$#{'%.4f' % result.cost}"
|
|
1766
|
+
session_has_reported = @token_usage.any? { |_, u| u[:cost] && u[:cost] > 0 }
|
|
1767
|
+
session_cost = session_has_reported ? @token_usage.sum { |_, u| u[:cost] || 0 } :
|
|
1768
|
+
Configuration.estimate_cost(model, input: total_input, output: total_output, cache_ttl: ttl) || 0
|
|
1769
|
+
$stderr.puts "\n#{d}[debug] ← response: #{parts.join(' | ')} (session: $#{'%.4f' % session_cost})#{r}"
|
|
1770
|
+
elsif pricing
|
|
1771
|
+
cost = Configuration.estimate_cost(model, input: input_t, output: output_t,
|
|
1772
|
+
cache_read: cache_r, cache_write: cache_w, cache_ttl: ttl)
|
|
1773
|
+
parts << "~$#{'%.4f' % cost}" if cost
|
|
1774
|
+
session_cost = Configuration.estimate_cost(model, input: total_input, output: total_output, cache_ttl: ttl) || 0
|
|
1605
1775
|
$stderr.puts "\n#{d}[debug] ← response: #{parts.join(' | ')} (session: ~$#{'%.4f' % session_cost})#{r}"
|
|
1606
1776
|
else
|
|
1607
1777
|
$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
|
|
@@ -55,6 +55,8 @@ module RailsConsoleAi
|
|
|
55
55
|
}
|
|
56
56
|
temp = config.resolved_temperature
|
|
57
57
|
body[:temperature] = temp unless temp.nil?
|
|
58
|
+
effort = config.resolved_effort
|
|
59
|
+
body[:output_config] = { effort: effort } if effort
|
|
58
60
|
if system_prompt
|
|
59
61
|
body[:system] = [
|
|
60
62
|
{ 'type' => 'text', 'text' => system_prompt, 'cache_control' => cache_control }
|
|
@@ -74,7 +76,7 @@ module RailsConsoleAi
|
|
|
74
76
|
usage = data['usage'] || {}
|
|
75
77
|
|
|
76
78
|
tool_calls = extract_tool_calls(data)
|
|
77
|
-
stop = data['stop_reason']
|
|
79
|
+
stop = normalize_stop_reason(data['stop_reason'], %w[tool_use], %w[max_tokens])
|
|
78
80
|
|
|
79
81
|
ChatResult.new(
|
|
80
82
|
text: extract_text(data),
|
|
@@ -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
|
|
@@ -28,6 +29,20 @@ module RailsConsoleAi
|
|
|
28
29
|
|
|
29
30
|
private
|
|
30
31
|
|
|
32
|
+
# Every provider reports "I stopped because I ran out of room" under its own
|
|
33
|
+
# name, and the obvious mapping — anything that isn't a tool call is a
|
|
34
|
+
# finished turn — is wrong in a way that costs the whole turn: a response cut
|
|
35
|
+
# off mid-tool-call arrives with its arguments half-written, the engine sees
|
|
36
|
+
# :end_turn, discards the partial call and prints whatever preamble the model
|
|
37
|
+
# managed first. That reads as the model deciding to answer in one sentence.
|
|
38
|
+
# Naming truncation lets the engine say so and let the model recover.
|
|
39
|
+
def normalize_stop_reason(raw, tool_values, truncation_values)
|
|
40
|
+
raw = raw.to_s
|
|
41
|
+
return :tool_use if tool_values.include?(raw)
|
|
42
|
+
return :max_tokens if truncation_values.include?(raw)
|
|
43
|
+
:end_turn
|
|
44
|
+
end
|
|
45
|
+
|
|
31
46
|
# Read and connect timeouts are separate budgets. Establishing the TCP/TLS
|
|
32
47
|
# connection either happens in a couple of seconds or is not going to, while
|
|
33
48
|
# generation legitimately takes minutes with adaptive thinking and a large
|
|
@@ -150,7 +165,7 @@ module RailsConsoleAi
|
|
|
150
165
|
class ProviderError < StandardError; end
|
|
151
166
|
|
|
152
167
|
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
|
|
168
|
+
:cache_read_input_tokens, :cache_write_input_tokens, :cost, keyword_init: true) do
|
|
154
169
|
def total_tokens
|
|
155
170
|
(input_tokens || 0) + (output_tokens || 0)
|
|
156
171
|
end
|
|
@@ -158,6 +173,12 @@ module RailsConsoleAi
|
|
|
158
173
|
def tool_use?
|
|
159
174
|
stop_reason == :tool_use && tool_calls && !tool_calls.empty?
|
|
160
175
|
end
|
|
176
|
+
|
|
177
|
+
# The response hit the output cap mid-generation. Whatever it was writing —
|
|
178
|
+
# text, or the arguments of a tool call — is incomplete and cannot be used.
|
|
179
|
+
def truncated?
|
|
180
|
+
stop_reason == :max_tokens
|
|
181
|
+
end
|
|
161
182
|
end
|
|
162
183
|
|
|
163
184
|
def self.build(config = RailsConsoleAi.configuration)
|
|
@@ -168,6 +189,10 @@ module RailsConsoleAi
|
|
|
168
189
|
when :openai
|
|
169
190
|
require 'rails_console_ai/providers/openai'
|
|
170
191
|
OpenAI.new(config)
|
|
192
|
+
when :openrouter
|
|
193
|
+
require 'rails_console_ai/providers/openai'
|
|
194
|
+
require 'rails_console_ai/providers/openrouter'
|
|
195
|
+
OpenRouter.new(config)
|
|
171
196
|
when :local
|
|
172
197
|
require 'rails_console_ai/providers/openai'
|
|
173
198
|
require 'rails_console_ai/providers/local'
|