rails_console_ai 0.34.0 → 0.35.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 61fcabfed3f03e58e104b7c8b49b86ccf365db5dbae5e1dbf1f8f1b7e63d811c
4
- data.tar.gz: ec844ca4e1251fb9c1f0c0172095050745ef9690a90defefbcded92eae0962f8
3
+ metadata.gz: 37ef99b1cc4572e89283e5210b15e6110bac2b0adc758a1b7d222c603c4557c2
4
+ data.tar.gz: 511f76003fdc00ce0104b506eaa71d7de918e3cf1f62eebd395308461caeefd1
5
5
  SHA512:
6
- metadata.gz: f2294a51714714de69afcc908dccbcb8d5034999973c40b4533600579e8617efc1133ed918ed7925aa082a3afd5598335b5a09527d1a933b38a6ddbf8143d595
7
- data.tar.gz: bce68674c9467a92eac6b7521e4f1e39f1d06c32b374f38f627513c7ab31bdda57a12a2e937654103a2322d075b594aa56b610cf2ad716f1965f1c2d6123cfad
6
+ metadata.gz: e6cb2ccc4f03fbcafff6d7edcace1c80fd76a99692404f7e05aa039e9276665b07e03ab70b12043635b37742c5e05ffe1fa9fd42f77e25de6daff329c90e0dfc
7
+ data.tar.gz: ee6acd8f33f2d13c921832fd1d4470e9d08bd8a795ec45d7ebb3c7b67ae7ca3fb6475b46796192dcd4a0b2a48d66b0bbb8bc2b2e47e498e9785c12dad7eefba4
data/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.35.0]
6
+
7
+ - Extend prompt caching to the full conversation history
8
+ - Add an option for a one-hour prompt cache
9
+ - Fix cost reporting to price cached reads and writes correctly
10
+ - Correct the Claude Sonnet 5 pricing rate
11
+ - Add separate connect and read timeouts for provider requests
12
+ - Retry transient provider failures with exponential backoff
13
+ - Warn when a conversation approaches the model's context window
14
+ - Stop overriding a configured request timeout
15
+ - Fix a failure when a turn hits the tool round limit
16
+
5
17
  ## [0.34.0]
6
18
 
7
19
  - Add an `:in_process_requests` built-in safety guard that blocks in-process HTTP dispatch against the app itself — `ActionDispatch::Integration::Session` requests (the console `app` helper) and direct Rack dispatch (`Rails.application.call`) — for all verbs including GET, since these can hang the session thread indefinitely; allowlist entries are request paths
@@ -1,10 +1,17 @@
1
1
  module RailsConsoleAi
2
2
  module SessionsHelper
3
+ # All four usage buckets bill at their own rate. `input_tokens` is the uncached
4
+ # remainder only — on a cached session it is a tiny fraction of the prompt, so
5
+ # pricing input+output alone reads as near-zero for the sessions that actually
6
+ # cost the most.
3
7
  def estimated_cost(session)
4
8
  pricing = Configuration.pricing_for(session.model)
5
9
  return nil unless pricing
6
10
 
7
- (session.input_tokens * pricing[:input]) + (session.output_tokens * pricing[:output])
11
+ (session.input_tokens * pricing[:input]) +
12
+ (session.output_tokens * pricing[:output]) +
13
+ (session.try(:cache_read_tokens).to_i * pricing[:cache_read]) +
14
+ (session.try(:cache_write_tokens).to_i * pricing[:cache_write])
8
15
  end
9
16
 
10
17
  def format_cost(session)
@@ -33,7 +33,7 @@
33
33
  <td class="query-cell"><a href="<%= rails_console_ai.session_path(session) %>" title="<%= h session.query.truncate(200) %>"><%= truncate(session.query.gsub(/\s+/, ' ').strip, length: 80) %></a></td>
34
34
  <td><span class="badge badge-<%= session.mode %>"><%= session.mode %></span></td>
35
35
  <td><% status = session.try(:status) %><%= status.present? ? content_tag(:span, status, class: "badge badge-status-#{status}") : '-' %></td>
36
- <td class="mono"><%= session.input_tokens + session.output_tokens %></td>
36
+ <td class="mono"><%= session.input_tokens + session.output_tokens + session.try(:cache_read_tokens).to_i + session.try(:cache_write_tokens).to_i %></td>
37
37
  <td class="mono"><%= format_cost(session) %></td>
38
38
  <td class="mono"><%= session.duration_ms ? "#{session.duration_ms}ms" : '-' %></td>
39
39
  </tr>
@@ -44,6 +44,12 @@
44
44
  <label>Tokens (in / out)</label>
45
45
  <span class="mono"><%= @session.input_tokens %> / <%= @session.output_tokens %></span>
46
46
  </div>
47
+ <% if (@session.try(:cache_read_tokens).to_i + @session.try(:cache_write_tokens).to_i) > 0 %>
48
+ <div class="meta-item">
49
+ <label>Cache (read / write)</label>
50
+ <span class="mono"><%= @session.cache_read_tokens %> / <%= @session.cache_write_tokens %></span>
51
+ </div>
52
+ <% end %>
47
53
  <div class="meta-item">
48
54
  <label>Est. Cost</label>
49
55
  <span class="mono"><%= format_cost(@session) %></span>
@@ -20,8 +20,11 @@ RailsConsoleAi.configure do |config|
20
20
  # Max tool-use rounds per query (safety cap)
21
21
  config.max_tool_rounds = 10
22
22
 
23
- # HTTP timeout in seconds
24
- config.timeout = 30
23
+ # Read timeout in seconds for one provider request. The default (300) allows for
24
+ # a long thinking turn; a value too low cuts off generation mid-stream, which
25
+ # loses the turn and the tokens already spent on it. The connect timeout is
26
+ # separate (config.open_timeout, default 10).
27
+ # config.timeout = 300
25
28
 
