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 +4 -4
- data/CHANGELOG.md +12 -0
- data/app/helpers/rails_console_ai/sessions_helper.rb +8 -1
- data/app/views/rails_console_ai/sessions/index.html.erb +1 -1
- data/app/views/rails_console_ai/sessions/show.html.erb +6 -0
- data/lib/generators/rails_console_ai/templates/initializer.rb +5 -2
- data/lib/rails_console_ai/configuration.rb +53 -14
- data/lib/rails_console_ai/conversation_engine.rb +125 -47
- data/lib/rails_console_ai/providers/anthropic.rb +53 -9
- data/lib/rails_console_ai/providers/base.rb +68 -3
- data/lib/rails_console_ai/providers/bedrock.rb +47 -3
- data/lib/rails_console_ai/providers/openai.rb +1 -1
- data/lib/rails_console_ai/session_logger.rb +4 -0
- data/lib/rails_console_ai/slack_bot.rb +5 -3
- data/lib/rails_console_ai/sub_agent.rb +8 -3
- data/lib/rails_console_ai/version.rb +1 -1
- data/lib/rails_console_ai.rb +14 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 37ef99b1cc4572e89283e5210b15e6110bac2b0adc758a1b7d222c603c4557c2
|
|
4
|
+
data.tar.gz: 511f76003fdc00ce0104b506eaa71d7de918e3cf1f62eebd395308461caeefd1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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]) +
|
|
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
|
-
#
|
|
24
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
426
|
-
|
|
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
|
-
|
|
612
|
+
return if @compact_warned
|
|
581
613
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1004
|
-
|
|
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 &&
|
|
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(
|
|
1010
|
-
|
|
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
|
-
|
|
1041
|
-
|
|
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 = (
|
|
1480
|
-
|
|
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
|
|
1520
|
-
|
|
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' =>
|
|
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'] =
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
34
|
-
f.options.
|
|
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 <<
|
|
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 <<
|
|
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
|
-
|
|
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 *
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
data/lib/rails_console_ai.rb
CHANGED
|
@@ -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'
|