rails_console_ai 0.34.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.
@@ -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
@@ -51,24 +51,24 @@ module RailsConsoleAi
51
51
  body = {
52
52
  model: config.resolved_model,
53
53
  max_tokens: config.resolved_max_tokens,
54
- messages: format_messages(messages)
54
+ messages: mark_conversation_breakpoint(format_messages(messages))
55
55
  }
56
56
  temp = config.resolved_temperature
57
57
  body[:temperature] = temp unless temp.nil?
58
58
  if system_prompt
59
59
  body[:system] = [
60
- { 'type' => 'text', 'text' => system_prompt, 'cache_control' => { 'type' => 'ephemeral' } }
60
+ { 'type' => 'text', 'text' => system_prompt, 'cache_control' => cache_control }
61
61
  ]
62
62
  end
63
63
  if tools
64
64
  anthropic_tools = tools.to_anthropic_format
65
- anthropic_tools.last['cache_control'] = { 'type' => 'ephemeral' } if anthropic_tools.any?
65
+ anthropic_tools.last['cache_control'] = cache_control if anthropic_tools.any?
66
66
  body[:tools] = anthropic_tools
67
67
  end
68
68
 
69
69
  json_body = JSON.generate(body)
70
70
  debug_request("#{API_URL}/v1/messages", body)
71
- response = conn.post('/v1/messages', json_body)
71
+ response = with_retries { conn.post('/v1/messages', json_body) }
72
72
  debug_response(response.body)
73
73
  data = parse_response(response)
74
74
  usage = data['usage'] || {}
@@ -87,16 +87,60 @@ module RailsConsoleAi
87
87
  )
88
88
  end
89
89
 
90
+ # Text content is always rendered as a one-element block array, even though
91
+ # the API accepts a bare string. The breakpoint below can only be attached
92
+ # to a block, so a string tail would have to be promoted to a block — and
93
+ # then rendered back as a string on the next request, once it is no longer
94
+ # the tail. That byte-level flip-flop would break the prefix at exactly the
95
+ # message the next request needs to read from cache. Rendering one shape
96
+ # always keeps the prefix stable.
97
+ #
98
+ # Empty content is left alone: an empty text block is rejected outright.
90
99
  def format_messages(messages)
91
100
  messages.map do |msg|
92
- if msg[:content].is_a?(Array)
93
- { role: msg[:role].to_s, content: msg[:content] }
94
- else
95
- { role: msg[:role].to_s, content: msg[:content].to_s }
96
- end
101
+ content = msg[:content]
102
+ content =
103
+ if content.is_a?(Array) || content.to_s.strip.empty?
104
+ content
105
+ else
106
+ [{ 'type' => 'text', 'text' => content.to_s }]
107
+ end
108
+ { role: msg[:role].to_s, content: content }
97
109
  end
98
110
  end
99
111
 
112
+ def cache_control
113
+ ttl = config.respond_to?(:resolved_cache_ttl) ? config.resolved_cache_ttl : nil
114
+ ttl ? { 'type' => 'ephemeral', 'ttl' => ttl } : { 'type' => 'ephemeral' }
115
+ end
116
+
117
+ # Caching `tools` and `system` only covers the static prefix. Every round of
118
+ # a tool loop resends the whole accumulated conversation, so without a
119
+ # breakpoint in `messages` the history — which is nearly all of the tokens —
120
+ # is re-billed at full input price every round, and a task's cost grows with
121
+ # roughly the square of its round count.
122
+ #
123
+ # The breakpoint moves to the end of the array on every request. Breakpoints
124
+ # written by earlier requests stay valid read points, so each round reads
125
+ # everything accumulated so far at ~0.1x and writes only what the last round
126
+ # added. Blocks are duped before marking: `format_messages` passes content
127
+ # arrays through by reference and they belong to the caller's history.
128
+ def mark_conversation_breakpoint(formatted)
129
+ return formatted if formatted.empty?
130
+
131
+ last = formatted.last
132
+ # Anything not already a block array is empty content (see #format_messages)
133
+ # — nothing to cache there.
134
+ return formatted unless last[:content].is_a?(Array)
135
+
136
+ blocks = last[:content].map { |b| b.is_a?(Hash) ? b.dup : b }
137
+ target = blocks.last
138
+ return formatted unless target.is_a?(Hash)
139
+ target['cache_control'] = cache_control
140
+
141
+ formatted[0..-2] + [last.merge(content: blocks)]
142
+ end
143
+
100
144
  def extract_text(data)
101
145
  content = data['content']
102
146
  return '' unless content.is_a?(Array)
@@ -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,17 +29,82 @@ module RailsConsoleAi
28
29
 
29
30
  private
30
31
 