26
29
  # Local model provider (Ollama, vLLM, or any OpenAI-compatible server):
27
30
  # config.provider = :local
@@ -11,17 +11,23 @@ module RailsConsoleAi
11
11
  # Cache pricing is derived: read = 0.1x input, write = 1.25x input.
12
12
  # temperature: false marks families that reject the `temperature` parameter
13
13
  # (removed on opus-4-7+, sonnet-5, and fable-5).
14
+ # max_tokens is the OUTPUT cap we request; context is the total window.
14
15
  MODEL_FAMILIES = {
15
- 'claude-fable-5' => { input: 10.0, output: 50.0, max_tokens: 16_000, temperature: false },
16
- 'claude-opus-5' => { input: 5.0, output: 25.0, max_tokens: 16_000, temperature: false },
17
- 'claude-opus-4-8' => { input: 5.0, output: 25.0, max_tokens: 16_000, temperature: false },
18
- 'claude-opus-4-7' => { input: 5.0, output: 25.0, max_tokens: 16_000, temperature: false },
19
- 'claude-opus-4-6' => { input: 5.0, output: 25.0, max_tokens: 16_000, temperature: true },
20
- 'claude-sonnet-5' => { input: 3.0, output: 15.0, max_tokens: 16_000, temperature: false },
21
- 'claude-sonnet-4-6' => { input: 3.0, output: 15.0, max_tokens: 16_000, temperature: true },
22
- 'claude-haiku-4-5' => { input: 1.0, output: 5.0, max_tokens: 16_000, temperature: true },
16
+ 'claude-fable-5' => { input: 10.0, output: 50.0, max_tokens: 16_000, context: 1_000_000, temperature: false },
17
+ 'claude-opus-5' => { input: 5.0, output: 25.0, max_tokens: 16_000, context: 1_000_000, temperature: false },
18
+ 'claude-opus-4-8' => { input: 5.0, output: 25.0, max_tokens: 16_000, context: 1_000_000, temperature: false },
19
+ 'claude-opus-4-7' => { input: 5.0, output: 25.0, max_tokens: 16_000, context: 1_000_000, temperature: false },
20
+ 'claude-opus-4-6' => { input: 5.0, output: 25.0, max_tokens: 16_000, context: 1_000_000, temperature: true },
21
+ 'claude-sonnet-5' => { input: 2.0, output: 10.0, max_tokens: 16_000, context: 1_000_000, temperature: false },
22
+ 'claude-sonnet-4-6' => { input: 3.0, output: 15.0, max_tokens: 16_000, context: 1_000_000, temperature: true },
23
+ 'claude-haiku-4-5' => { input: 1.0, output: 5.0, max_tokens: 16_000, context: 200_000, temperature: true },
23
24
  }.freeze
24
25
 
26
+ # Assumed context window for models with no family entry — local models and
27
+ # anything newer than this table. Deliberately small: under-guessing warns a
28
+ # little early, over-guessing means no warning before the request is rejected.
29
+ DEFAULT_CONTEXT_WINDOW = 200_000
30
+
25
31
  # Family keys sorted longest-first so a more specific family always wins
26
32
  # if keys ever overlap (e.g. a future 'claude-sonnet-5-5' entry would match
27
33
  # before 'claude-sonnet-5').
@@ -36,7 +42,9 @@ module RailsConsoleAi
36
42
 
37
43
  # Per-token pricing for a model ID, matched by family. Returns
38
44
  # { input:, output:, cache_read:, cache_write: } or nil for unknown models.
39
- def self.pricing_for(model_id)
45
+ # Cache reads bill at 0.1x the base input rate; cache writes at 1.25x for the
46
+ # 5-minute cache and 2x for the 1-hour cache.
47
+ def self.pricing_for(model_id, cache_ttl: nil)
40
48
  family = model_family(model_id)
41
49
  return nil unless family
42
50
  input = family[:input] / 1_000_000
@@ -44,10 +52,16 @@ module RailsConsoleAi
44
52
  input: input,
45
53
  output: family[:output] / 1_000_000,
46
54
  cache_read: input * 0.1,
47
- cache_write: input * 1.25,
55
+ cache_write: input * (cache_ttl.to_s == '1h' ? 2.0 : 1.25),
48
56
  }
49
57
  end
50
58
 
59
+ # Total context window for a model ID, matched by family.
60
+ def self.context_window_for(model_id)
61
+ family = model_family(model_id)
62
+ (family && family[:context]) || DEFAULT_CONTEXT_WINDOW
63
+ end
64
+
51
65
  # Known environment-level failures the executor recognizes and explains to the
52
66
  # LLM on the FIRST occurrence, so it doesn't burn rounds rediscovering them
53
67
  # through trial and error. Each entry: { name:, pattern:, hint: }.
@@ -69,9 +83,10 @@ module RailsConsoleAi
69
83
 
70
84
  attr_accessor :provider, :api_key, :model, :thinking_model, :max_tokens,
71
85
  :auto_execute, :temperature,
72
- :timeout, :debug, :max_tool_rounds,
86
+ :timeout, :open_timeout, :max_retries, :debug, :max_tool_rounds,
73
87
  :error_hints,
74
88
  :token_nudge_threshold, :token_stop_threshold,
89
+ :cache_ttl,
75
90
  :storage_adapter, :memories_enabled,
76
91
  :session_logging, :connection_class,
77
92
  :admin_username, :admin_password,
@@ -94,12 +109,29 @@ module RailsConsoleAi
94
109
  @max_tokens = nil
95
110
  @auto_execute = false
96
111
  @temperature = 0.2
