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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +95 -0
  3. data/LICENSE +21 -0
  4. data/README.md +328 -0
  5. data/app/controllers/rails_console_ai/application_controller.rb +28 -0
  6. data/app/controllers/rails_console_ai/sessions_controller.rb +16 -0
  7. data/app/helpers/rails_console_ai/sessions_helper.rb +56 -0
  8. data/app/models/rails_console_ai/session.rb +23 -0
  9. data/app/views/layouts/rails_console_ai/application.html.erb +84 -0
  10. data/app/views/rails_console_ai/sessions/index.html.erb +57 -0
  11. data/app/views/rails_console_ai/sessions/show.html.erb +66 -0
  12. data/config/routes.rb +4 -0
  13. data/lib/generators/rails_console_ai/install_generator.rb +26 -0
  14. data/lib/generators/rails_console_ai/templates/initializer.rb +79 -0
  15. data/lib/rails_console_ai/channel/base.rb +23 -0
  16. data/lib/rails_console_ai/channel/console.rb +457 -0
  17. data/lib/rails_console_ai/channel/slack.rb +182 -0
  18. data/lib/rails_console_ai/configuration.rb +185 -0
  19. data/lib/rails_console_ai/console_methods.rb +277 -0
  20. data/lib/rails_console_ai/context_builder.rb +120 -0
  21. data/lib/rails_console_ai/conversation_engine.rb +1142 -0
  22. data/lib/rails_console_ai/engine.rb +5 -0
  23. data/lib/rails_console_ai/executor.rb +461 -0
  24. data/lib/rails_console_ai/providers/anthropic.rb +122 -0
  25. data/lib/rails_console_ai/providers/base.rb +118 -0
  26. data/lib/rails_console_ai/providers/bedrock.rb +171 -0
  27. data/lib/rails_console_ai/providers/local.rb +112 -0
  28. data/lib/rails_console_ai/providers/openai.rb +114 -0
  29. data/lib/rails_console_ai/railtie.rb +34 -0
  30. data/lib/rails_console_ai/repl.rb +65 -0
  31. data/lib/rails_console_ai/safety_guards.rb +207 -0
  32. data/lib/rails_console_ai/session_logger.rb +90 -0
  33. data/lib/rails_console_ai/slack_bot.rb +473 -0
  34. data/lib/rails_console_ai/storage/base.rb +27 -0
  35. data/lib/rails_console_ai/storage/file_storage.rb +63 -0
  36. data/lib/rails_console_ai/tools/code_tools.rb +126 -0
  37. data/lib/rails_console_ai/tools/memory_tools.rb +136 -0
  38. data/lib/rails_console_ai/tools/model_tools.rb +95 -0
  39. data/lib/rails_console_ai/tools/registry.rb +478 -0
  40. data/lib/rails_console_ai/tools/schema_tools.rb +60 -0
  41. data/lib/rails_console_ai/version.rb +3 -0
  42. data/lib/rails_console_ai.rb +214 -0
  43. data/lib/tasks/rails_console_ai.rake +7 -0
  44. metadata +152 -0
@@ -0,0 +1,5 @@
1
+ module RailsConsoleAI
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace RailsConsoleAI
4
+ end
5
+ end
@@ -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