rails_console_ai 0.33.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 +19 -0
- data/README.md +5 -3
- data/app/helpers/rails_console_ai/sessions_helper.rb +9 -2
- data/app/views/layouts/rails_console_ai/application.html.erb +5 -0
- data/app/views/rails_console_ai/sessions/index.html.erb +3 -1
- data/app/views/rails_console_ai/sessions/show.html.erb +6 -0
- data/lib/generators/rails_console_ai/templates/initializer.rb +12 -2
- data/lib/rails_console_ai/configuration.rb +60 -18
- data/lib/rails_console_ai/conversation_engine.rb +147 -49
- 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/safety_guards.rb +75 -0
- data/lib/rails_console_ai/session_logger.rb +17 -1
- 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,25 @@
|
|
|
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
|
+
|
|
17
|
+
## [0.34.0]
|
|
18
|
+
|
|
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
|
|
20
|
+
- Create/update the session record with a `running` status before each turn's tool loop starts, so a turn that hangs or dies mid-loop still leaves a visible session record, and show each session's status in the admin sessions page
|
|
21
|
+
- Make session logging drop attributes the sessions table doesn't have a column for yet, so a gem newer than the table degrades to a partial row instead of losing the insert
|
|
22
|
+
- Fix the sessions page cost display after the pricing refactor
|
|
23
|
+
|
|
5
24
|
## [0.33.0]
|
|
6
25
|
|
|
7
26
|
- Support Claude Opus 5 and make it the default model for the Anthropic and Bedrock providers
|
data/README.md
CHANGED
|
@@ -176,15 +176,17 @@ Safety guards prevent AI-generated code from causing side effects. When a guard
|
|
|
176
176
|
|
|
177
177
|
```ruby
|
|
178
178
|
RailsConsoleAi.configure do |config|
|
|
179
|
-
config.use_builtin_safety_guard :database_writes
|
|
180
|
-
config.use_builtin_safety_guard :http_mutations
|
|
181
|
-
config.use_builtin_safety_guard :mailers
|
|
179
|
+
config.use_builtin_safety_guard :database_writes # blocks INSERT/UPDATE/DELETE/DROP/etc.
|
|
180
|
+
config.use_builtin_safety_guard :http_mutations # blocks POST/PUT/PATCH/DELETE via Net::HTTP
|
|
181
|
+
config.use_builtin_safety_guard :mailers # disables ActionMailer delivery
|
|
182
|
+
config.use_builtin_safety_guard :in_process_requests # blocks in-process requests against the app itself
|
|
182
183
|
end
|
|
183
184
|
```
|
|
184
185
|
|
|
185
186
|
- **`:database_writes`** — intercepts the ActiveRecord connection adapter to block write SQL. Works on Rails 5+ with any database adapter.
|
|
186
187
|
- **`:http_mutations`** — intercepts `Net::HTTP#request` to block non-GET/HEAD/OPTIONS requests. Covers libraries built on Net::HTTP (HTTParty, RestClient, Faraday).
|
|
187
188
|
- **`:mailers`** — sets `ActionMailer::Base.perform_deliveries = false` during execution.
|
|
189
|
+
- **`:in_process_requests`** — blocks `ActionDispatch::Integration::Session` requests (the console `app` helper) and direct Rack dispatch (`Rails.application.call`). These run the app's full middleware stack inside the current process and can deadlock or hang the session thread indefinitely, so **all verbs are blocked, including GET**. Allowlist entries are request paths.
|
|
188
190
|
|
|
189
191
|
### Custom Guards
|
|
190
192
|
|
|
@@ -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
|
-
pricing = Configuration
|
|
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)
|
|
@@ -35,6 +35,11 @@
|
|
|
35
35
|
.badge-one_shot { background: #d4edda; color: #155724; }
|
|
36
36
|
.badge-interactive { background: #cce5ff; color: #004085; }
|
|
37
37
|
.badge-explain { background: #fff3cd; color: #856404; }
|
|
38
|
+
.badge-status-running { background: #fff3cd; color: #856404; }
|
|
39
|
+
.badge-status-queued { background: #e2e3e5; color: #383d41; }
|
|
40
|
+
.badge-status-ready { background: #d4edda; color: #155724; }
|
|
41
|
+
.badge-status-failed { background: #f8d7da; color: #721c24; }
|
|
42
|
+
.badge-status-aborted { background: #f8d7da; color: #721c24; }
|
|
38
43
|
.meta-card {
|
|
39
44
|
background: #fff; border-radius: 8px; padding: 20px;
|
|
40
45
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 24px;
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
<th>Name</th>
|
|
18
18
|
<th style="max-width: 400px;">Query</th>
|
|
19
19
|
<th>Mode</th>
|
|
20
|
+
<th>Status</th>
|
|
20
21
|
<th>Tokens</th>
|
|
21
22
|
<th>Cost</th>
|
|
22
23
|
<th>Duration</th>
|
|
@@ -31,7 +32,8 @@
|
|
|
31
32
|
<td><%= session.name.present? ? session.name : '-' %></td>
|
|
32
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>
|
|
33
34
|
<td><span class="badge badge-<%= session.mode %>"><%= session.mode %></span></td>
|
|
34
|
-
<td
|
|
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 + session.try(:cache_read_tokens).to_i + session.try(:cache_write_tokens).to_i %></td>
|
|
35
37
|
<td class="mono"><%= format_cost(session) %></td>
|
|
36
38
|
<td class="mono"><%= session.duration_ms ? "#{session.duration_ms}ms" : '-' %></td>
|
|
37
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
|
|
@@ -72,6 +75,13 @@ RailsConsoleAi.configure do |config|
|
|
|
72
75
|
# Built-in guard for mailers — disables ActionMailer delivery:
|
|
73
76
|
# config.use_builtin_safety_guard :mailers
|
|
74
77
|
#
|
|
78
|
+
# Built-in guard for in-process requests — blocks ActionDispatch::Integration::Session
|
|
79
|
+
# (the console `app` helper) and direct Rack dispatch against the running app.
|
|
80
|
+
# These run the full middleware stack inside this process and can hang the session
|
|
81
|
+
# thread indefinitely, so ALL verbs are blocked (including GET). Strongly recommended
|
|
82
|
+
# for Slack/API channels:
|
|
83
|
+
# config.use_builtin_safety_guard :in_process_requests
|
|
84
|
+
#
|
|
75
85
|
# config.safety_guard :jobs do |&execute|
|
|
76
86
|
# Sidekiq::Testing.fake! { execute.call }
|
|
77
87
|
# end
|
|
@@ -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
|
|
@@ -172,12 +204,13 @@ module RailsConsoleAi
|
|
|
172
204
|
end
|
|
173
205
|
|
|
174
206
|
# Register a built-in safety guard by name.
|
|
175
|
-
# Available: :database_writes, :http_mutations, :mailers
|
|
207
|
+
# Available: :database_writes, :http_mutations, :mailers, :in_process_requests
|
|
176
208
|
#
|
|
177
209
|
# Options:
|
|
178
210
|
# allow: Array of strings or regexps to allowlist for this guard.
|
|
179
|
-
# - :http_mutations
|
|
180
|
-
# - :database_writes
|
|
211
|
+
# - :http_mutations → hosts (e.g. "s3.amazonaws.com", /googleapis\.com/)
|
|
212
|
+
# - :database_writes → table names (e.g. "rails_console_ai_sessions")
|
|
213
|
+
# - :in_process_requests → request paths (e.g. "/health")
|
|
181
214
|
def use_builtin_safety_guard(name, allow: nil)
|
|
182
215
|
require 'rails_console_ai/safety_guards'
|
|
183
216
|
guard_name = name.to_sym
|
|
@@ -188,8 +221,10 @@ module RailsConsoleAi
|
|
|
188
221
|
safety_guards.add(:http_mutations, &BuiltinGuards.http_mutations)
|
|
189
222
|
when :mailers
|
|
190
223
|
safety_guards.add(:mailers, &BuiltinGuards.mailers)
|
|
224
|
+
when :in_process_requests
|
|
225
|
+
safety_guards.add(:in_process_requests, &BuiltinGuards.in_process_requests)
|
|
191
226
|
else
|
|
192
|
-
raise ConfigurationError, "Unknown built-in safety guard: #{name}. Available: database_writes, http_mutations, mailers"
|
|
227
|
+
raise ConfigurationError, "Unknown built-in safety guard: #{name}. Available: database_writes, http_mutations, mailers, in_process_requests"
|
|
193
228
|
end
|
|
194
229
|
|
|
195
230
|
if allow
|
|
@@ -242,6 +277,13 @@ module RailsConsoleAi
|
|
|
242
277
|
@temperature
|
|
243
278
|
end
|
|
244
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
|
+
|
|
245
287
|
def resolved_thinking_model
|
|
246
288
|
return @thinking_model if @thinking_model && !@thinking_model.empty?
|
|
247
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
|
|
@@ -242,6 +256,8 @@ module RailsConsoleAi
|
|
|
242
256
|
end
|
|
243
257
|
|
|
244
258
|
def execute_direct(raw_code)
|
|
259
|
+
@interactive_query ||= "> #{raw_code}"
|
|
260
|
+
log_interactive_turn(status: 'running')
|
|
245
261
|
exec_result = @executor.execute_unsafe(raw_code)
|
|
246
262
|
|
|
247
263
|
output_parts = []
|
|
@@ -260,14 +276,31 @@ module RailsConsoleAi
|
|
|
260
276
|
end
|
|
261
277
|
@history << { role: :user, content: context_msg, output_id: output_id }
|
|
262
278
|
|
|
263
|
-
@interactive_query ||= "> #{raw_code}"
|
|
264
279
|
@last_interactive_code = raw_code
|
|
265
280
|
@last_interactive_output = @executor.last_output
|
|
266
281
|
@last_interactive_result = exec_result ? exec_result.inspect : nil
|
|
267
282
|
@last_interactive_executed = true
|
|
283
|
+
log_interactive_turn(status: 'ready')
|
|
268
284
|
end
|
|
269
285
|
|
|
286
|
+
# Wraps run_turn with session-row status tracking. The row is created/updated
|
|
287
|
+
# with status 'running' BEFORE the tool loop starts, so a turn that hangs or
|
|
288
|
+
# dies mid-loop (hung eval, OOM-killed pod, deploy) still leaves a visible
|
|
289
|
+
# session record instead of vanishing without a trace.
|
|
270
290
|
def send_and_execute
|
|
291
|
+
log_interactive_turn(status: 'running')
|
|
292
|
+
status = run_turn
|
|
293
|
+
log_interactive_turn(status: status == :error ? 'failed' : 'ready')
|
|
294
|
+
status
|
|
295
|
+
rescue Interrupt
|
|
296
|
+
log_interactive_turn(status: 'ready')
|
|
297
|
+
raise
|
|
298
|
+
rescue StandardError
|
|
299
|
+
log_interactive_turn(status: 'failed')
|
|
300
|
+
raise
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def run_turn
|
|
271
304
|
begin
|
|
272
305
|
result, tool_messages, last_llm_stats = send_query(nil, conversation: @history)
|
|
273
306
|
rescue Providers::ProviderError => e
|
|
@@ -394,21 +427,16 @@ module RailsConsoleAi
|
|
|
394
427
|
$stdout.puts "\e[36m Cost estimate:\e[0m"
|
|
395
428
|
|
|
396
429
|
@token_usage.each do |model, usage|
|
|
397
|
-
pricing = Configuration.pricing_for(model)
|
|
430
|
+
pricing = Configuration.pricing_for(model, cache_ttl: RailsConsoleAi.configuration.resolved_cache_ttl)
|
|
398
431
|
pricing ||= { input: 0.0, output: 0.0 } if RailsConsoleAi.configuration.provider == :local
|
|
399
432
|
input_str = "in: #{format_tokens(usage[:input])}"
|
|
400
433
|
output_str = "out: #{format_tokens(usage[:output])}"
|
|
401
434
|
|
|
402
435
|
if pricing
|
|
403
|
-
cost = (usage[:input] * pricing[:input]) + (usage[:output] * pricing[:output])
|
|
404
436
|
cache_read = usage[:cache_read] || 0
|
|
405
437
|
cache_write = usage[:cache_write] || 0
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
cost -= cache_read * pricing[:input]
|
|
409
|
-
cost += cache_read * pricing[:cache_read]
|
|
410
|
-
cost += cache_write * (pricing[:cache_write] - pricing[:input])
|
|
411
|
-
end
|
|
438
|
+
cost = usage_cost(pricing, input: usage[:input], output: usage[:output],
|
|
439
|
+
cache_read: cache_read, cache_write: cache_write)
|
|
412
440
|
total_cost += cost
|
|
413
441
|
cache_str = ""
|
|
414
442
|
cache_str = " cache r: #{format_tokens(cache_read)} w: #{format_tokens(cache_write)}" if cache_read > 0 || cache_write > 0
|
|
@@ -447,15 +475,29 @@ module RailsConsoleAi
|
|
|
447
475
|
conversation_messages(messages, **opts)
|
|
448
476
|
end
|
|
449
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.
|
|
450
484
|
def context
|
|
451
485
|
base = @context_base ||= context_builder.build
|
|
452
486
|
parts = [base]
|
|
453
487
|
parts << safety_context
|
|
454
488
|
parts << @channel.system_instructions
|
|
455
|
-
parts << binding_variable_summary
|
|
456
489
|
parts.compact.join("\n\n")
|
|
457
490
|
end
|
|
458
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
|
+
|
|
459
501
|
AUTO_THINK_PATTERN = /\bthink\s+(harder|deeper|hard|carefully|more\s+carefully)\b/i
|
|
460
502
|
|
|
461
503
|
def maybe_auto_upgrade_thinking(text)
|
|
@@ -557,18 +599,32 @@ module RailsConsoleAi
|
|
|
557
599
|
end
|
|
558
600
|
end
|
|
559
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.
|
|
560
611
|
def warn_if_history_large
|
|
561
|
-
|
|
612
|
+
return if @compact_warned
|
|
562
613
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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"
|
|
567
623
|
end
|
|
568
624
|
|
|
569
625
|
# --- Session logging ---
|
|
570
626
|
|
|
571
|
-
def log_interactive_turn
|
|
627
|
+
def log_interactive_turn(status: nil)
|
|
572
628
|
require 'rails_console_ai/session_logger'
|
|
573
629
|
session_attrs = {
|
|
574
630
|
conversation: @history,
|
|
@@ -580,6 +636,7 @@ module RailsConsoleAi
|
|
|
580
636
|
executed: @last_interactive_executed,
|
|
581
637
|
console_output: @channel.respond_to?(:console_capture_string) ? @channel.console_capture_string : nil
|
|
582
638
|
}
|
|
639
|
+
session_attrs[:status] = status if status
|
|
583
640
|
|
|
584
641
|
if @interactive_session_id
|
|
585
642
|
SessionLogger.update(@interactive_session_id, session_attrs)
|
|
@@ -794,11 +851,33 @@ module RailsConsoleAi
|
|
|
794
851
|
max_rounds = RailsConsoleAi.configuration.max_tool_rounds
|
|
795
852
|
total_input = 0
|
|
796
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 }
|
|
797
865
|
result = nil
|
|
798
866
|
new_messages = []
|
|
799
867
|
last_thinking = nil
|
|
800
868
|
last_tool_names = []
|
|
801
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
|
+
|
|
802
881
|
exhausted = false
|
|
803
882
|
wrap_up_reason = nil
|
|
804
883
|
tool_call_counts = Hash.new(0)
|
|
@@ -835,7 +914,7 @@ module RailsConsoleAi
|
|
|
835
914
|
|
|
836
915
|
if round > 0
|
|
837
916
|
req_tokens = estimate_request_tokens(messages)
|
|
838
|
-
@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)}")
|
|
839
918
|
end
|
|
840
919
|
|
|
841
920
|
if RailsConsoleAi.configuration.debug
|
|
@@ -851,6 +930,8 @@ module RailsConsoleAi
|
|
|
851
930
|
end
|
|
852
931
|
total_input += result.input_tokens || 0
|
|
853
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
|
|
854
935
|
|
|
855
936
|
break if @channel.cancelled?
|
|
856
937
|
|
|
@@ -959,7 +1040,7 @@ module RailsConsoleAi
|
|
|
959
1040
|
wrap_up_reason ||= :tool_loop
|
|
960
1041
|
elsif tool_call_counts[key] >= LOOP_WARN_THRESHOLD
|
|
961
1042
|
@channel.display_status(" Warning: #{tc[:name]} called #{tool_call_counts[key]} times with same args — consider a different approach.")
|
|
962
|
-
|
|
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.")
|
|
963
1044
|
end
|
|
964
1045
|
end
|
|
965
1046
|
|
|
@@ -974,20 +1055,21 @@ module RailsConsoleAi
|
|
|
974
1055
|
elsif count >= REPEAT_ERROR_WARN_THRESHOLD && !warned_error_sigs.include?(sig)
|
|
975
1056
|
warned_error_sigs << sig
|
|
976
1057
|
@channel.display_status(" Warning: same error hit #{count} times — nudging model to change strategy.")
|
|
977
|
-
|
|
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.")
|
|
978
1059
|
end
|
|
979
1060
|
end
|
|
980
1061
|
|
|
981
1062
|
# Circuit breaker: token budget for a single tool loop.
|
|
982
1063
|
config = RailsConsoleAi.configuration
|
|
983
|
-
|
|
984
|
-
|
|
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.")
|
|
985
1067
|
exhausted = true
|
|
986
1068
|
wrap_up_reason ||= :token_budget
|
|
987
|
-
elsif config.token_nudge_threshold &&
|
|
1069
|
+
elsif config.token_nudge_threshold && prompt_tokens >= config.token_nudge_threshold && !token_nudge_sent
|
|
988
1070
|
token_nudge_sent = true
|
|
989
|
-
@channel.display_status(" High token usage (#{format_tokens(
|
|
990
|
-
|
|
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.")
|
|
991
1073
|
end
|
|
992
1074
|
|
|
993
1075
|
break if exhausted
|
|
@@ -1017,10 +1099,16 @@ module RailsConsoleAi
|
|
|
1017
1099
|
if wrap_up_reason.nil? || wrap_up_reason == :round_cap
|
|
1018
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"
|
|
1019
1101
|
end
|
|
1020
|
-
|
|
1021
|
-
|
|
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)
|
|
1022
1108
|
total_input += result.input_tokens || 0
|
|
1023
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
|
|
1024
1112
|
end
|
|
1025
1113
|
|
|
1026
1114
|
last_llm_stats = result ? format_llm_stats(result) : nil
|
|
@@ -1028,6 +1116,8 @@ module RailsConsoleAi
|
|
|
1028
1116
|
text: result ? result.text : '',
|
|
1029
1117
|
input_tokens: total_input,
|
|
1030
1118
|
output_tokens: total_output,
|
|
1119
|
+
cache_read_input_tokens: total_cache_read,
|
|
1120
|
+
cache_write_input_tokens: total_cache_write,
|
|
1031
1121
|
stop_reason: result ? result.stop_reason : :end_turn
|
|
1032
1122
|
)
|
|
1033
1123
|
[final_result, new_messages, last_llm_stats]
|
|
@@ -1036,6 +1126,8 @@ module RailsConsoleAi
|
|
|
1036
1126
|
def track_usage(result)
|
|
1037
1127
|
@total_input_tokens += result.input_tokens || 0
|
|
1038
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
|
|
1039
1131
|
|
|
1040
1132
|
model = effective_model
|
|
1041
1133
|
@token_usage[model][:input] += result.input_tokens || 0
|
|
@@ -1084,6 +1176,8 @@ module RailsConsoleAi
|
|
|
1084
1176
|
merged = attrs.merge(
|
|
1085
1177
|
input_tokens: @total_input_tokens,
|
|
1086
1178
|
output_tokens: @total_output_tokens,
|
|
1179
|
+
cache_read_tokens: @total_cache_read_tokens,
|
|
1180
|
+
cache_write_tokens: @total_cache_write_tokens,
|
|
1087
1181
|
duration_ms: duration_ms,
|
|
1088
1182
|
model: effective_model
|
|
1089
1183
|
)
|
|
@@ -1102,6 +1196,18 @@ module RailsConsoleAi
|
|
|
1102
1196
|
chars / 4
|
|
1103
1197
|
end
|
|
1104
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
|
+
|
|
1105
1211
|
def format_tokens(count)
|
|
1106
1212
|
if count >= 1_000_000
|
|
1107
1213
|
"#{(count / 1_000_000.0).round(1)}M"
|
|
@@ -1454,14 +1560,10 @@ module RailsConsoleAi
|
|
|
1454
1560
|
cache_w = result.cache_write_input_tokens || 0
|
|
1455
1561
|
parts << "cache r: #{format_tokens(cache_r)} w: #{format_tokens(cache_w)}" if cache_r > 0 || cache_w > 0
|
|
1456
1562
|
model = effective_model
|
|
1457
|
-
pricing = Configuration.pricing_for(model)
|
|
1563
|
+
pricing = Configuration.pricing_for(model, cache_ttl: RailsConsoleAi.configuration.resolved_cache_ttl)
|
|
1458
1564
|
if pricing
|
|
1459
|
-
cost = (
|
|
1460
|
-
|
|
1461
|
-
cost -= cache_r * pricing[:input]
|
|
1462
|
-
cost += cache_r * pricing[:cache_read]
|
|
1463
|
-
cost += cache_w * (pricing[:cache_write] - pricing[:input])
|
|
1464
|
-
end
|
|
1565
|
+
cost = usage_cost(pricing, input: result.input_tokens, output: result.output_tokens,
|
|
1566
|
+
cache_read: cache_r, cache_write: cache_w)
|
|
1465
1567
|
parts << "~$#{'%.4f' % cost}"
|
|
1466
1568
|
end
|
|
1467
1569
|
parts.join(' | ')
|
|
@@ -1487,7 +1589,7 @@ module RailsConsoleAi
|
|
|
1487
1589
|
input_t = result.input_tokens || 0
|
|
1488
1590
|
output_t = result.output_tokens || 0
|
|
1489
1591
|
model = effective_model
|
|
1490
|
-
pricing = Configuration.pricing_for(model)
|
|
1592
|
+
pricing = Configuration.pricing_for(model, cache_ttl: RailsConsoleAi.configuration.resolved_cache_ttl)
|
|
1491
1593
|
pricing ||= { input: 0.0, output: 0.0 } if RailsConsoleAi.configuration.provider == :local
|
|
1492
1594
|
|
|
1493
1595
|
cache_r = result.cache_read_input_tokens || 0
|
|
@@ -1496,12 +1598,8 @@ module RailsConsoleAi
|
|
|
1496
1598
|
parts << "cache r: #{format_tokens(cache_r)} w: #{format_tokens(cache_w)}" if cache_r > 0 || cache_w > 0
|
|
1497
1599
|
|
|
1498
1600
|
if pricing
|
|
1499
|
-
cost = (input_t
|
|
1500
|
-
|
|
1501
|
-
cost -= cache_r * pricing[:input]
|
|
1502
|
-
cost += cache_r * pricing[:cache_read]
|
|
1503
|
-
cost += cache_w * (pricing[:cache_write] - pricing[:input])
|
|
1504
|
-
end
|
|
1601
|
+
cost = usage_cost(pricing, input: input_t, output: output_t,
|
|
1602
|
+
cache_read: cache_r, cache_write: cache_w)
|
|
1505
1603
|
session_cost = (total_input * pricing[:input]) + (total_output * pricing[:output])
|
|
1506
1604
|
parts << "~$#{'%.4f' % cost}"
|
|
1507
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'] || {}
|
|
@@ -364,6 +364,81 @@ module RailsConsoleAi
|
|
|
364
364
|
}
|
|
365
365
|
end
|
|
366
366
|
|
|
367
|
+
# Blocks in-process HTTP dispatch against the running app itself.
|
|
368
|
+
# An ActionDispatch::Integration::Session request (the console `app` helper,
|
|
369
|
+
# or a manually built integration session) runs the app's full middleware
|
|
370
|
+
# stack inside the current process and can deadlock or hang the session
|
|
371
|
+
# thread indefinitely — so ALL verbs are blocked, including GET.
|
|
372
|
+
module InProcessRequestBlocker
|
|
373
|
+
def process(*args, **kwargs, &block)
|
|
374
|
+
RailsConsoleAi::BuiltinGuards.check_in_process_request!(args[0], args[1])
|
|
375
|
+
super
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Backstop for the same hazard via direct Rack dispatch
|
|
380
|
+
# (e.g. Rails.application.call(env)), which bypasses Integration::Session.
|
|
381
|
+
module EngineCallBlocker
|
|
382
|
+
def call(env, *args)
|
|
383
|
+
if env.is_a?(Hash)
|
|
384
|
+
RailsConsoleAi::BuiltinGuards.check_in_process_request!(env['REQUEST_METHOD'], env['PATH_INFO'])
|
|
385
|
+
end
|
|
386
|
+
super
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def self.check_in_process_request!(http_method, path)
|
|
391
|
+
return unless Thread.current[:rails_console_ai_block_in_process_requests]
|
|
392
|
+
return if Thread.current[:rails_console_ai_bypass_guards]
|
|
393
|
+
|
|
394
|
+
key = path.to_s
|
|
395
|
+
guards = RailsConsoleAi.configuration.safety_guards
|
|
396
|
+
return if !key.empty? && guards.allowed?(:in_process_requests, key)
|
|
397
|
+
|
|
398
|
+
label = [http_method.to_s.upcase, key].reject(&:empty?).join(' ')
|
|
399
|
+
raise RailsConsoleAi::SafetyError.new(
|
|
400
|
+
"In-process HTTP request blocked (#{label.empty? ? 'app dispatch' : label}). " \
|
|
401
|
+
"Dispatching a request through the app's own middleware stack from this session " \
|
|
402
|
+
"can hang the process indefinitely, even for GET. Do not retry via another route " \
|
|
403
|
+
"or Rack — call the controller's underlying service or model code directly instead.",
|
|
404
|
+
guard: :in_process_requests,
|
|
405
|
+
blocked_key: key.empty? ? nil : key
|
|
406
|
+
)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def self.in_process_requests
|
|
410
|
+
->(&block) {
|
|
411
|
+
ensure_in_process_blocker_installed!
|
|
412
|
+
prev = Thread.current[:rails_console_ai_block_in_process_requests]
|
|
413
|
+
Thread.current[:rails_console_ai_block_in_process_requests] = true
|
|
414
|
+
begin
|
|
415
|
+
block.call
|
|
416
|
+
ensure
|
|
417
|
+
Thread.current[:rails_console_ai_block_in_process_requests] = prev
|
|
418
|
+
end
|
|
419
|
+
}
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def self.ensure_in_process_blocker_installed!
|
|
423
|
+
return if @in_process_blocker_installed
|
|
424
|
+
|
|
425
|
+
begin
|
|
426
|
+
require 'action_dispatch'
|
|
427
|
+
require 'action_dispatch/testing/integration'
|
|
428
|
+
rescue LoadError, NameError
|
|
429
|
+
nil # actionpack not (fully) available — the Engine backstop may still apply
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
if defined?(ActionDispatch::Integration::Session) &&
|
|
433
|
+
!ActionDispatch::Integration::Session.ancestors.include?(InProcessRequestBlocker)
|
|
434
|
+
ActionDispatch::Integration::Session.prepend(InProcessRequestBlocker)
|
|
435
|
+
end
|
|
436
|
+
if defined?(Rails::Engine) && !Rails::Engine.ancestors.include?(EngineCallBlocker)
|
|
437
|
+
Rails::Engine.prepend(EngineCallBlocker)
|
|
438
|
+
end
|
|
439
|
+
@in_process_blocker_installed = true
|
|
440
|
+
end
|
|
441
|
+
|
|
367
442
|
def self.ensure_http_blocker_installed!
|
|
368
443
|
return if @http_blocker_installed
|
|
369
444
|
|
|
@@ -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],
|
|
@@ -32,7 +34,7 @@ module RailsConsoleAi
|
|
|
32
34
|
opts = attrs[:options]
|
|
33
35
|
create_attrs[:options] = opts.is_a?(String) ? opts : opts.to_json
|
|
34
36
|
end
|
|
35
|
-
record = session_class.create!(create_attrs)
|
|
37
|
+
record = session_class.create!(filter_to_columns(create_attrs))
|
|
36
38
|
record.id
|
|
37
39
|
rescue => e
|
|
38
40
|
msg = "RailsConsoleAi: session logging failed: #{e.class}: #{e.message}"
|
|
@@ -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)
|
|
@@ -70,6 +74,7 @@ module RailsConsoleAi
|
|
|
70
74
|
updates[:result] = attrs[:result] if attrs.key?(:result)
|
|
71
75
|
updates[:error_message] = attrs[:error_message] if attrs.key?(:error_message)
|
|
72
76
|
|
|
77
|
+
updates = filter_to_columns(updates)
|
|
73
78
|
session_class.where(id: id).update_all(updates) unless updates.empty?
|
|
74
79
|
rescue => e
|
|
75
80
|
msg = "RailsConsoleAi: session update failed: #{e.class}: #{e.message}"
|
|
@@ -80,6 +85,17 @@ module RailsConsoleAi
|
|
|
80
85
|
|
|
81
86
|
private
|
|
82
87
|
|
|
88
|
+
# Drop attrs the table doesn't have a column for, so a gem that's newer
|
|
89
|
+
# than the table (e.g. status added before RailsConsoleAi.migrate! ran)
|
|
90
|
+
# degrades to a partial row instead of losing the whole insert.
|
|
91
|
+
def filter_to_columns(attrs)
|
|
92
|
+
return attrs unless session_class.respond_to?(:column_names)
|
|
93
|
+
cols = session_class.column_names.map(&:to_s)
|
|
94
|
+
attrs.select { |k, _| cols.include?(k.to_s) }
|
|
95
|
+
rescue StandardError
|
|
96
|
+
attrs
|
|
97
|
+
end
|
|
98
|
+
|
|
83
99
|
def table_exists?
|
|
84
100
|
# Only cache positive results — retry on failure so transient
|
|
85
101
|
# errors (boot timing, connection not ready) don't stick forever
|
|
@@ -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'
|