rails_console_ai 0.34.0 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +56 -2
- data/app/helpers/rails_console_ai/sessions_helper.rb +11 -4
- 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 +12 -3
- data/lib/rails_console_ai/channel/console.rb +120 -15
- data/lib/rails_console_ai/configuration.rb +97 -19
- data/lib/rails_console_ai/conversation_engine.rb +241 -63
- data/lib/rails_console_ai/line_editor.rb +142 -0
- data/lib/rails_console_ai/providers/anthropic.rb +53 -9
- data/lib/rails_console_ai/providers/base.rb +74 -4
- data/lib/rails_console_ai/providers/bedrock.rb +47 -3
- data/lib/rails_console_ai/providers/local.rb +16 -36
- data/lib/rails_console_ai/providers/openai.rb +30 -9
- data/lib/rails_console_ai/providers/openrouter.rb +100 -0
- data/lib/rails_console_ai/session_logger.rb +4 -0
- data/lib/rails_console_ai/slack_bot.rb +19 -10
- data/lib/rails_console_ai/slash_commands.rb +149 -0
- data/lib/rails_console_ai/sub_agent.rb +15 -5
- data/lib/rails_console_ai/tools/registry.rb +2 -2
- data/lib/rails_console_ai/version.rb +1 -1
- data/lib/rails_console_ai.rb +15 -0
- metadata +4 -1
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
module RailsConsoleAi
|
|
2
|
+
# Thin adapters over the two line editors we can drive, so the interactive loop
|
|
3
|
+
# doesn't care which one is underneath.
|
|
4
|
+
#
|
|
5
|
+
# Reline (bundled with Ruby >= 2.7) is preferred: it renders a live completion
|
|
6
|
+
# dropdown as you type, which is what makes "/" discoverable. Readline is the
|
|
7
|
+
# fallback and only offers Tab completion.
|
|
8
|
+
#
|
|
9
|
+
# The two differ in four ways that matter here, all absorbed by the adapters:
|
|
10
|
+
# - prompt escaping: Readline needs \001..\002 around ANSI so it can compute
|
|
11
|
+
# the prompt width; Reline parses ANSI itself and would print those literally.
|
|
12
|
+
# - output: Reline writes through a Ruby IO, so it has to be pointed at the real
|
|
13
|
+
# stdout — otherwise the dropdown's escape codes land in the captured session log.
|
|
14
|
+
# Readline writes to its own C-level stream and bypasses $stdout entirely.
|
|
15
|
+
# - key binding: Readline has parse_and_bind, Reline has add_default_key_binding.
|
|
16
|
+
# - what a completion candidate may contain: see #matches in each adapter.
|
|
17
|
+
module LineEditor
|
|
18
|
+
# Shift-Tab: jump to line start, kill the line, type /auto, submit.
|
|
19
|
+
# Reline binds real bytes; Readline's inputrc parser wants the escapes
|
|
20
|
+
# un-interpreted, so it gets the backslash form verbatim.
|
|
21
|
+
SHIFT_TAB = "\e[Z".freeze
|
|
22
|
+
AUTO_MACRO = "\C-a\C-k/auto\C-m".freeze
|
|
23
|
+
INPUTRC_BIND = '"\e[Z": "\C-a\C-k/auto\C-m"'.freeze
|
|
24
|
+
|
|
25
|
+
def self.resolve(preference = nil, output: nil)
|
|
26
|
+
preference = (preference || :auto).to_sym
|
|
27
|
+
editor =
|
|
28
|
+
case preference
|
|
29
|
+
when :readline then readline_adapter
|
|
30
|
+
when :reline then reline_adapter || readline_adapter
|
|
31
|
+
else reline_adapter || readline_adapter
|
|
32
|
+
end
|
|
33
|
+
editor.output = output if output && editor.respond_to?(:output=)
|
|
34
|
+
editor
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.reline_adapter
|
|
38
|
+
require 'reline'
|
|
39
|
+
Reline.respond_to?(:autocompletion=) ? Reline_.new : nil
|
|
40
|
+
rescue LoadError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.readline_adapter
|
|
45
|
+
require 'readline'
|
|
46
|
+
Readline_.new
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class Base
|
|
50
|
+
def name; self.class.name.split('::').last.chomp('_').downcase; end
|
|
51
|
+
|
|
52
|
+
# Candidates come from a proc so the list stays live — skills and agents can
|
|
53
|
+
# be created mid-session. The proc returns [slug, label] pairs, where the
|
|
54
|
+
# label says what kind of thing the slug is ("command", "skill", "agent").
|
|
55
|
+
def complete_with(&candidates); @candidates = candidates; self; end
|
|
56
|
+
|
|
57
|
+
def matches(target)
|
|
58
|
+
matching(target).map(&:first)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def matching(target)
|
|
64
|
+
return [] unless target.to_s.start_with?('/')
|
|
65
|
+
entries = @candidates ? @candidates.call : []
|
|
66
|
+
entries.select { |slug, _| slug.start_with?(target) }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
class Reline_ < Base
|
|
71
|
+
def initialize
|
|
72
|
+
Reline.autocompletion = true
|
|
73
|
+
Reline.completion_append_character = ' '
|
|
74
|
+
Reline.completion_proc = ->(target) { matches(target) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def output=(io); Reline.output = io; end
|
|
78
|
+
|
|
79
|
+
def prompt(text, color)
|
|
80
|
+
"#{color}#{text}\e[0m"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def readline(prompt)
|
|
84
|
+
Reline.readline(prompt, false)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def push_history(line)
|
|
88
|
+
Reline::HISTORY.push(line) unless line == Reline::HISTORY.to_a.last
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def bind_auto_toggle
|
|
92
|
+
Reline.core.config.add_default_key_binding(SHIFT_TAB.bytes, AUTO_MACRO.bytes)
|
|
93
|
+
rescue StandardError
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Reline's menu inserts whichever row you arrow onto, verbatim, so a
|
|
98
|
+
# candidate has to be exactly the text that belongs in the buffer. No labels.
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
class Readline_ < Base
|
|
102
|
+
def initialize
|
|
103
|
+
Readline.completion_append_character = ' '
|
|
104
|
+
Readline.completion_proc = ->(target) { matches(target) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def prompt(text, color)
|
|
108
|
+
"\001#{color}\002#{text}\001\e[0m\002"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def readline(prompt)
|
|
112
|
+
Readline.readline(prompt, false)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def push_history(line)
|
|
116
|
+
Readline::HISTORY.push(line) unless line == Readline::HISTORY.to_a.last
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def bind_auto_toggle
|
|
120
|
+
return unless Readline.respond_to?(:parse_and_bind)
|
|
121
|
+
Readline.parse_and_bind(INPUTRC_BIND)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Readline only ever inserts the common prefix of the candidates it is given,
|
|
125
|
+
# and displays the rest for the eye alone — so when there is more than one
|
|
126
|
+
# match we can append a kind label without it ever reaching the buffer. That
|
|
127
|
+
# is what makes a built-in command distinguishable from a skill or an agent
|
|
128
|
+
# in the Tab list. The labels sit past the point where the slugs diverge, so
|
|
129
|
+
# they can't lengthen the common prefix either.
|
|
130
|
+
#
|
|
131
|
+
# A lone match is different: there the common prefix IS the whole candidate,
|
|
132
|
+
# so it must be the bare slug, and Readline appends its trailing space.
|
|
133
|
+
def matches(target)
|
|
134
|
+
found = matching(target)
|
|
135
|
+
return found.map(&:first) if found.size <= 1
|
|
136
|
+
|
|
137
|
+
width = found.map { |slug, _| slug.length }.max + 2
|
|
138
|
+
found.map { |slug, label| "#{slug.ljust(width)}#{label}" }
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -51,24 +51,24 @@ module RailsConsoleAi
|
|
|
51
51
|
body = {
|
|
52
52
|
model: config.resolved_model,
|
|
53
53
|
max_tokens: config.resolved_max_tokens,
|
|
54
|
-
messages: format_messages(messages)
|
|
54
|
+
messages: mark_conversation_breakpoint(format_messages(messages))
|
|
55
55
|
}
|
|
56
56
|
temp = config.resolved_temperature
|
|
57
57
|
body[:temperature] = temp unless temp.nil?
|
|
58
58
|
if system_prompt
|
|
59
59
|
body[:system] = [
|
|
60
|
-
{ 'type' => 'text', 'text' => system_prompt, 'cache_control' =>
|
|
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)
|
|
@@ -5,6 +5,7 @@ module RailsConsoleAi
|
|
|
5
5
|
module Providers
|
|
6
6
|
class Base
|
|
7
7
|
attr_reader :config
|
|
8
|
+
attr_accessor :routing_session_id
|
|
8
9
|
|
|
9
10
|
def initialize(config = RailsConsoleAi.configuration)
|
|
10
11
|
@config = config
|
|
@@ -28,17 +29,82 @@ module RailsConsoleAi
|
|
|
28
29
|
|
|
29
30
|
private
|
|
30
31
|
|
|
32
|
+
# Read and connect timeouts are separate budgets. Establishing the TCP/TLS
|
|
33
|
+
# connection either happens in a couple of seconds or is not going to, while
|
|
34
|
+
# generation legitimately takes minutes with adaptive thinking and a large
|
|
35
|
+
# output cap — sharing one value between them means either a connect timeout
|
|
36
|
+
# that hangs or a read timeout that cuts off generation mid-stream. A cut-off
|
|
37
|
+
# request loses the turn AND the tokens already spent producing it.
|
|
31
38
|
def build_connection(url, headers = {})
|
|
32
39
|
Faraday.new(url: url) do |f|
|
|
33
|
-
|
|
34
|
-
f.options.
|
|
35
|
-
f.options.open_timeout = t
|
|
40
|
+
f.options.timeout = config.respond_to?(:resolved_timeout) ? config.resolved_timeout : config.timeout
|
|
41
|
+
f.options.open_timeout = config.respond_to?(:open_timeout) ? config.open_timeout : 10
|
|
36
42
|
f.headers.update(headers)
|
|
37
43
|
f.headers['Content-Type'] = 'application/json'
|
|
38
44
|
f.adapter Faraday.default_adapter
|
|
39
45
|
end
|
|
40
46
|
end
|
|
41
47
|
|
|
48
|
+
# Transient failures worth another attempt: rate limits, upstream overload,
|
|
49
|
+
# and connections that never got established. Deliberately NOT retried:
|
|
50
|
+
#
|
|
51
|
+
# - Timeouts. A request that used its whole read budget is not obviously
|
|
52
|
+
# going to do better on a second try, and each retry both doubles the wait
|
|
53
|
+
# and pays again for a generation nobody will read. Raise instead, and say
|
|
54
|
+
# which knob to turn.
|
|
55
|
+
# - 4xx other than 429. A malformed request stays malformed.
|
|
56
|
+
RETRYABLE_STATUSES = [408, 409, 429, 500, 502, 503, 504, 529].freeze
|
|
57
|
+
|
|
58
|
+
def with_retries
|
|
59
|
+
max = config.respond_to?(:max_retries) ? config.max_retries.to_i : 2
|
|
60
|
+
attempt = 0
|
|
61
|
+
|
|
62
|
+
loop do
|
|
63
|
+
response = nil
|
|
64
|
+
reason = nil
|
|
65
|
+
|
|
66
|
+
begin
|
|
67
|
+
response = yield
|
|
68
|
+
rescue Faraday::TimeoutError
|
|
69
|
+
t = config.respond_to?(:resolved_timeout) ? config.resolved_timeout : config.timeout
|
|
70
|
+
raise ProviderError,
|
|
71
|
+
"Provider request timed out after #{t}s. Raise it with: " \
|
|
72
|
+
"RailsConsoleAi.configure { |c| c.timeout = #{t * 2} }"
|
|
73
|
+
rescue Faraday::ConnectionFailed, Faraday::SSLError => e
|
|
74
|
+
raise ProviderError, "Could not reach the provider: #{e.message}" if attempt >= max
|
|
75
|
+
|
|
76
|
+
reason = e.class.name
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
if response
|
|
80
|
+
return response if response.success?
|
|
81
|
+
return response unless RETRYABLE_STATUSES.include?(response.status)
|
|
82
|
+
return response if attempt >= max
|
|
83
|
+
|
|
84
|
+
reason = "HTTP #{response.status}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
delay = retry_delay(response, attempt)
|
|
88
|
+
RailsConsoleAi.logger.warn(
|
|
89
|
+
"RailsConsoleAi: #{reason} from provider, retrying in #{'%.1f' % delay}s " \
|
|
90
|
+
"(attempt #{attempt + 1} of #{max})"
|
|
91
|
+
)
|
|
92
|
+
sleep(delay)
|
|
93
|
+
attempt += 1
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Honour Retry-After when the server sends one; otherwise exponential backoff
|
|
98
|
+
# with jitter, so concurrent sessions don't retry in lockstep.
|
|
99
|
+
def retry_delay(response, attempt)
|
|
100
|
+
header = response && (response.headers['retry-after'] || response.headers['Retry-After'])
|
|
101
|
+
if header && header.to_f > 0
|
|
102
|
+
[header.to_f, 60.0].min
|
|
103
|
+
else
|
|
104
|
+
(2**attempt) + rand
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
42
108
|
def debug_request(url, body)
|
|
43
109
|
return unless config.debug
|
|
44
110
|
|
|
@@ -85,7 +151,7 @@ module RailsConsoleAi
|
|
|
85
151
|
class ProviderError < StandardError; end
|
|
86
152
|
|
|
87
153
|
ChatResult = Struct.new(:text, :input_tokens, :output_tokens, :tool_calls, :stop_reason,
|
|
88
|
-
:cache_read_input_tokens, :cache_write_input_tokens, keyword_init: true) do
|
|
154
|
+
:cache_read_input_tokens, :cache_write_input_tokens, :cost, keyword_init: true) do
|
|
89
155
|
def total_tokens
|
|
90
156
|
(input_tokens || 0) + (output_tokens || 0)
|
|
91
157
|
end
|
|
@@ -103,6 +169,10 @@ module RailsConsoleAi
|
|
|
103
169
|
when :openai
|
|
104
170
|
require 'rails_console_ai/providers/openai'
|
|
105
171
|
OpenAI.new(config)
|
|
172
|
+
when :openrouter
|
|
173
|
+
require 'rails_console_ai/providers/openai'
|
|
174
|
+
require 'rails_console_ai/providers/openrouter'
|
|
175
|
+
OpenRouter.new(config)
|
|
106
176
|
when :local
|
|
107
177
|
require 'rails_console_ai/providers/openai'
|
|
108
178
|
require 'rails_console_ai/providers/local'
|
|
@@ -46,17 +46,17 @@ module RailsConsoleAi
|
|
|
46
46
|
inference[:temperature] = temp unless temp.nil?
|
|
47
47
|
params = {
|
|
48
48
|
model_id: config.resolved_model,
|
|
49
|
-
messages: format_messages(messages),
|
|
49
|
+
messages: mark_conversation_breakpoint(format_messages(messages)),
|
|
50
50
|
inference_config: inference
|
|
51
51
|
}
|
|
52
52
|
if system_prompt
|
|
53
53
|
sys_blocks = [{ text: system_prompt }]
|
|
54
|
-
sys_blocks <<
|
|
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)
|
|
@@ -3,38 +3,19 @@ module RailsConsoleAi
|
|
|
3
3
|
class Local < OpenAI
|
|
4
4
|
private
|
|
5
5
|
|
|
6
|
-
def
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
headers = { 'Content-Type' => 'application/json' }
|
|
10
|
-
api_key = config.local_api_key
|
|
11
|
-
if api_key && api_key != 'no-key' && !api_key.empty?
|
|
12
|
-
headers['Authorization'] = "Bearer #{api_key}"
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
conn = build_connection(base_url, headers)
|
|
16
|
-
|
|
17
|
-
formatted = []
|
|
18
|
-
formatted << { role: 'system', content: system_prompt } if system_prompt
|
|
19
|
-
formatted.concat(format_messages(messages))
|
|
20
|
-
|
|
21
|
-
body = {
|
|
22
|
-
model: config.resolved_model,
|
|
23
|
-
max_tokens: config.resolved_max_tokens,
|
|
24
|
-
messages: formatted
|
|
25
|
-
}
|
|
26
|
-
temp = config.resolved_temperature
|
|
27
|
-
body[:temperature] = temp unless temp.nil?
|
|
28
|
-
body[:tools] = tools.to_openai_format if tools
|
|
6
|
+
def api_base
|
|
7
|
+
config.local_url
|
|
8
|
+
end
|
|
29
9
|
|
|
30
|
-
|
|
10
|
+
def request_headers
|
|
11
|
+
key = config.local_api_key
|
|
12
|
+
return {} if key.nil? || key.empty? || key == 'no-key'
|
|
13
|
+
{ 'Authorization' => "Bearer #{key}" }
|
|
14
|
+
end
|
|
31
15
|
|
|
32
|
-
|
|
33
|
-
debug_request("#{base_url}/v1/chat/completions", body)
|
|
34
|
-
response = conn.post('/v1/chat/completions', json_body)
|
|
35
|
-
debug_response(response.body)
|
|
36
|
-
data = parse_response(response)
|
|
16
|
+
def build_result(data, body:, tools: nil)
|
|
37
17
|
usage = data['usage'] || {}
|
|
18
|
+
estimated_input_tokens = estimate_tokens(body)
|
|
38
19
|
|
|
39
20
|
prompt_tokens = usage['prompt_tokens']
|
|
40
21
|
if prompt_tokens && estimated_input_tokens > 0 && prompt_tokens < estimated_input_tokens * 0.5
|
|
@@ -50,9 +31,6 @@ module RailsConsoleAi
|
|
|
50
31
|
|
|
51
32
|
tool_calls = extract_tool_calls(message)
|
|
52
33
|
|
|
53
|
-
# Fallback: some local models (e.g. Ollama) emit tool calls as JSON
|
|
54
|
-
# in the content field instead of using the structured tool_calls format.
|
|
55
|
-
# Only match when the JSON "name" is a known tool name to avoid false positives.
|
|
56
34
|
if tool_calls.empty? && tools
|
|
57
35
|
tool_names = tools.to_openai_format.map { |t| t.dig('function', 'name') }.compact
|
|
58
36
|
text_calls = extract_tool_calls_from_text(message['content'], tool_names)
|
|
@@ -74,10 +52,12 @@ module RailsConsoleAi
|
|
|
74
52
|
)
|
|
75
53
|
end
|
|
76
54
|
|
|
77
|
-
def estimate_tokens(
|
|
78
|
-
chars =
|
|
79
|
-
messages
|
|
80
|
-
|
|
55
|
+
def estimate_tokens(body)
|
|
56
|
+
chars = 0
|
|
57
|
+
(body[:messages] || []).each do |m|
|
|
58
|
+
chars += m[:content].to_s.length + (m[:tool_calls].to_s.length)
|
|
59
|
+
end
|
|
60
|
+
chars += body[:tools].to_s.length if body[:tools]
|
|
81
61
|
chars / 4
|
|
82
62
|
end
|
|
83
63
|
|
|
@@ -40,12 +40,35 @@ module RailsConsoleAi
|
|
|
40
40
|
private
|
|
41
41
|
|
|
42
42
|
def call_api(messages, system_prompt: nil, tools: nil)
|
|
43
|
-
conn = build_connection(
|
|
44
|
-
|
|
45
|
-
})
|
|
43
|
+
conn = build_connection(api_base, request_headers)
|
|
44
|
+
body = build_body(messages, system_prompt: system_prompt, tools: tools)
|
|
45
|
+
debug_request("#{api_base}#{endpoint_path}", body)
|
|
46
|
+
response = with_retries { conn.post(endpoint_path, JSON.generate(body)) }
|
|
47
|
+
debug_response(response.body)
|
|
48
|
+
build_result(parse_response(response), body: body, tools: tools)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def api_base
|
|
52
|
+
API_URL
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def endpoint_path
|
|
56
|
+
'/v1/chat/completions'
|
|
57
|
+
end
|
|
46
58
|
|
|
59
|
+
def request_headers
|
|
60
|
+
{ 'Authorization' => "Bearer #{config.resolved_api_key}" }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Overridable: providers that support explicit cache breakpoints emit
|
|
64
|
+
# multipart content here instead of a bare string.
|
|
65
|
+
def system_message(system_prompt)
|
|
66
|
+
{ role: 'system', content: system_prompt }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def build_body(messages, system_prompt:, tools:)
|
|
47
70
|
formatted = []
|
|
48
|
-
formatted <<
|
|
71
|
+
formatted << system_message(system_prompt) if system_prompt
|
|
49
72
|
formatted.concat(format_messages(messages))
|
|
50
73
|
|
|
51
74
|
body = {
|
|
@@ -56,12 +79,10 @@ module RailsConsoleAi
|
|
|
56
79
|
temp = config.resolved_temperature
|
|
57
80
|
body[:temperature] = temp unless temp.nil?
|
|
58
81
|
body[:tools] = tools.to_openai_format if tools
|
|
82
|
+
body
|
|
83
|
+
end
|
|
59
84
|
|
|
60
|
-
|
|
61
|
-
debug_request("#{API_URL}/v1/chat/completions", body)
|
|
62
|
-
response = conn.post('/v1/chat/completions', json_body)
|
|
63
|
-
debug_response(response.body)
|
|
64
|
-
data = parse_response(response)
|
|
85
|
+
def build_result(data, body:, tools: nil)
|
|
65
86
|
usage = data['usage'] || {}
|
|
66
87
|
|
|
67
88
|
choice = (data['choices'] || []).first || {}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
module RailsConsoleAi
|
|
2
|
+
module Providers
|
|
3
|
+
class OpenRouter < OpenAI
|
|
4
|
+
DEFAULT_URL = 'https://openrouter.ai'.freeze
|
|
5
|
+
ANTHROPIC_MODEL = /anthropic\/|claude/i
|
|
6
|
+
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def api_base
|
|
10
|
+
config.openrouter_url || DEFAULT_URL
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def endpoint_path
|
|
14
|
+
'/api/v1/chat/completions'
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def request_headers
|
|
18
|
+
h = { 'Authorization' => "Bearer #{config.resolved_api_key}" }
|
|
19
|
+
h['HTTP-Referer'] = config.openrouter_site_url if config.openrouter_site_url
|
|
20
|
+
h['X-Title'] = config.openrouter_app_name if config.openrouter_app_name
|
|
21
|
+
h
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def build_body(messages, system_prompt:, tools:)
|
|
25
|
+
body = super
|
|
26
|
+
# Root-level cache_control is OpenRouter's automatic mode: it places a
|
|
27
|
+
# breakpoint on the last cacheable block and moves it forward as the
|
|
28
|
+
# conversation grows, which is what a tool loop needs — otherwise every
|
|
29
|
+
# round re-bills the whole accumulated history at full input price.
|
|
30
|
+
body[:cache_control] = cache_control if cache_supported?
|
|
31
|
+
body[:session_id] = routing_session_id if routing_session_id
|
|
32
|
+
# OpenRouter only returns usage.cost — the real dollars every cost
|
|
33
|
+
# readout prefers over an estimate — when the request asks for it.
|
|
34
|
+
body[:usage] = { include: true }
|
|
35
|
+
body
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The static system prefix gets its own explicit breakpoint so it has a
|
|
39
|
+
# guaranteed read point no matter what happens later in `messages`; the
|
|
40
|
+
# automatic breakpoint above then covers the growing tail. OpenRouter
|
|
41
|
+
# expresses Anthropic breakpoints as OpenAI-style multipart content.
|
|
42
|
+
def system_message(system_prompt)
|
|
43
|
+
return super unless cache_supported?
|
|
44
|
+
|
|
45
|
+
{ role: 'system',
|
|
46
|
+
content: [{ type: 'text', text: system_prompt, cache_control: cache_control }] }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Same TTL on every breakpoint: entries with the longer TTL must precede
|
|
50
|
+
# shorter ones, and an explicit marker whose TTL differs from the root-level
|
|
51
|
+
# field's is rejected outright.
|
|
52
|
+
def cache_control
|
|
53
|
+
ttl = config.respond_to?(:resolved_cache_ttl) ? config.resolved_cache_ttl : nil
|
|
54
|
+
ttl ? { type: 'ephemeral', ttl: ttl } : { type: 'ephemeral' }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def build_result(data, body:, tools: nil)
|
|
58
|
+
raise_inline_error!(data)
|
|
59
|
+
result = super
|
|
60
|
+
usage = data['usage'] || {}
|
|
61
|
+
details = usage['prompt_tokens_details'] || {}
|
|
62
|
+
|
|
63
|
+
cache_read = details['cached_tokens'].to_i
|
|
64
|
+
cache_write = details['cache_write_tokens'].to_i
|
|
65
|
+
result.cache_read_input_tokens = cache_read
|
|
66
|
+
result.cache_write_input_tokens = cache_write
|
|
67
|
+
result.cost = usage['cost']
|
|
68
|
+
|
|
69
|
+
# OpenAI-shaped `prompt_tokens` is the WHOLE prompt, cached tokens
|
|
70
|
+
# included; Anthropic's `input_tokens` is the uncached remainder, and the
|
|
71
|
+
# engine is built on the Anthropic contract — it reconstructs total prompt
|
|
72
|
+
# volume as input + cache_read + cache_write for the runaway-loop budget
|
|
73
|
+
# breakers. Left as sent, a cached round counts twice and those breakers
|
|
74
|
+
# fire at half the volume they are set to.
|
|
75
|
+
if result.input_tokens
|
|
76
|
+
result.input_tokens = [result.input_tokens - cache_read - cache_write, 0].max
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
if result.tool_calls&.any?
|
|
80
|
+
result.stop_reason = :tool_use
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
result
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def cache_supported?
|
|
87
|
+
config.resolved_model.to_s.match?(ANTHROPIC_MODEL)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def raise_inline_error!(data)
|
|
91
|
+
err = data['error'] || (data['choices'] || []).first&.dig('error')
|
|
92
|
+
return unless err
|
|
93
|
+
|
|
94
|
+
msg = err.is_a?(Hash) ? (err['message'] || err.to_s) : err.to_s
|
|
95
|
+
code = err.is_a?(Hash) ? err['code'] : 'unknown'
|
|
96
|
+
raise ProviderError, "OpenRouter error (#{code}): #{msg}"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -10,6 +10,8 @@ module RailsConsoleAi
|
|
|
10
10
|
conversation: Array(attrs[:conversation]).to_json,
|
|
11
11
|
input_tokens: attrs[:input_tokens] || 0,
|
|
12
12
|
output_tokens: attrs[:output_tokens] || 0,
|
|
13
|
+
cache_read_tokens: attrs[:cache_read_tokens] || 0,
|
|
14
|
+
cache_write_tokens: attrs[:cache_write_tokens] || 0,
|
|
13
15
|
user_name: attrs[:user_name] || current_user_name,
|
|
14
16
|
mode: attrs[:mode].to_s,
|
|
15
17
|
name: attrs[:name],
|
|
@@ -59,6 +61,8 @@ module RailsConsoleAi
|
|
|
59
61
|
updates[:conversation] = Array(attrs[:conversation]).to_json if attrs.key?(:conversation)
|
|
60
62
|
updates[:input_tokens] = attrs[:input_tokens] if attrs.key?(:input_tokens)
|
|
61
63
|
updates[:output_tokens] = attrs[:output_tokens] if attrs.key?(:output_tokens)
|
|
64
|
+
updates[:cache_read_tokens] = attrs[:cache_read_tokens] if attrs.key?(:cache_read_tokens)
|
|
65
|
+
updates[:cache_write_tokens] = attrs[:cache_write_tokens] if attrs.key?(:cache_write_tokens)
|
|
62
66
|
updates[:code_executed] = attrs[:code_executed] if attrs.key?(:code_executed)
|
|
63
67
|
updates[:code_output] = attrs[:code_output] if attrs.key?(:code_output)
|
|
64
68
|
updates[:code_result] = attrs[:code_result] if attrs.key?(:code_result)
|