32
+ # Read and connect timeouts are separate budgets. Establishing the TCP/TLS
33
+ # connection either happens in a couple of seconds or is not going to, while
34
+ # generation legitimately takes minutes with adaptive thinking and a large
35
+ # output cap — sharing one value between them means either a connect timeout
36
+ # that hangs or a read timeout that cuts off generation mid-stream. A cut-off
37
+ # request loses the turn AND the tokens already spent producing it.
31
38
  def build_connection(url, headers = {})
32
39
  Faraday.new(url: url) do |f|
33
- t = config.respond_to?(:resolved_timeout) ? config.resolved_timeout : config.timeout
34
- f.options.timeout = t
35
- f.options.open_timeout = t
40
+ f.options.timeout = config.respond_to?(:resolved_timeout) ? config.resolved_timeout : config.timeout
41
+ f.options.open_timeout = config.respond_to?(:open_timeout) ? config.open_timeout : 10
36
42
  f.headers.update(headers)
37
43
  f.headers['Content-Type'] = 'application/json'
38
44
  f.adapter Faraday.default_adapter
39
45
  end
40
46
  end
41
47
 
48
+ # Transient failures worth another attempt: rate limits, upstream overload,
49
+ # and connections that never got established. Deliberately NOT retried:
50
+ #
51
+ # - Timeouts. A request that used its whole read budget is not obviously
52
+ # going to do better on a second try, and each retry both doubles the wait
53
+ # and pays again for a generation nobody will read. Raise instead, and say
54
+ # which knob to turn.
55
+ # - 4xx other than 429. A malformed request stays malformed.
56
+ RETRYABLE_STATUSES = [408, 409, 429, 500, 502, 503, 504, 529].freeze
57
+
58
+ def with_retries
59
+ max = config.respond_to?(:max_retries) ? config.max_retries.to_i : 2
60
+ attempt = 0
61
+
62
+ loop do
63
+ response = nil
64
+ reason = nil
65
+
66
+ begin
67
+ response = yield
68
+ rescue Faraday::TimeoutError
69
+ t = config.respond_to?(:resolved_timeout) ? config.resolved_timeout : config.timeout
70
+ raise ProviderError,
71
+ "Provider request timed out after #{t}s. Raise it with: " \
72
+ "RailsConsoleAi.configure { |c| c.timeout = #{t * 2} }"
73
+ rescue Faraday::ConnectionFailed, Faraday::SSLError => e
74
+ raise ProviderError, "Could not reach the provider: #{e.message}" if attempt >= max
75
+
76
+ reason = e.class.name
77
+ end
78
+
79
+ if response
80
+ return response if response.success?
81
+ return response unless RETRYABLE_STATUSES.include?(response.status)
82
+ return response if attempt >= max
83
+
84
+ reason = "HTTP #{response.status}"
85
+ end
86
+
87
+ delay = retry_delay(response, attempt)
88
+ RailsConsoleAi.logger.warn(
89
+ "RailsConsoleAi: #{reason} from provider, retrying in #{'%.1f' % delay}s " \
90
+ "(attempt #{attempt + 1} of #{max})"
91
+ )
92
+ sleep(delay)
93
+ attempt += 1
94
+ end
95
+ end
96
+
97
+ # Honour Retry-After when the server sends one; otherwise exponential backoff
98
+ # with jitter, so concurrent sessions don't retry in lockstep.
99
+ def retry_delay(response, attempt)
100
+ header = response && (response.headers['retry-after'] || response.headers['Retry-After'])
101
+ if header && header.to_f > 0
102
+ [header.to_f, 60.0].min
103
+ else
104
+ (2**attempt) + rand
105
+ end
106
+ end
107
+
42
108
  def debug_request(url, body)
43
109
  return unless config.debug
44
110
 
@@ -85,7 +151,7 @@ module RailsConsoleAi
85
151
  class ProviderError < StandardError; end
86
152
 
87
153
  ChatResult = Struct.new(:text, :input_tokens, :output_tokens, :tool_calls, :stop_reason,
88
- :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
89
155
  def total_tokens
90
156
  (input_tokens || 0) + (output_tokens || 0)
91
157
  end
@@ -103,6 +169,10 @@ module RailsConsoleAi
103
169
  when :openai
104
170
  require 'rails_console_ai/providers/openai'
105
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)
106
176
  when :local
107
177
  require 'rails_console_ai/providers/openai'
108
178
  require 'rails_console_ai/providers/local'
@@ -46,17 +46,17 @@ module RailsConsoleAi
46
46
  inference[:temperature] = temp unless temp.nil?
47
47
  params = {
48
48
  model_id: config.resolved_model,
49
- messages: format_messages(messages),
49
+ messages: mark_conversation_breakpoint(format_messages(messages)),
50
50
  inference_config: inference
51
51
  }
52
52
  if system_prompt
53
53
  sys_blocks = [{ text: system_prompt }]
54
- sys_blocks << { cache_point: { type: 'default' } } if cache_supported?
54
+ sys_blocks << cache_point if cache_supported?
55
55
  params[:system] = sys_blocks
56
56
  end
57
57
  if tools