97
- @timeout = 30
112
+ # Read timeout for one provider request. Adaptive thinking plus a large output
113
+ # cap means a single agentic call can legitimately run for minutes; the old 30s
114
+ # cut those off mid-generation, which loses the turn and the tokens already
115
+ # spent on it, and sends the user back to re-ask from a cold cache.
116
+ @timeout = 300
117
+ @open_timeout = 10 # establishing the connection, not generating the response
118
+ @max_retries = 2 # transient failures only — see Providers::Base#with_retries
98
119
  @debug = false
99
120
  @max_tool_rounds = 200
100
121
  @error_hints = DEFAULT_ERROR_HINTS.dup
101
- @token_nudge_threshold = 500_000 # input tokens in one tool loop nudge model to wrap up (nil disables)
102
- @token_stop_threshold = 1_000_000 # input tokens in one tool loop force a final answer (nil disables)
122
+ # Measured against total prompt tokens sent in one tool loop uncached input
123
+ # plus cache reads plus cache writes. Not the API's `input_tokens` alone:
124
+ # that is only the uncached remainder, so with caching on it stays near zero
125
+ # regardless of conversation size and neither guard would ever fire.
126
+ @token_nudge_threshold = 500_000 # prompt tokens in one tool loop → nudge model to wrap up (nil disables)
127
+ @token_stop_threshold = 1_000_000 # prompt tokens in one tool loop → force a final answer (nil disables)
128
+ # Prompt cache lifetime: nil/'5m' for the 5-minute default, '1h' for the
129
+ # 1-hour cache. Within a tool loop, rounds are seconds apart and 5m is
130
+ # strictly cheaper (a read refreshes the entry, and the write costs 1.25x
131
+ # vs 2x). '1h' pays off when a human sits between turns for more than five
132
+ # minutes — long interactive console sessions and Slack threads — because
133
+ # a miss there resends the whole conversation at full price.
134
+ @cache_ttl = nil
103
135
  @storage_adapter = nil
104
136
  @memories_enabled = true
105
137
  @session_logging = true
@@ -245,6 +277,13 @@ module RailsConsoleAi
245
277
  @temperature
246
278
  end
247
279
 
280
+ # Returns '1h' when the 1-hour prompt cache is requested, else nil (the
281
+ # 5-minute default). Providers that offer only one cache duration ignore it.
282
+ def resolved_cache_ttl
283
+ return nil unless @cache_ttl
284
+ @cache_ttl.to_s == '1h' ? '1h' : nil
285
+ end
286
+
248
287
  def resolved_thinking_model
249
288
  return @thinking_model if @thinking_model && !@thinking_model.empty?
250
289
 
@@ -1,6 +1,7 @@
1
1
  module RailsConsoleAi
2
2
  class ConversationEngine
3
3
  attr_reader :history, :total_input_tokens, :total_output_tokens,
4
+ :total_cache_read_tokens, :total_cache_write_tokens,
4
5
  :interactive_session_id, :session_name
5
6
 
6
7
  LARGE_OUTPUT_THRESHOLD = 20_000 # chars — truncate tool results larger than this immediately
@@ -9,6 +10,7 @@ module RailsConsoleAi
9
10
  LOOP_BREAK_THRESHOLD = 5 # same tool+args repeated → break loop
10
11
  REPEAT_ERROR_WARN_THRESHOLD = 3 # same error signature (any args) → inject warning
11
12
  REPEAT_ERROR_BREAK_THRESHOLD = 5 # same error signature (any args) → force wrap-up
13
+ CONTEXT_WARN_FRACTION = 0.7 # share of the model's context window → suggest /compact
12
14
 
13
15
  def initialize(binding_context:, channel:, slack_thread_ts: nil, slack_channel_name: nil)
14
16
  @binding_context = binding_context
@@ -22,6 +24,8 @@ module RailsConsoleAi
22
24
  @history = []
23
25
  @total_input_tokens = 0
24
26
  @total_output_tokens = 0
27
+ @total_cache_read_tokens = 0
28
+ @total_cache_write_tokens = 0
25
29
  @token_usage = Hash.new { |h, k| h[k] = { input: 0, output: 0 } }
26
30
  @interactive_session_id = nil
27
31
  @session_name = nil
@@ -42,7 +46,7 @@ module RailsConsoleAi
42
46
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
43
47
  console_capture = StringIO.new
44
48
  exec_result = with_console_capture(console_capture) do
45
- conversation = [{ role: :user, content: query }]
49
+ conversation = [{ role: :user, content: user_turn(query) }]
46
50
  exec_result, code, executed = one_shot_round(conversation)
47
51
 
48
52
  if executed && @executor.last_error && !@executor.last_safety_error
@@ -85,7 +89,7 @@ module RailsConsoleAi
85
89
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
86
90
  console_capture = StringIO.new
87
91
  with_console_capture(console_capture) do
88
- result, _ = send_query(query)
92
+ result, _ = send_query(user_turn(query))
89
93
  track_usage(result)
90
94
  @executor.display_response(result.text)
91
95
  display_usage(result)
@@ -115,7 +119,7 @@ module RailsConsoleAi
115
119
  @channel.log_input(text) if @channel.respond_to?(:log_input)
116
120
  @interactive_query ||= text
117
121
  maybe_auto_upgrade_thinking(text)
118
- @history << { role: :user, content: text }
122
+ @history << { role: :user, content: user_turn(text) }
119
123
 
120
124
  status = send_and_execute
121
125
  if status == :error
@@ -144,9 +148,6 @@ module RailsConsoleAi
144
148
  sys_prompt = init_system_prompt(existing_guide)
145
149
  messages = [{ role: :user, content: "Explore this Rails application and generate the application guide." }]
146
150
 
