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.
@@ -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
- if pricing
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
- cost = usage_cost(pricing, input: usage[:input], output: usage[:output],
439
- cache_read: cache_read, cache_write: cache_write)
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
- $stdout.puts "\e[36m Total: ~$#{'%.2f' % total_cost}\e[0m"
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
- sa = tools.last_sub_agent_usage
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
- model = effective_model
1563
- pricing = Configuration.pricing_for(model, cache_ttl: RailsConsoleAi.configuration.resolved_cache_ttl)
1564
- if pricing
1565
- cost = usage_cost(pricing, input: result.input_tokens, output: result.output_tokens,
1566
- cache_read: cache_r, cache_write: cache_w)
1567
- parts << "~$#{'%.4f' % cost}"
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
- if pricing
1601
- cost = usage_cost(pricing, input: input_t, output: output_t,
1602
- cache_read: cache_r, cache_write: cache_w)
1603
- session_cost = (total_input * pricing[:input]) + (total_output * pricing[:output])
1604
- parts << "~$#{'%.4f' % cost}"
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 call_api(messages, system_prompt: nil, tools: nil)
7
- base_url = config.local_url
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
- estimated_input_tokens = estimate_tokens(formatted, system_prompt, tools)
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
- json_body = JSON.generate(body)
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(messages, system_prompt, tools)
78
- chars = system_prompt.to_s.length
79
- messages.each { |m| chars += m[:content].to_s.length + (m[:tool_calls].to_s.length) }
80
- chars += tools.to_openai_format.to_s.length if tools
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(API_URL, {
44
- 'Authorization' => "Bearer #{config.resolved_api_key}"
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 << { role: 'system', content: system_prompt } if system_prompt
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
- json_body = JSON.generate(body)
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