58
58
  bedrock_tools = tools.to_bedrock_format
59
- bedrock_tools << { cache_point: { type: 'default' } } if bedrock_tools.any? && cache_supported?
59
+ bedrock_tools << cache_point if bedrock_tools.any? && cache_supported?
60
60
  params[:tool_config] = { tools: bedrock_tools }
61
61
  end
62
62
 
@@ -96,6 +96,10 @@ module RailsConsoleAi
96
96
  client_opts[:region] = region if region && !region.empty?
97
97
  t = config.respond_to?(:resolved_timeout) ? config.resolved_timeout : config.timeout
98
98
  client_opts[:http_read_timeout] = t
99
+ # Separate budget from generation time, same reasoning as
100
+ # Providers::Base#build_connection. The AWS SDK does its own retrying of
101
+ # throttling and 5xx, so there is no with_retries wrapper on this path.
102
+ client_opts[:http_open_timeout] = config.open_timeout if config.respond_to?(:open_timeout)
99
103
  Aws::BedrockRuntime::Client.new(client_opts)
100
104
  end
101
105
  end
@@ -141,6 +145,46 @@ module RailsConsoleAi
141
145
  merged
142
146
  end
143
147
 
148
+ # Converse takes a cache breakpoint as a content block. `ttl` is optional and
149
+ # only present on newer aws-sdk-bedrockruntime versions — the SDK validates
150
+ # params against its own struct and raises on an unknown member, so the
151
+ # member is probed rather than assumed. Omitting it means the 5-minute
152
+ # default, which is also what `cache_ttl = nil` asks for.
153
+ def cache_point
154
+ ttl = config.respond_to?(:resolved_cache_ttl) ? config.resolved_cache_ttl : nil
155
+ return { cache_point: { type: 'default' } } unless ttl && cache_ttl_supported?
156
+
157
+ { cache_point: { type: 'default', ttl: ttl } }
158
+ end
159
+
160
+ def cache_ttl_supported?
161
+ return @cache_ttl_supported if defined?(@cache_ttl_supported)
162
+
163
+ # The struct is only defined once aws-sdk-bedrockruntime is loaded, and
164
+ # #client does that lazily. Probing first would fail-open to "no TTL" on
165
+ # the first request of the process — silently, which is the failure mode
166
+ # this whole change exists to avoid. #client is memoized and needed a few
167
+ # lines later anyway.
168
+ client
169
+
170
+ @cache_ttl_supported =
171
+ defined?(Aws::BedrockRuntime::Types::CachePointBlock) &&
172
+ Aws::BedrockRuntime::Types::CachePointBlock.members.include?(:ttl)
173
+ end
174
+
175
+ # Same reasoning as Providers::Anthropic#mark_conversation_breakpoint: the
176
+ # system/tools cache points only cover the static prefix, so without a cache
177
+ # point in the conversation the accumulated history is re-billed at full
178
+ # price on every round of a tool loop. `format_messages` has already duped
179
+ # the content arrays, so appending is safe.
180
+ def mark_conversation_breakpoint(formatted)
181
+ return formatted unless cache_supported?
182
+ return formatted if formatted.empty?
183
+
184
+ formatted.last[:content] << cache_point
185
+ formatted
186
+ end
187
+
144
188
  def extract_text(response)
145
189
  content = response.output&.message&.content
146
190
  return '' unless content.is_a?(Array)
@@ -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 = 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
@@ -10,6 +10,8 @@ module RailsConsoleAi
10
10
  conversation: Array(attrs[:conversation]).to_json,
11
11
  input_tokens: attrs[:input_tokens] || 0,
12
12
  output_tokens: attrs[:output_tokens] || 0,
13
+ cache_read_tokens: attrs[:cache_read_tokens] || 0,
14
+ cache_write_tokens: attrs[:cache_write_tokens] || 0,
13
15
  user_name: attrs[:user_name] || current_user_name,
14
16
  mode: attrs[:mode].to_s,
15
17
  name: attrs[:name],
@@ -59,6 +61,8 @@ module RailsConsoleAi
59
61
  updates[:conversation] = Array(attrs[:conversation]).to_json if attrs.key?(:conversation)
60
62
  updates[:input_tokens] = attrs[:input_tokens] if attrs.key?(:input_tokens)
61
63
  updates[:output_tokens] = attrs[:output_tokens] if attrs.key?(:output_tokens)
64
+ updates[:cache_read_tokens] = attrs[:cache_read_tokens] if attrs.key?(:cache_read_tokens)
65
+ updates[:cache_write_tokens] = attrs[:cache_write_tokens] if attrs.key?(:cache_write_tokens)
62
66
  updates[:code_executed] = attrs[:code_executed] if attrs.key?(:code_executed)
63
67
  updates[:code_output] = attrs[:code_output] if attrs.key?(:code_output)
64
68
  updates[:code_result] = attrs[:code_result] if attrs.key?(:code_result)