147
- original_timeout = RailsConsoleAi.configuration.timeout
148
- RailsConsoleAi.configuration.timeout = [original_timeout, 120].max
149
-
150
151
  result, _ = send_query_with_tools(messages, system_prompt: sys_prompt, tools_override: init_tools)
151
152
 
152
153
  guide_text = result.text.to_s.strip
@@ -172,8 +173,6 @@ module RailsConsoleAi
172
173
  rescue => e
173
174
  @channel.display_error("RailsConsoleAi Error: #{e.class}: #{e.message}")
174
175
  nil
175
- ensure
176
- RailsConsoleAi.configuration.timeout = original_timeout if original_timeout
177
176
  end
178
177
 
179
178
  # --- Interactive session management ---
@@ -184,6 +183,8 @@ module RailsConsoleAi
184
183
  @history = []
185
184
  @total_input_tokens = 0
186
185
  @total_output_tokens = 0
186
+ @total_cache_read_tokens = 0
187
+ @total_cache_write_tokens = 0
187
188
  @token_usage = Hash.new { |h, k| h[k] = { input: 0, output: 0 } }
188
189
  @interactive_query = nil
189
190
  @interactive_session_id = nil
@@ -203,20 +204,33 @@ module RailsConsoleAi
203
204
  @session_name = session.name
204
205
  @total_input_tokens = session.input_tokens || 0
205
206
  @total_output_tokens = session.output_tokens || 0
207
+ # respond_to? rather than #try: the columns are only present after
208
+ # RailsConsoleAi.migrate! has run, and this path must not depend on
209
+ # ActiveSupport being loaded.
210
+ @total_cache_read_tokens = session_column(session, :cache_read_tokens)
211
+ @total_cache_write_tokens = session_column(session, :cache_write_tokens)
206
212
  @prior_duration_ms = session.duration_ms || 0
207
213
 
208
214
  if session.model && (session.input_tokens.to_i > 0 || session.output_tokens.to_i > 0)
209
215
  @token_usage[session.model][:input] = session.input_tokens.to_i
210
216
  @token_usage[session.model][:output] = session.output_tokens.to_i
217
+ @token_usage[session.model][:cache_read] = @total_cache_read_tokens
218
+ @token_usage[session.model][:cache_write] = @total_cache_write_tokens
211
219
  end
212
220
  end
213
221
 
222
+ # Reads a column that may not exist yet on this install (added by migrate!).
223
+ def session_column(session, name)
224
+ return 0 unless session.respond_to?(name)
225
+ session.public_send(name).to_i
226
+ end
227
+
214
228
  def set_interactive_query(text)
215
229
  @interactive_query ||= text
216
230
  end
217
231
 
218
232
  def add_user_message(text)
219
- @history << { role: :user, content: text }
233
+ @history << { role: :user, content: user_turn(text) }
220
234
  end
221
235
 
222
236
  def pop_last_message
@@ -413,21 +427,16 @@ module RailsConsoleAi
413
427
  $stdout.puts "\e[36m Cost estimate:\e[0m"
414
428
 
415
429
  @token_usage.each do |model, usage|
416
- pricing = Configuration.pricing_for(model)
430
+ pricing = Configuration.pricing_for(model, cache_ttl: RailsConsoleAi.configuration.resolved_cache_ttl)
417
431
  pricing ||= { input: 0.0, output: 0.0 } if RailsConsoleAi.configuration.provider == :local
418
432
  input_str = "in: #{format_tokens(usage[:input])}"
419
433
  output_str = "out: #{format_tokens(usage[:output])}"
420
434
 
421
435
  if pricing
422
- cost = (usage[:input] * pricing[:input]) + (usage[:output] * pricing[:output])
423
436
  cache_read = usage[:cache_read] || 0
424
437
  cache_write = usage[:cache_write] || 0
425
- if (cache_read > 0 || cache_write > 0) && pricing[:cache_read]
426
- # Subtract cached tokens from full-price input, add at cache rates
427
- cost -= cache_read * pricing[:input]
428
- cost += cache_read * pricing[:cache_read]
429
- cost += cache_write * (pricing[:cache_write] - pricing[:input])
430
- end
438
+ cost = usage_cost(pricing, input: usage[:input], output: usage[:output],
439
+ cache_read: cache_read, cache_write: cache_write)
431
440
  total_cost += cost
432
441
  cache_str = ""
433
442
  cache_str = " cache r: #{format_tokens(cache_read)} w: #{format_tokens(cache_write)}" if cache_read > 0 || cache_write > 0
@@ -466,15 +475,29 @@ module RailsConsoleAi
466
475
  conversation_messages(messages, **opts)
467
476
  end
468
477
 
478
+ # The system prompt must stay byte-identical for the life of a session: it
479
+ # renders ahead of the entire conversation, so any change to it invalidates
480
+ # the system cache AND every cached message after it. Everything here is
481
+ # fixed for the session — the binding's variable list, which changes whenever
482
+ # the console (or generated code) assigns a local, rides along with the user
483
+ # turn instead. See #user_turn.
469
484
  def context
470
485
  base = @context_base ||= context_builder.build
471
486
  parts = [base]
472
487
  parts << safety_context
473
488
  parts << @channel.system_instructions
474
- parts << binding_variable_summary
475
489
  parts.compact.join("\n\n")
476
490
  end
477
491
 
492
+ # Composes a user turn with the console binding's current variables appended.
493
+ # This belongs in `messages`, not in the system prompt: a message at turn 5
494
+ # invalidates nothing before turn 5, and because it is persisted into history
495
+ # rather than injected per-request, the prefix stays append-only.
496
+ def user_turn(text)
497
+ summary = binding_variable_summary
498
+ summary ? "#{text}\n\n#{summary}" : text
499
+ end
500
+
478
501
  AUTO_THINK_PATTERN = /\bthink\s+(harder|deeper|hard|carefully|more\s+carefully)\b/i
