rails_console_ai 0.13.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 +7 -0
- data/CHANGELOG.md +95 -0
- data/LICENSE +21 -0
- data/README.md +328 -0
- data/app/controllers/rails_console_ai/application_controller.rb +28 -0
- data/app/controllers/rails_console_ai/sessions_controller.rb +16 -0
- data/app/helpers/rails_console_ai/sessions_helper.rb +56 -0
- data/app/models/rails_console_ai/session.rb +23 -0
- data/app/views/layouts/rails_console_ai/application.html.erb +84 -0
- data/app/views/rails_console_ai/sessions/index.html.erb +57 -0
- data/app/views/rails_console_ai/sessions/show.html.erb +66 -0
- data/config/routes.rb +4 -0
- data/lib/generators/rails_console_ai/install_generator.rb +26 -0
- data/lib/generators/rails_console_ai/templates/initializer.rb +79 -0
- data/lib/rails_console_ai/channel/base.rb +23 -0
- data/lib/rails_console_ai/channel/console.rb +457 -0
- data/lib/rails_console_ai/channel/slack.rb +182 -0
- data/lib/rails_console_ai/configuration.rb +185 -0
- data/lib/rails_console_ai/console_methods.rb +277 -0
- data/lib/rails_console_ai/context_builder.rb +120 -0
- data/lib/rails_console_ai/conversation_engine.rb +1142 -0
- data/lib/rails_console_ai/engine.rb +5 -0
- data/lib/rails_console_ai/executor.rb +461 -0
- data/lib/rails_console_ai/providers/anthropic.rb +122 -0
- data/lib/rails_console_ai/providers/base.rb +118 -0
- data/lib/rails_console_ai/providers/bedrock.rb +171 -0
- data/lib/rails_console_ai/providers/local.rb +112 -0
- data/lib/rails_console_ai/providers/openai.rb +114 -0
- data/lib/rails_console_ai/railtie.rb +34 -0
- data/lib/rails_console_ai/repl.rb +65 -0
- data/lib/rails_console_ai/safety_guards.rb +207 -0
- data/lib/rails_console_ai/session_logger.rb +90 -0
- data/lib/rails_console_ai/slack_bot.rb +473 -0
- data/lib/rails_console_ai/storage/base.rb +27 -0
- data/lib/rails_console_ai/storage/file_storage.rb +63 -0
- data/lib/rails_console_ai/tools/code_tools.rb +126 -0
- data/lib/rails_console_ai/tools/memory_tools.rb +136 -0
- data/lib/rails_console_ai/tools/model_tools.rb +95 -0
- data/lib/rails_console_ai/tools/registry.rb +478 -0
- data/lib/rails_console_ai/tools/schema_tools.rb +60 -0
- data/lib/rails_console_ai/version.rb +3 -0
- data/lib/rails_console_ai.rb +214 -0
- data/lib/tasks/rails_console_ai.rake +7 -0
- metadata +152 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
require 'stringio'
|
|
2
|
+
|
|
3
|
+
module RailsConsoleAI
|
|
4
|
+
# Writes to two IO streams simultaneously
|
|
5
|
+
class TeeIO
|
|
6
|
+
attr_reader :secondary
|
|
7
|
+
|
|
8
|
+
def initialize(primary, secondary)
|
|
9
|
+
@primary = primary
|
|
10
|
+
@secondary = secondary
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def write(str)
|
|
14
|
+
@primary.write(str)
|
|
15
|
+
@secondary.write(str)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def puts(*args)
|
|
19
|
+
@primary.puts(*args)
|
|
20
|
+
# Capture what puts would output
|
|
21
|
+
args.each { |a| @secondary.write("#{a}\n") }
|
|
22
|
+
@secondary.write("\n") if args.empty?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def print(*args)
|
|
26
|
+
@primary.print(*args)
|
|
27
|
+
args.each { |a| @secondary.write(a.to_s) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def flush
|
|
31
|
+
@primary.flush if @primary.respond_to?(:flush)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def respond_to_missing?(method, include_private = false)
|
|
35
|
+
@primary.respond_to?(method, include_private) || super
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def method_missing(method, *args, &block)
|
|
39
|
+
@primary.send(method, *args, &block)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class Executor
|
|
44
|
+
CODE_REGEX = /```ruby\s*\n(.*?)```/m
|
|
45
|
+
|
|
46
|
+
attr_reader :binding_context, :last_error, :last_safety_error, :last_safety_exception
|
|
47
|
+
attr_accessor :on_prompt
|
|
48
|
+
|
|
49
|
+
def initialize(binding_context, channel: nil)
|
|
50
|
+
@binding_context = binding_context
|
|
51
|
+
@channel = channel
|
|
52
|
+
@omitted_outputs = {}
|
|
53
|
+
@omitted_counter = 0
|
|
54
|
+
@output_store = {}
|
|
55
|
+
@output_counter = 0
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def extract_code(response)
|
|
59
|
+
match = response.match(CODE_REGEX)
|
|
60
|
+
match ? match[1].strip : ''
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Matches any fenced code block (```anything ... ```)
|
|
64
|
+
ANY_CODE_FENCE_REGEX = /```\w*\s*\n.*?```/m
|
|
65
|
+
|
|
66
|
+
def display_response(response)
|
|
67
|
+
code = extract_code(response)
|
|
68
|
+
explanation = response.gsub(ANY_CODE_FENCE_REGEX, '').strip
|
|
69
|
+
|
|
70
|
+
if @channel
|
|
71
|
+
$stdout.puts
|
|
72
|
+
@channel.display(explanation) unless explanation.empty?
|
|
73
|
+
@channel.display_code(code) unless code.empty?
|
|
74
|
+
else
|
|
75
|
+
$stdout.puts
|
|
76
|
+
$stdout.puts colorize(explanation, :cyan) unless explanation.empty?
|
|
77
|
+
|
|
78
|
+
unless code.empty?
|
|
79
|
+
$stdout.puts
|
|
80
|
+
$stdout.puts colorize("# Generated code:", :yellow)
|
|
81
|
+
$stdout.puts highlight_code(code)
|
|
82
|
+
$stdout.puts
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
code
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def execute(code)
|
|
90
|
+
return nil if code.nil? || code.strip.empty?
|
|
91
|
+
|
|
92
|
+
@last_error = nil
|
|
93
|
+
@last_safety_error = false
|
|
94
|
+
@last_safety_exception = nil
|
|
95
|
+
captured_output = StringIO.new
|
|
96
|
+
old_stdout = $stdout
|
|
97
|
+
# Tee output: capture it and also print to the real stdout
|
|
98
|
+
$stdout = TeeIO.new(old_stdout, captured_output)
|
|
99
|
+
|
|
100
|
+
result = with_safety_guards do
|
|
101
|
+
binding_context.eval(code, "(rails_console_ai)", 1)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
$stdout = old_stdout
|
|
105
|
+
|
|
106
|
+
# Send captured puts output through channel before the return value
|
|
107
|
+
if @channel && !captured_output.string.empty?
|
|
108
|
+
@channel.display_result_output(captured_output.string)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
display_result(result)
|
|
112
|
+
|
|
113
|
+
@last_output = captured_output.string
|
|
114
|
+
result
|
|
115
|
+
rescue RailsConsoleAI::SafetyError => e
|
|
116
|
+
$stdout = old_stdout if old_stdout
|
|
117
|
+
@last_error = "SafetyError: #{e.message}"
|
|
118
|
+
@last_safety_error = true
|
|
119
|
+
@last_safety_exception = e
|
|
120
|
+
display_error("Blocked: #{e.message}")
|
|
121
|
+
@last_output = captured_output&.string
|
|
122
|
+
nil
|
|
123
|
+
rescue SyntaxError => e
|
|
124
|
+
$stdout = old_stdout if old_stdout
|
|
125
|
+
@last_error = "SyntaxError: #{e.message}"
|
|
126
|
+
display_error(@last_error)
|
|
127
|
+
@last_output = nil
|
|
128
|
+
nil
|
|
129
|
+
rescue => e
|
|
130
|
+
$stdout = old_stdout if old_stdout
|
|
131
|
+
# Check if a SafetyError is wrapped (e.g. ActiveRecord::StatementInvalid wrapping our error)
|
|
132
|
+
if safety_error?(e)
|
|
133
|
+
safety_exc = extract_safety_exception(e)
|
|
134
|
+
safety_msg = safety_exc ? safety_exc.message : e.message
|
|
135
|
+
@last_error = "SafetyError: #{safety_msg}"
|
|
136
|
+
@last_safety_error = true
|
|
137
|
+
@last_safety_exception = safety_exc
|
|
138
|
+
display_error("Blocked: #{safety_msg}")
|
|
139
|
+
@last_output = captured_output&.string
|
|
140
|
+
return nil
|
|
141
|
+
end
|
|
142
|
+
@last_error = "#{e.class}: #{e.message}"
|
|
143
|
+
display_error("Error: #{@last_error}")
|
|
144
|
+
e.backtrace.first(3).each { |line| display_error(" #{line}") }
|
|
145
|
+
@last_output = captured_output&.string
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def last_output
|
|
150
|
+
@last_output
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def expand_output(id)
|
|
154
|
+
@omitted_outputs[id]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def store_output(content)
|
|
158
|
+
@output_counter += 1
|
|
159
|
+
@output_store[@output_counter] = content
|
|
160
|
+
@output_counter
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def recall_output(id)
|
|
164
|
+
@output_store[id]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def last_answer
|
|
168
|
+
@last_answer
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def last_cancelled?
|
|
172
|
+
@last_cancelled
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def confirm_and_execute(code)
|
|
176
|
+
return nil if code.nil? || code.strip.empty?
|
|
177
|
+
|
|
178
|
+
@last_cancelled = false
|
|
179
|
+
@last_answer = nil
|
|
180
|
+
prompt = execute_prompt
|
|
181
|
+
|
|
182
|
+
if @channel
|
|
183
|
+
answer = @channel.confirm(prompt)
|
|
184
|
+
else
|
|
185
|
+
$stdout.print colorize(prompt, :yellow)
|
|
186
|
+
@on_prompt&.call
|
|
187
|
+
answer = $stdin.gets.to_s.strip.downcase
|
|
188
|
+
echo_stdin(answer)
|
|
189
|
+
end
|
|
190
|
+
@last_answer = answer
|
|
191
|
+
|
|
192
|
+
loop do
|
|
193
|
+
case answer
|
|
194
|
+
when 'y', 'yes', 'a'
|
|
195
|
+
result = execute(code)
|
|
196
|
+
if @last_safety_error
|
|
197
|
+
return nil unless danger_allowed?
|
|
198
|
+
return offer_danger_retry(code)
|
|
199
|
+
end
|
|
200
|
+
return result
|
|
201
|
+
when 'd', 'danger'
|
|
202
|
+
unless danger_allowed?
|
|
203
|
+
display_error("Safety guards cannot be disabled in this channel.")
|
|
204
|
+
return nil
|
|
205
|
+
end
|
|
206
|
+
if @channel
|
|
207
|
+
@channel.display_error("Executing with safety guards disabled.")
|
|
208
|
+
else
|
|
209
|
+
$stdout.puts colorize("Executing with safety guards disabled.", :red)
|
|
210
|
+
end
|
|
211
|
+
return execute_unsafe(code)
|
|
212
|
+
when 'e', 'edit'
|
|
213
|
+
edited = if @channel && @channel.supports_editing?
|
|
214
|
+
@channel.edit_code(code)
|
|
215
|
+
else
|
|
216
|
+
open_in_editor(code)
|
|
217
|
+
end
|
|
218
|
+
if edited && edited != code
|
|
219
|
+
$stdout.puts colorize("# Edited code:", :yellow)
|
|
220
|
+
$stdout.puts highlight_code(edited)
|
|
221
|
+
if @channel
|
|
222
|
+
edit_answer = @channel.confirm("Execute edited code? [y/N] ")
|
|
223
|
+
else
|
|
224
|
+
$stdout.print colorize("Execute edited code? [y/N] ", :yellow)
|
|
225
|
+
edit_answer = $stdin.gets.to_s.strip.downcase
|
|
226
|
+
echo_stdin(edit_answer)
|
|
227
|
+
end
|
|
228
|
+
if edit_answer == 'y'
|
|
229
|
+
return execute(edited)
|
|
230
|
+
else
|
|
231
|
+
$stdout.puts colorize("Cancelled.", :yellow)
|
|
232
|
+
return nil
|
|
233
|
+
end
|
|
234
|
+
else
|
|
235
|
+
return execute(code)
|
|
236
|
+
end
|
|
237
|
+
when 'n', 'no', ''
|
|
238
|
+
$stdout.puts colorize("Cancelled.", :yellow)
|
|
239
|
+
@last_cancelled = true
|
|
240
|
+
return nil
|
|
241
|
+
else
|
|
242
|
+
if @channel
|
|
243
|
+
answer = @channel.confirm(prompt)
|
|
244
|
+
else
|
|
245
|
+
$stdout.print colorize(prompt, :yellow)
|
|
246
|
+
@on_prompt&.call
|
|
247
|
+
answer = $stdin.gets.to_s.strip.downcase
|
|
248
|
+
echo_stdin(answer)
|
|
249
|
+
end
|
|
250
|
+
@last_answer = answer
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def offer_danger_retry(code)
|
|
256
|
+
return nil unless danger_allowed?
|
|
257
|
+
|
|
258
|
+
exc = @last_safety_exception
|
|
259
|
+
blocked_key = exc&.blocked_key
|
|
260
|
+
guard = exc&.guard
|
|
261
|
+
|
|
262
|
+
if blocked_key && guard
|
|
263
|
+
allow_desc = allow_description(guard, blocked_key)
|
|
264
|
+
$stdout.puts colorize(" [d] re-run with all safe mode disabled", :yellow)
|
|
265
|
+
$stdout.puts colorize(" [a] allow #{allow_desc} for this session", :yellow)
|
|
266
|
+
$stdout.puts colorize(" [N] cancel", :yellow)
|
|
267
|
+
prompt_text = "Choice: "
|
|
268
|
+
else
|
|
269
|
+
prompt_text = "Re-run with safe mode disabled? [y/N] "
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
if @channel
|
|
273
|
+
answer = @channel.confirm(prompt_text)
|
|
274
|
+
else
|
|
275
|
+
$stdout.print colorize(prompt_text, :yellow)
|
|
276
|
+
answer = $stdin.gets.to_s.strip.downcase
|
|
277
|
+
echo_stdin(answer)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
case answer
|
|
281
|
+
when 'a', 'allow'
|
|
282
|
+
if blocked_key && guard
|
|
283
|
+
RailsConsoleAI.configuration.safety_guards.allow(guard, blocked_key)
|
|
284
|
+
allow_desc = allow_description(guard, blocked_key)
|
|
285
|
+
$stdout.puts colorize("Allowed #{allow_desc} for this session.", :green)
|
|
286
|
+
return execute(code)
|
|
287
|
+
else
|
|
288
|
+
if @channel
|
|
289
|
+
answer = @channel.confirm("Nothing to allow — re-run with safe mode disabled instead? [y/N] ")
|
|
290
|
+
else
|
|
291
|
+
$stdout.puts colorize("Nothing to allow — re-run with safe mode disabled instead? [y/N]", :yellow)
|
|
292
|
+
answer = $stdin.gets.to_s.strip.downcase
|
|
293
|
+
echo_stdin(answer)
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
when 'd', 'danger', 'y', 'yes'
|
|
297
|
+
$stdout.puts colorize("Executing with safety guards disabled.", :red)
|
|
298
|
+
return execute_unsafe(code)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
$stdout.puts colorize("Cancelled.", :yellow)
|
|
302
|
+
nil
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
private
|
|
306
|
+
|
|
307
|
+
def danger_allowed?
|
|
308
|
+
@channel.nil? || @channel.supports_danger?
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def display_error(msg)
|
|
312
|
+
if @channel
|
|
313
|
+
@channel.display_error(msg)
|
|
314
|
+
else
|
|
315
|
+
$stderr.puts colorize(msg, :red)
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def allow_description(guard, blocked_key)
|
|
320
|
+
case guard
|
|
321
|
+
when :database_writes
|
|
322
|
+
"all writes to #{blocked_key}"
|
|
323
|
+
when :http_mutations
|
|
324
|
+
"all HTTP mutations to #{blocked_key}"
|
|
325
|
+
else
|
|
326
|
+
"#{blocked_key} for :#{guard}"
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def execute_unsafe(code)
|
|
331
|
+
guards = RailsConsoleAI.configuration.safety_guards
|
|
332
|
+
guards.disable!
|
|
333
|
+
execute(code)
|
|
334
|
+
ensure
|
|
335
|
+
guards.enable!
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def execute_prompt
|
|
339
|
+
guards = RailsConsoleAI.configuration.safety_guards
|
|
340
|
+
if !guards.empty? && guards.enabled? && danger_allowed?
|
|
341
|
+
"Execute? [y/N/edit/danger] "
|
|
342
|
+
else
|
|
343
|
+
"Execute? [y/N/edit] "
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def with_safety_guards(&block)
|
|
348
|
+
RailsConsoleAI.configuration.safety_guards.wrap(&block)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Check if an exception is or wraps a SafetyError (e.g. AR::StatementInvalid wrapping it)
|
|
352
|
+
def safety_error?(exception)
|
|
353
|
+
return true if exception.is_a?(RailsConsoleAI::SafetyError)
|
|
354
|
+
return true if exception.message.include?("RailsConsoleAI safe mode")
|
|
355
|
+
cause = exception.cause
|
|
356
|
+
while cause
|
|
357
|
+
return true if cause.is_a?(RailsConsoleAI::SafetyError)
|
|
358
|
+
cause = cause.cause
|
|
359
|
+
end
|
|
360
|
+
false
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def extract_safety_exception(exception)
|
|
364
|
+
return exception if exception.is_a?(RailsConsoleAI::SafetyError)
|
|
365
|
+
cause = exception.cause
|
|
366
|
+
while cause
|
|
367
|
+
return cause if cause.is_a?(RailsConsoleAI::SafetyError)
|
|
368
|
+
cause = cause.cause
|
|
369
|
+
end
|
|
370
|
+
nil
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
MAX_DISPLAY_LINES = 10
|
|
374
|
+
MAX_DISPLAY_CHARS = 2000
|
|
375
|
+
|
|
376
|
+
def display_result(result)
|
|
377
|
+
if @channel
|
|
378
|
+
@channel.display_result(result)
|
|
379
|
+
else
|
|
380
|
+
full = "=> #{result.inspect}"
|
|
381
|
+
lines = full.lines
|
|
382
|
+
total_lines = lines.length
|
|
383
|
+
total_chars = full.length
|
|
384
|
+
|
|
385
|
+
if total_lines <= MAX_DISPLAY_LINES && total_chars <= MAX_DISPLAY_CHARS
|
|
386
|
+
$stdout.puts colorize(full, :green)
|
|
387
|
+
else
|
|
388
|
+
truncated = lines.first(MAX_DISPLAY_LINES).join
|
|
389
|
+
truncated = truncated[0, MAX_DISPLAY_CHARS] if truncated.length > MAX_DISPLAY_CHARS
|
|
390
|
+
$stdout.puts colorize(truncated, :green)
|
|
391
|
+
|
|
392
|
+
omitted_lines = [total_lines - MAX_DISPLAY_LINES, 0].max
|
|
393
|
+
omitted_chars = [total_chars - truncated.length, 0].max
|
|
394
|
+
parts = []
|
|
395
|
+
parts << "#{omitted_lines} lines" if omitted_lines > 0
|
|
396
|
+
parts << "#{omitted_chars} chars" if omitted_chars > 0
|
|
397
|
+
|
|
398
|
+
@omitted_counter += 1
|
|
399
|
+
@omitted_outputs[@omitted_counter] = full
|
|
400
|
+
$stdout.puts colorize(" (omitting #{parts.join(', ')}) /expand #{@omitted_counter} to see all", :yellow)
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Write stdin input to the capture IO only (avoids double-echo on terminal)
|
|
406
|
+
def echo_stdin(text)
|
|
407
|
+
$stdout.secondary.write("#{text}\n") if $stdout.respond_to?(:secondary)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def open_in_editor(code)
|
|
411
|
+
require 'tempfile'
|
|
412
|
+
editor = ENV['EDITOR'] || 'vi'
|
|
413
|
+
tmpfile = Tempfile.new(['rails_console_ai', '.rb'])
|
|
414
|
+
tmpfile.write(code)
|
|
415
|
+
tmpfile.flush
|
|
416
|
+
|
|
417
|
+
system("#{editor} #{tmpfile.path}")
|
|
418
|
+
File.read(tmpfile.path)
|
|
419
|
+
rescue => e
|
|
420
|
+
$stderr.puts colorize("Editor error: #{e.message}", :red)
|
|
421
|
+
code
|
|
422
|
+
ensure
|
|
423
|
+
tmpfile.close! if tmpfile
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def highlight_code(code)
|
|
427
|
+
if coderay_available?
|
|
428
|
+
CodeRay.scan(code, :ruby).terminal
|
|
429
|
+
else
|
|
430
|
+
colorize(code, :white)
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def coderay_available?
|
|
435
|
+
return @coderay_available unless @coderay_available.nil?
|
|
436
|
+
@coderay_available = begin
|
|
437
|
+
require 'coderay'
|
|
438
|
+
true
|
|
439
|
+
rescue LoadError
|
|
440
|
+
false
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
COLORS = {
|
|
445
|
+
red: "\e[31m",
|
|
446
|
+
green: "\e[32m",
|
|
447
|
+
yellow: "\e[33m",
|
|
448
|
+
cyan: "\e[36m",
|
|
449
|
+
white: "\e[37m",
|
|
450
|
+
reset: "\e[0m"
|
|
451
|
+
}.freeze
|
|
452
|
+
|
|
453
|
+
def colorize(text, color)
|
|
454
|
+
if $stdout.respond_to?(:tty?) && $stdout.tty?
|
|
455
|
+
"#{COLORS[color]}#{text}#{COLORS[:reset]}"
|
|
456
|
+
else
|
|
457
|
+
text
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
module RailsConsoleAI
|
|
2
|
+
module Providers
|
|
3
|
+
class Anthropic < Base
|
|
4
|
+
API_URL = 'https://api.anthropic.com'.freeze
|
|
5
|
+
|
|
6
|
+
def chat(messages, system_prompt: nil)
|
|
7
|
+
result = call_api(messages, system_prompt: system_prompt)
|
|
8
|
+
result
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def chat_with_tools(messages, tools:, system_prompt: nil)
|
|
12
|
+
call_api(messages, system_prompt: system_prompt, tools: tools)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def format_assistant_message(result)
|
|
16
|
+
# Rebuild the assistant content blocks from the raw response
|
|
17
|
+
content_blocks = []
|
|
18
|
+
content_blocks << { 'type' => 'text', 'text' => result.text } if result.text && !result.text.empty?
|
|
19
|
+
(result.tool_calls || []).each do |tc|
|
|
20
|
+
content_blocks << {
|
|
21
|
+
'type' => 'tool_use',
|
|
22
|
+
'id' => tc[:id],
|
|
23
|
+
'name' => tc[:name],
|
|
24
|
+
'input' => tc[:arguments]
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
{ role: 'assistant', content: content_blocks }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def format_tool_result(tool_call_id, result_string)
|
|
31
|
+
{
|
|
32
|
+
role: 'user',
|
|
33
|
+
content: [
|
|
34
|
+
{
|
|
35
|
+
'type' => 'tool_result',
|
|
36
|
+
'tool_use_id' => tool_call_id,
|
|
37
|
+
'content' => result_string.to_s
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def call_api(messages, system_prompt: nil, tools: nil)
|
|
46
|
+
conn = build_connection(API_URL, {
|
|
47
|
+
'x-api-key' => config.resolved_api_key,
|
|
48
|
+
'anthropic-version' => '2023-06-01'
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
body = {
|
|
52
|
+
model: config.resolved_model,
|
|
53
|
+
max_tokens: config.resolved_max_tokens,
|
|
54
|
+
temperature: config.temperature,
|
|
55
|
+
messages: format_messages(messages)
|
|
56
|
+
}
|
|
57
|
+
if system_prompt
|
|
58
|
+
body[:system] = [
|
|
59
|
+
{ 'type' => 'text', 'text' => system_prompt, 'cache_control' => { 'type' => 'ephemeral' } }
|
|
60
|
+
]
|
|
61
|
+
end
|
|
62
|
+
if tools
|
|
63
|
+
anthropic_tools = tools.to_anthropic_format
|
|
64
|
+
anthropic_tools.last['cache_control'] = { 'type' => 'ephemeral' } if anthropic_tools.any?
|
|
65
|
+
body[:tools] = anthropic_tools
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
json_body = JSON.generate(body)
|
|
69
|
+
debug_request("#{API_URL}/v1/messages", body)
|
|
70
|
+
response = conn.post('/v1/messages', json_body)
|
|
71
|
+
debug_response(response.body)
|
|
72
|
+
data = parse_response(response)
|
|
73
|
+
usage = data['usage'] || {}
|
|
74
|
+
|
|
75
|
+
tool_calls = extract_tool_calls(data)
|
|
76
|
+
stop = data['stop_reason'] == 'tool_use' ? :tool_use : :end_turn
|
|
77
|
+
|
|
78
|
+
ChatResult.new(
|
|
79
|
+
text: extract_text(data),
|
|
80
|
+
input_tokens: usage['input_tokens'],
|
|
81
|
+
output_tokens: usage['output_tokens'],
|
|
82
|
+
cache_read_input_tokens: usage['cache_read_input_tokens'],
|
|
83
|
+
cache_write_input_tokens: usage['cache_creation_input_tokens'],
|
|
84
|
+
tool_calls: tool_calls,
|
|
85
|
+
stop_reason: stop
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def format_messages(messages)
|
|
90
|
+
messages.map do |msg|
|
|
91
|
+
if msg[:content].is_a?(Array)
|
|
92
|
+
{ role: msg[:role].to_s, content: msg[:content] }
|
|
93
|
+
else
|
|
94
|
+
{ role: msg[:role].to_s, content: msg[:content].to_s }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def extract_text(data)
|
|
100
|
+
content = data['content']
|
|
101
|
+
return '' unless content.is_a?(Array)
|
|
102
|
+
|
|
103
|
+
content.select { |c| c['type'] == 'text' }
|
|
104
|
+
.map { |c| c['text'] }
|
|
105
|
+
.join("\n")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def extract_tool_calls(data)
|
|
109
|
+
content = data['content']
|
|
110
|
+
return [] unless content.is_a?(Array)
|
|
111
|
+
|
|
112
|
+
content.select { |c| c['type'] == 'tool_use' }.map do |c|
|
|
113
|
+
{
|
|
114
|
+
id: c['id'],
|
|
115
|
+
name: c['name'],
|
|
116
|
+
arguments: c['input'] || {}
|
|
117
|
+
}
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
require 'faraday'
|
|
2
|
+
require 'json'
|
|
3
|
+
|
|
4
|
+
module RailsConsoleAI
|
|
5
|
+
module Providers
|
|
6
|
+
class Base
|
|
7
|
+
attr_reader :config
|
|
8
|
+
|
|
9
|
+
def initialize(config = RailsConsoleAI.configuration)
|
|
10
|
+
@config = config
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def chat(messages, system_prompt: nil)
|
|
14
|
+
raise NotImplementedError, "#{self.class}#chat must be implemented"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def chat_with_tools(messages, tools:, system_prompt: nil)
|
|
18
|
+
raise NotImplementedError, "#{self.class}#chat_with_tools must be implemented"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def format_assistant_message(_result)
|
|
22
|
+
raise NotImplementedError, "#{self.class}#format_assistant_message must be implemented"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def format_tool_result(_tool_call_id, _result_string)
|
|
26
|
+
raise NotImplementedError, "#{self.class}#format_tool_result must be implemented"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def build_connection(url, headers = {})
|
|
32
|
+
Faraday.new(url: url) do |f|
|
|
33
|
+
t = config.respond_to?(:resolved_timeout) ? config.resolved_timeout : config.timeout
|
|
34
|
+
f.options.timeout = t
|
|
35
|
+
f.options.open_timeout = t
|
|
36
|
+
f.headers.update(headers)
|
|
37
|
+
f.headers['Content-Type'] = 'application/json'
|
|
38
|
+
f.adapter Faraday.default_adapter
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def debug_request(url, body)
|
|
43
|
+
return unless config.debug
|
|
44
|
+
|
|
45
|
+
parsed = body.is_a?(String) ? (JSON.parse(body) rescue nil) : body
|
|
46
|
+
if parsed
|
|
47
|
+
# Support both symbol and string keys
|
|
48
|
+
model = parsed[:model] || parsed['model']
|
|
49
|
+
msgs = parsed[:messages] || parsed['messages']
|
|
50
|
+
sys = parsed[:system] || parsed['system']
|
|
51
|
+
tools = parsed[:tools] || parsed['tools']
|
|
52
|
+
$stderr.puts "\e[33m[debug] POST #{url} | model: #{model} | #{msgs&.length || 0} msgs | system: #{sys.to_s.length} chars | #{tools&.length || 0} tools\e[0m"
|
|
53
|
+
else
|
|
54
|
+
$stderr.puts "\e[33m[debug] POST #{url}\e[0m"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def debug_response(body)
|
|
59
|
+
return unless config.debug
|
|
60
|
+
|
|
61
|
+
parsed = body.is_a?(String) ? (JSON.parse(body) rescue nil) : body
|
|
62
|
+
if parsed && parsed['usage']
|
|
63
|
+
u = parsed['usage']
|
|
64
|
+
$stderr.puts "\e[36m[debug] response: #{parsed['stop_reason']} | in: #{u['input_tokens']} out: #{u['output_tokens']}\e[0m"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def parse_response(response)
|
|
69
|
+
unless response.success?
|
|
70
|
+
body = begin
|
|
71
|
+
JSON.parse(response.body)
|
|
72
|
+
rescue
|
|
73
|
+
{ 'error' => response.body }
|
|
74
|
+
end
|
|
75
|
+
error_msg = body.dig('error', 'message') || body['error'] || response.body
|
|
76
|
+
raise ProviderError, "API error (#{response.status}): #{error_msg}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
JSON.parse(response.body)
|
|
80
|
+
rescue JSON::ParserError => e
|
|
81
|
+
raise ProviderError, "Failed to parse response: #{e.message}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
class ProviderError < StandardError; end
|
|
86
|
+
|
|
87
|
+
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
|
|
89
|
+
def total_tokens
|
|
90
|
+
(input_tokens || 0) + (output_tokens || 0)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def tool_use?
|
|
94
|
+
stop_reason == :tool_use && tool_calls && !tool_calls.empty?
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.build(config = RailsConsoleAI.configuration)
|
|
99
|
+
case config.provider
|
|
100
|
+
when :anthropic
|
|
101
|
+
require 'rails_console_ai/providers/anthropic'
|
|
102
|
+
Anthropic.new(config)
|
|
103
|
+
when :openai
|
|
104
|
+
require 'rails_console_ai/providers/openai'
|
|
105
|
+
OpenAI.new(config)
|
|
106
|
+
when :local
|
|
107
|
+
require 'rails_console_ai/providers/openai'
|
|
108
|
+
require 'rails_console_ai/providers/local'
|
|
109
|
+
Local.new(config)
|
|
110
|
+
when :bedrock
|
|
111
|
+
require 'rails_console_ai/providers/bedrock'
|
|
112
|
+
Bedrock.new(config)
|
|
113
|
+
else
|
|
114
|
+
raise ConfigurationError, "Unknown provider: #{config.provider}"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|