479
502
 
480
503
  def maybe_auto_upgrade_thinking(text)
@@ -576,13 +599,27 @@ module RailsConsoleAi
576
599
  end
577
600
  end
578
601
 
602
+ # Warn when the conversation is closing in on the model's context window — the
603
+ # one thing a long conversation still costs. It used to warn at 50K characters
604
+ # (~12K tokens) on the theory that a big conversation is an expensive one; that
605
+ # was true when every round re-sent the whole history at full input price, but
606
+ # the history is cached now and a warm 15K-token prefix is unremarkable. Warning
607
+ # there just nags, and the advice actively costs money: /compact rewrites the
608
+ # prefix, throwing away the cache, and spends a summarization call doing it.
609
+ #
610
+ # So this fires on headroom instead, and says what compacting costs.
579
611
  def warn_if_history_large
580
- chars = @history.sum { |m| m[:content].to_s.length }
612
+ return if @compact_warned
581
613
 
582
- if chars > 50_000 && !@compact_warned
583
- @compact_warned = true
584
- $stdout.puts "\e[33m Conversation is getting large (~#{format_tokens(chars)} chars). Consider running /compact to reduce context size.\e[0m"
585
- end
614
+ tokens = estimate_request_tokens(@history)
615
+ window = Configuration.context_window_for(effective_model)
616
+ return if tokens < window * CONTEXT_WARN_FRACTION
617
+
618
+ @compact_warned = true
619
+ pct = ((tokens.to_f / window) * 100).round
620
+ $stdout.puts "\e[33m Conversation is using ~#{format_tokens(tokens)} of the #{format_tokens(window)} " \
621
+ "context window (~#{pct}%). /compact will summarize it to free room — it also resets the " \
622
+ "prompt cache, so only run it when you need the headroom.\e[0m"
586
623
  end
587
624
 
588
625
  # --- Session logging ---
@@ -814,11 +851,33 @@ module RailsConsoleAi
814
851
  max_rounds = RailsConsoleAi.configuration.max_tool_rounds
815
852
  total_input = 0
816
853
  total_output = 0
854
+ # Cache activity has to be summed across rounds and reported out with the
855
+ # rest of the usage: it is the only evidence that caching is working, and
856
+ # `input_tokens` alone can't show it (the API reports only the UNCACHED
857
+ # remainder there — total prompt size is input + cache_read + cache_write).
858
+ total_cache_read = 0
859
+ total_cache_write = 0
860
+ # Prompt volume actually sent this loop. `total_input` alone is NOT it: the
861
+ # API reports only the uncached remainder there, so once caching is working
862
+ # it stays near zero no matter how large the conversation grows. The token
863
+ # budget below has to be measured against the full prompt or it never fires.
864
+ total_prompt = -> { total_input + total_cache_read + total_cache_write }
817
865
  result = nil
818
866
  new_messages = []
819
867
  last_thinking = nil
820
868
  last_tool_names = []
821
869
 
870
+ # Steering messages go into BOTH the request and the persisted history.
871
+ # Injecting a message for one request and dropping it from history rewrites
872
+ # the prefix the next turn sends, so every cached block from that point on
873
+ # misses — and the model also loses the fact that it was already nudged.
874
+ add_nudge = lambda do |text|
875
+ msg = { role: :user, content: text }
876
+ messages << msg
877
+ new_messages << msg
878
+ msg
879
+ end
880
+
822
881
  exhausted = false
823
882
  wrap_up_reason = nil
824
883
  tool_call_counts = Hash.new(0)
@@ -855,7 +914,7 @@ module RailsConsoleAi
855
914
 
856
915
  if round > 0
857
916
  req_tokens = estimate_request_tokens(messages)
858
- @channel.display_status(" #{llm_status(round, messages, req_tokens, total_input, last_thinking, last_tool_names)}")
917
+ @channel.display_status(" #{llm_status(round, messages, req_tokens, total_prompt.call, last_thinking, last_tool_names)}")
859
918
  end
860
919
 
861
920
  if RailsConsoleAi.configuration.debug
@@ -871,6 +930,8 @@ module RailsConsoleAi
871
930
  end
872
931
  total_input += result.input_tokens || 0
873
932
  total_output += result.output_tokens || 0
933
+ total_cache_read += result.cache_read_input_tokens || 0
934
+ total_cache_write += result.cache_write_input_tokens || 0
874
935
 
875
936
  break if @channel.cancelled?
876
937
 
@@ -979,7 +1040,7 @@ module RailsConsoleAi
979
1040
  wrap_up_reason ||= :tool_loop
980
1041
  elsif tool_call_counts[key] >= LOOP_WARN_THRESHOLD
981
1042
  @channel.display_status(" Warning: #{tc[:name]} called #{tool_call_counts[key]} times with same args — consider a different approach.")
982
- messages << { role: :user, content: "You are repeating the same tool call (#{tc[:name]}) with the same arguments. This is not making progress. Try a different approach or provide your answer now." }
1043
+ add_nudge.call("You are repeating the same tool call (#{tc[:name]}) with the same arguments. This is not making progress. Try a different approach or provide your answer now.")
983
1044
  end
984
1045
  end
985
1046
 
@@ -994,20 +1055,21 @@ module RailsConsoleAi
994
1055
  elsif count >= REPEAT_ERROR_WARN_THRESHOLD && !warned_error_sigs.include?(sig)
995
1056
  warned_error_sigs << sig
996
1057
  @channel.display_status(" Warning: same error hit #{count} times — nudging model to change strategy.")
997
- messages << { role: :user, content: "You have now hit the same error #{count} times (#{sig}). Trying variations of the same approach is not producing new information. If this error cannot be resolved from this session, stop investigating it: summarize what you have established, state what you could not determine and why, and give the user your best answer." }
1058
+ add_nudge.call("You have now hit the same error #{count} times (#{sig}). Trying variations of the same approach is not producing new information. If this error cannot be resolved from this session, stop investigating it: summarize what you have established, state what you could not determine and why, and give the user your best answer.")
998
1059
  end
999
1060
  end
1000
1061
 
1001
1062
  # Circuit breaker: token budget for a single tool loop.
1002
1063
  config = RailsConsoleAi.configuration
1003
- if config.token_stop_threshold && total_input >= config.token_stop_threshold
1004
- @channel.display_status(" Token budget exceeded (#{format_tokens(total_input)} input tokens this request) — forcing wrap-up.")
1064
+ prompt_tokens = total_prompt.call
1065
+ if config.token_stop_threshold && prompt_tokens >= config.token_stop_threshold
1066
+ @channel.display_status(" Token budget exceeded (#{format_tokens(prompt_tokens)} prompt tokens this request) — forcing wrap-up.")
1005
1067
  exhausted = true
1006
1068
  wrap_up_reason ||= :token_budget
1007
- elsif config.token_nudge_threshold && total_input >= config.token_nudge_threshold && !token_nudge_sent
1069
+ elsif config.token_nudge_threshold && prompt_tokens >= config.token_nudge_threshold && !token_nudge_sent
1008
1070
  token_nudge_sent = true
1009
- @channel.display_status(" High token usage (#{format_tokens(total_input)} input tokens this request) — nudging model to wrap up.")
1010
- messages << { role: :user, content: "This investigation has consumed #{format_tokens(total_input)} input tokens without reaching a conclusion. Wrap up now: stop opening new lines of investigation, summarize what you have established, state what you could not determine and why, and give the user your best answer. Only make another tool call if you are confident a single call will resolve the question." }
1071
+ @channel.display_status(" High token usage (#{format_tokens(prompt_tokens)} prompt tokens this request) — nudging model to wrap up.")
1072
+ add_nudge.call("This investigation has consumed #{format_tokens(prompt_tokens)} prompt tokens without reaching a conclusion. Wrap up now: stop opening new lines of investigation, summarize what you have established, state what you could not determine and why, and give the user your best answer. Only make another tool call if you are confident a single call will resolve the question.")
1011
1073
  end
1012
1074
 
1013
1075
  break if exhausted
@@ -1037,10 +1099,16 @@ module RailsConsoleAi
1037
1099
  if wrap_up_reason.nil? || wrap_up_reason == :round_cap
1038
1100
  $stdout.puts "\e[33m Hit tool round limit (#{max_rounds}). Forcing final answer. Increase with: RailsConsoleAi.configure { |c| c.max_tool_rounds = 200 }\e[0m"
1039
1101
  end
1040
- messages << { role: :user, content: final_nudge }
1041
- result = provider.chat(messages, system_prompt: active_system_prompt)
1102
+ add_nudge.call(final_nudge)
1103
+ # Must be chat_with_tools, not chat: the transcript contains
1104
+ # tool_use/tool_result blocks, and Bedrock/Anthropic reject those unless
1105
+ # the request also defines tools. Any tool calls in the response are
1106
+ # ignored — only the text is used.
1107
+ result = provider.chat_with_tools(messages, tools: tools, system_prompt: active_system_prompt)
1042
1108
  total_input += result.input_tokens || 0
1043
1109
  total_output += result.output_tokens || 0
1110
+ total_cache_read += result.cache_read_input_tokens || 0
1111
+ total_cache_write += result.cache_write_input_tokens || 0
1044
1112
  end
1045
1113
 
1046
1114
  last_llm_stats = result ? format_llm_stats(result) : nil
@@ -1048,6 +1116,8 @@ module RailsConsoleAi
1048
1116
  text: result ? result.text : '',
1049
1117
  input_tokens: total_input,
1050
1118
  output_tokens: total_output,
1119
+ cache_read_input_tokens: total_cache_read,
1120
+ cache_write_input_tokens: total_cache_write,
1051
1121
  stop_reason: result ? result.stop_reason : :end_turn
1052
1122
  )
1053
1123
  [final_result, new_messages, last_llm_stats]
@@ -1056,6 +1126,8 @@ module RailsConsoleAi
1056
1126
  def track_usage(result)
1057
1127
  @total_input_tokens += result.input_tokens || 0
1058
1128
  @total_output_tokens += result.output_tokens || 0
1129
+ @total_cache_read_tokens += result.cache_read_input_tokens || 0
1130
+ @total_cache_write_tokens += result.cache_write_input_tokens || 0
1059
1131
 
1060
1132
  model = effective_model
1061
1133
  @token_usage[model][:input] += result.input_tokens || 0
@@ -1104,6 +1176,8 @@ module RailsConsoleAi
1104
1176
  merged = attrs.merge(
1105
1177
  input_tokens: @total_input_tokens,
1106
1178
  output_tokens: @total_output_tokens,
1179
+ cache_read_tokens: @total_cache_read_tokens,
1180
+ cache_write_tokens: @total_cache_write_tokens,
1107
1181
  duration_ms: duration_ms,
1108
1182
  model: effective_model
1109
1183
  )
@@ -1122,6 +1196,18 @@ module RailsConsoleAi
1122
1196
  chars / 4
1123
1197
  end
1124
1198
 
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
+
1125
1211
  def format_tokens(count)
1126
1212
  if count >= 1_000_000
1127
1213
  "#{(count / 1_000_000.0).round(1)}M"
@@ -1474,14 +1560,10 @@ module RailsConsoleAi
1474
1560
  cache_w = result.cache_write_input_tokens || 0
1475
1561
  parts << "cache r: #{format_tokens(cache_r)} w: #{format_tokens(cache_w)}" if cache_r > 0 || cache_w > 0
1476
1562
  model = effective_model
1477
- pricing = Configuration.pricing_for(model)
1563
+ pricing = Configuration.pricing_for(model, cache_ttl: RailsConsoleAi.configuration.resolved_cache_ttl)
1478
1564
  if pricing
1479
- cost = ((result.input_tokens || 0) * pricing[:input]) + ((result.output_tokens || 0) * pricing[:output])
1480
- if (cache_r > 0 || cache_w > 0) && pricing[:cache_read]
1481
- cost -= cache_r * pricing[:input]
1482
- cost += cache_r * pricing[:cache_read]
1483
- cost += cache_w * (pricing[:cache_write] - pricing[:input])
1484
- end
1565
+ cost = usage_cost(pricing, input: result.input_tokens, output: result.output_tokens,
1566
+ cache_read: cache_r, cache_write: cache_w)
1485
1567
  parts << "~$#{'%.4f' % cost}"
1486
1568
  end
1487
1569
  parts.join(' | ')
@@ -1507,7 +1589,7 @@ module RailsConsoleAi
1507
1589
  input_t = result.input_tokens || 0
1508
1590
  output_t = result.output_tokens || 0
1509
1591
  model = effective_model
1510
- pricing = Configuration.pricing_for(model)
1592
+ pricing = Configuration.pricing_for(model, cache_ttl: RailsConsoleAi.configuration.resolved_cache_ttl)
1511
1593
  pricing ||= { input: 0.0, output: 0.0 } if RailsConsoleAi.configuration.provider == :local
1512
1594
 
1513
1595
  cache_r = result.cache_read_input_tokens || 0
@@ -1516,12 +1598,8 @@ module RailsConsoleAi
1516
1598
  parts << "cache r: #{format_tokens(cache_r)} w: #{format_tokens(cache_w)}" if cache_r > 0 || cache_w > 0
1517
1599
 
1518
1600
  if pricing
1519
- cost = (input_t * pricing[:input]) + (output_t * pricing[:output])
1520
- if (cache_r > 0 || cache_w > 0) && pricing[:cache_read]
1521
- cost -= cache_r * pricing[:input]
1522
- cost += cache_r * pricing[:cache_read]
1523
- cost += cache_w * (pricing[:cache_write] - pricing[:input])
1524
- end
1601
+ cost = usage_cost(pricing, input: input_t, output: output_t,
1602
+ cache_read: cache_r, cache_write: cache_w)
1525
1603
  session_cost = (total_input * pricing[:input]) + (total_output * pricing[:output])
1526
1604
  parts << "~$#{'%.4f' % cost}"
1527
1605
  $stderr.puts "\n#{d}[debug] ← response: #{parts.join(' | ')} (session: ~$#{'%.4f' % session_cost})#{r}"
@@ -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)
@@ -28,17 +28,82 @@ module RailsConsoleAi
28
28
 
29
29
  private
30
30
 
31
+ # Read and connect timeouts are separate budgets. Establishing the TCP/TLS
32
+ # connection either happens in a couple of seconds or is not going to, while
33
+ # generation legitimately takes minutes with adaptive thinking and a large
34
+ # output cap — sharing one value between them means either a connect timeout
35
+ # that hangs or a read timeout that cuts off generation mid-stream. A cut-off
36
+ # request loses the turn AND the tokens already spent producing it.
31
37
  def build_connection(url, headers = {})
32
38
  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
39
+ f.options.timeout = config.respond_to?(:resolved_timeout) ? config.resolved_timeout : config.timeout
40
+ f.options.open_timeout = config.respond_to?(:open_timeout) ? config.open_timeout : 10
36
41
  f.headers.update(headers)
37
42
  f.headers['Content-Type'] = 'application/json'
38
43
  f.adapter Faraday.default_adapter
39
44
  end
40
45
  end
41
46
 
47
+ # Transient failures worth another attempt: rate limits, upstream overload,
48
+ # and connections that never got established. Deliberately NOT retried:
49
+ #
50
+ # - Timeouts. A request that used its whole read budget is not obviously
51
+ # going to do better on a second try, and each retry both doubles the wait
52
+ # and pays again for a generation nobody will read. Raise instead, and say
53
+ # which knob to turn.
54
+ # - 4xx other than 429. A malformed request stays malformed.
55
+ RETRYABLE_STATUSES = [408, 409, 429, 500, 502, 503, 504, 529].freeze
56
+
57
+ def with_retries
58
+ max = config.respond_to?(:max_retries) ? config.max_retries.to_i : 2
59
+ attempt = 0
60
+
61
+ loop do
62
+ response = nil
63
+ reason = nil
64
+
65
+ begin
66
+ response = yield
67
+ rescue Faraday::TimeoutError
68
+ t = config.respond_to?(:resolved_timeout) ? config.resolved_timeout : config.timeout
69
+ raise ProviderError,
70
+ "Provider request timed out after #{t}s. Raise it with: " \
71
+ "RailsConsoleAi.configure { |c| c.timeout = #{t * 2} }"
72
+ rescue Faraday::ConnectionFailed, Faraday::SSLError => e
73
+ raise ProviderError, "Could not reach the provider: #{e.message}" if attempt >= max
74
+
75
+ reason = e.class.name
76
+ end
77
+
78
+ if response
79
+ return response if response.success?
80
+ return response unless RETRYABLE_STATUSES.include?(response.status)
81
+ return response if attempt >= max
82
+
83
+ reason = "HTTP #{response.status}"
84
+ end
85
+
86
+ delay = retry_delay(response, attempt)
87
+ RailsConsoleAi.logger.warn(
88
+ "RailsConsoleAi: #{reason} from provider, retrying in #{'%.1f' % delay}s " \
89
+ "(attempt #{attempt + 1} of #{max})"
90
+ )
91
+ sleep(delay)
92
+ attempt += 1
93
+ end
94
+ end
95
+
96
+ # Honour Retry-After when the server sends one; otherwise exponential backoff
97
+ # with jitter, so concurrent sessions don't retry in lockstep.
98
+ def retry_delay(response, attempt)
99
+ header = response && (response.headers['retry-after'] || response.headers['Retry-After'])
100
+ if header && header.to_f > 0
101
+ [header.to_f, 60.0].min
102
+ else
103
+ (2**attempt) + rand
104
+ end
105
+ end
106
+
42
107
  def debug_request(url, body)
43
108
  return unless config.debug
44
109
 
@@ -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)
@@ -59,7 +59,7 @@ module RailsConsoleAi
59
59
 
60
60
  json_body = JSON.generate(body)
61
61
  debug_request("#{API_URL}/v1/chat/completions", body)
62
- response = conn.post('/v1/chat/completions', json_body)
62
+ response = with_retries { conn.post('/v1/chat/completions', json_body) }
63
63
  debug_response(response.body)
64
64
  data = parse_response(response)
65
65
  usage = data['usage'] || {}
@@ -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)
@@ -739,7 +739,7 @@ module RailsConsoleAi
739
739
  total_cost = 0.0
740
740
 
741
741
  token_usage.each do |model, usage|
742
- pricing = Configuration.pricing_for(model)
742
+ pricing = Configuration.pricing_for(model, cache_ttl: RailsConsoleAi.configuration.resolved_cache_ttl)
743
743
  pricing ||= { input: 0.0, output: 0.0 } if RailsConsoleAi.configuration.provider == :local
744
744
  input_str = "in: #{usage[:input]}"
745
745
  output_str = "out: #{usage[:output]}"
@@ -749,9 +749,11 @@ module RailsConsoleAi
749
749
  cache_read = usage[:cache_read] || 0
750
750
  cache_write = usage[:cache_write] || 0
751
751
  if (cache_read > 0 || cache_write > 0) && pricing[:cache_read]
752
- cost -= cache_read * pricing[:input]
752
+ # input_tokens excludes cached tokens — bill each bucket at its own
753
+ # rate rather than discounting cache_read out of input (see
754
+ # ConversationEngine#display_cost_summary).
753
755
  cost += cache_read * pricing[:cache_read]
754
- cost += cache_write * (pricing[:cache_write] - pricing[:input])
756
+ cost += cache_write * pricing[:cache_write]
755
757
  end
756
758
  total_cost += cost
757
759
  cache_str = ""
@@ -144,13 +144,18 @@ module RailsConsoleAi
144
144
  end
145
145
 
146
146
  if exhausted
147
- messages << { role: :user, content: "Provide your best answer now based on what you've learned." }
148
- result = provider.chat(messages, system_prompt: system_prompt)
147
+ messages << { role: :user, content: "Provide your best answer now based on what you've learned. Do not call any more tools." }
148
+ # Must be chat_with_tools, not chat: the transcript contains
149
+ # tool_use/tool_result blocks, and Bedrock/Anthropic reject those unless
150
+ # the request also defines tools. Any tool calls in the response are
151
+ # ignored — only the text is used.
152
+ result = provider.chat_with_tools(messages, tools: tools, system_prompt: system_prompt)
149
153
  @input_tokens += result.input_tokens || 0
150
154
  @output_tokens += result.output_tokens || 0
151
155
  end
152
156
 
153
- result&.text || '(sub-agent returned no result)'
157
+ text = result&.text.to_s
158
+ text.strip.empty? ? '(sub-agent returned no result)' : text
154
159
  end
155
160
 
156
161
  def format_user_interruption(messages)
@@ -1,3 +1,3 @@
1
1
  module RailsConsoleAi
2
- VERSION = '0.34.0'.freeze
2
+ VERSION = '0.35.0'.freeze
3
3
  end
@@ -154,6 +154,8 @@ module RailsConsoleAi
154
154
  t.text :conversation, null: false
155
155
  t.integer :input_tokens, default: 0
156
156
  t.integer :output_tokens, default: 0
157
+ t.integer :cache_read_tokens, default: 0
158
+ t.integer :cache_write_tokens, default: 0
157
159
  t.string :user_name, limit: 255
158
160
  t.string :mode, limit: 20, null: false
159
161
  t.text :code_executed
@@ -397,6 +399,18 @@ module RailsConsoleAi
397
399
  migrations << 'options'
398
400
  end
399
401
 
402
+ # Without these, a session row records only the UNCACHED remainder of its
403
+ # input and the admin cost column reads near-zero for every cached session.
404
+ unless conn.column_exists?(table, :cache_read_tokens)
405
+ conn.add_column(table, :cache_read_tokens, :integer, default: 0)
406
+ migrations << 'cache_read_tokens'
407
+ end
408
+
409
+ unless conn.column_exists?(table, :cache_write_tokens)
410
+ conn.add_column(table, :cache_write_tokens, :integer, default: 0)
411
+ migrations << 'cache_write_tokens'
412
+ end
413
+
400
414
  unless conn.index_exists?(table, [:mode, :status], name: 'idx_rca_sessions_mode_status')
401
415
  conn.add_index(table, [:mode, :status], name: 'idx_rca_sessions_mode_status')
402
416
  migrations << 'idx_rca_sessions_mode_status'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_console_ai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.34.0
4
+ version: 0.35.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cortfr