diff --git a/Gemfile b/Gemfile index 76e14d08..5f1bbf81 100644 --- a/Gemfile +++ b/Gemfile @@ -63,6 +63,9 @@ gem "jwt", "~> 2.8" # Slack notifications gem "slack-notifier" +# AI provider abstraction +gem "ruby_llm" + # Markdown rendering gem "redcarpet" diff --git a/Gemfile.lock b/Gemfile.lock index 6b67f250..ac333b48 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -159,6 +159,7 @@ GEM erubi (1.13.1) et-orbi (1.3.0) tzinfo + event_stream_parser (1.0.0) factory_bot (6.5.5) activesupport (>= 6.1.0) factory_bot_rails (6.5.1) @@ -177,6 +178,8 @@ GEM multipart-post (~> 2.0) faraday-net_http (3.4.1) net-http (>= 0.5.0) + faraday-retry (2.4.0) + faraday (~> 2.0) fugit (1.11.1) et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) @@ -464,6 +467,17 @@ GEM rubocop-performance (>= 1.24) rubocop-rails (>= 2.30) ruby-progressbar (1.13.0) + ruby_llm (1.14.1) + base64 + event_stream_parser (~> 1) + faraday (>= 1.10.0) + faraday-multipart (>= 1) + faraday-net_http (>= 1) + faraday-retry (>= 1) + marcel (~> 1) + ruby_llm-schema (~> 0) + zeitwerk (~> 2) + ruby_llm-schema (0.3.0) securerandom (0.4.1) shoulda-matchers (6.5.0) activesupport (>= 5.2.0) @@ -598,6 +612,7 @@ DEPENDENCIES resend rspec-rails (~> 6.0) rubocop-rails-omakase + ruby_llm shoulda-matchers sidekiq sidekiq-cron diff --git a/app/controllers/ai_provider_configs_controller.rb b/app/controllers/ai_provider_configs_controller.rb new file mode 100644 index 00000000..137b5654 --- /dev/null +++ b/app/controllers/ai_provider_configs_controller.rb @@ -0,0 +1,61 @@ +class AiProviderConfigsController < ApplicationController + layout "admin" + before_action :authenticate_user! + before_action :set_config, only: [:update, :destroy, :activate] + + def models + provider = params[:provider].to_s + unless %w[anthropic openai gemini].include?(provider) + return render json: { fast: [], power: [] } + end + + chat_models = RubyLLM.models.by_provider(provider).select { |m| m.type == "chat" } + sorted = chat_models.sort_by { |m| m.name.downcase } + + render json: { + fast: sorted.map { |m| { value: m.id, label: m.name } }, + power: sorted.map { |m| { value: m.id, label: m.name } } + } + end + + def create + @config = current_account.ai_provider_configs.build(config_params) + + # Auto-activate if no other configs exist yet + @config.active = true unless current_account.ai_provider_configs.active.exists? + + if @config.save + redirect_to account_settings_path, notice: "AI provider added." + else + redirect_to account_settings_path, alert: @config.errors.full_messages.join(", ") + end + end + + def update + if @config.update(config_params) + redirect_to account_settings_path, notice: "AI provider updated." + else + redirect_to account_settings_path, alert: @config.errors.full_messages.join(", ") + end + end + + def destroy + @config.destroy + redirect_to account_settings_path, notice: "AI provider removed." + end + + def activate + @config.activate! + redirect_to account_settings_path, notice: "#{@config.provider.titleize} is now the active AI provider." + end + + private + + def set_config + @config = current_account.ai_provider_configs.find(params[:id]) + end + + def config_params + params.require(:ai_provider_config).permit(:provider, :api_key, :fast_model, :power_model) + end +end diff --git a/app/controllers/api/v1/cli/incidents_controller.rb b/app/controllers/api/v1/cli/incidents_controller.rb index c0882c1c..3ef94b7e 100644 --- a/app/controllers/api/v1/cli/incidents_controller.rb +++ b/app/controllers/api/v1/cli/incidents_controller.rb @@ -87,7 +87,7 @@ def explain else # Generate new AI summary sample_event = issue.events.order(occurred_at: :desc).first - service = AiSummaryService.new(issue: issue, sample_event: sample_event) + service = AiSummaryService.new(account: issue.account, issue: issue, sample_event: sample_event) result = service.call if result[:error] diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 89755b0a..75453b22 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -11,6 +11,7 @@ class ApplicationController < ActionController::Base checkouts subscriptions account_settings + ai_provider_configs settings ].freeze @@ -197,6 +198,7 @@ def user_not_authorized settings account_settings project_settings + ai_provider_configs ].freeze # Super admin viewing mode: block most write operations (allow settings changes) diff --git a/app/controllers/errors_controller.rb b/app/controllers/errors_controller.rb index 39f68bc9..6afde743 100644 --- a/app/controllers/errors_controller.rb +++ b/app/controllers/errors_controller.rb @@ -344,7 +344,7 @@ def show else # First-time attempt only github_client = github_client_for_issue(@issue) - result = AiSummaryService.new(issue: @issue, sample_event: @selected_event, github_client: github_client).call + result = AiSummaryService.new(account: current_account, issue: @issue, sample_event: @selected_event, github_client: github_client).call if result[:summary].present? @issue.update(ai_summary: result[:summary], ai_summary_generated_at: Time.current) else @@ -385,7 +385,7 @@ def regenerate_ai_summary # Force regeneration by clearing the previous summary github_client = github_client_for_issue(@issue) - result = AiSummaryService.new(issue: @issue, sample_event: @selected_event, github_client: github_client).call + result = AiSummaryService.new(account: current_account, issue: @issue, sample_event: @selected_event, github_client: github_client).call if result[:summary].present? @issue.update(ai_summary: result[:summary], ai_summary_generated_at: Time.current) diff --git a/app/controllers/performance_controller.rb b/app/controllers/performance_controller.rb index 45072601..ab98e6b9 100644 --- a/app/controllers/performance_controller.rb +++ b/app/controllers/performance_controller.rb @@ -618,7 +618,7 @@ def action_detail p95_ms: (@p95_response_time&.round(1)) } sample = @selected_event || @events.first - result = AiPerformanceSummaryService.new(target: @target, stats: stats, sample_event: sample).call + result = AiPerformanceSummaryService.new(account: current_account, target: @target, stats: stats, sample_event: sample).call if result[:summary].present? @performance_summary = result[:summary] diff --git a/app/helpers/errors_helper.rb b/app/helpers/errors_helper.rb index 0755034e..373be2a6 100644 --- a/app/helpers/errors_helper.rb +++ b/app/helpers/errors_helper.rb @@ -79,18 +79,19 @@ def normalize_client_frame(frame) raw: raw || "#{file}:#{line}:in `#{method_name}'", in_app: in_app, frame_type: frame_type, - source_context: normalize_source_context(source_context) + source_context: normalize_source_context(source_context, line&.to_i) } end # Normalize source context from client - def normalize_source_context(ctx) + def normalize_source_context(ctx, frame_line = nil) return nil if ctx.blank? lines_before = ctx["lines_before"] || ctx[:lines_before] || [] line_content = ctx["line_content"] || ctx[:line_content] lines_after = ctx["lines_after"] || ctx[:lines_after] || [] - start_line = ctx["start_line"] || ctx[:start_line] + start_line = ctx["start_line"] || ctx[:start_line] || + (frame_line ? frame_line - lines_before.length : 1) return nil if line_content.blank? diff --git a/app/javascript/controllers/ai_provider_form_controller.js b/app/javascript/controllers/ai_provider_form_controller.js new file mode 100644 index 00000000..add63478 --- /dev/null +++ b/app/javascript/controllers/ai_provider_form_controller.js @@ -0,0 +1,186 @@ +import { Controller } from "@hotwired/stimulus" + +const DEFAULTS = { + anthropic: { fast: "claude-haiku-4-5-20251001", power: "claude-sonnet-4-6" }, + openai: { fast: "gpt-4o-mini", power: "gpt-4o" }, + gemini: { fast: "gemini-2.0-flash", power: "gemini-2.5-pro" } +} + +export default class extends Controller { + static targets = ["provider", "fastModel", "powerModel", "fastDropdown", "powerDropdown", "fastSearch", "powerSearch", "fastDisplay", "powerDisplay"] + static values = { modelsUrl: String } + + connect() { + this.closeAll = this.closeAll.bind(this) + document.addEventListener("click", this.closeAll) + this.models = { fast: [], power: [] } + } + + disconnect() { + document.removeEventListener("click", this.closeAll) + } + + async providerChanged() { + const provider = this.providerTarget.value + if (!provider) { + this.models = { fast: [], power: [] } + this.clearDropdown("fast") + this.clearDropdown("power") + return + } + + // Show loading state + this.fastDisplayTarget.textContent = "Loading models..." + this.fastDisplayTarget.classList.add("text-gray-400") + this.powerDisplayTarget.textContent = "Loading models..." + this.powerDisplayTarget.classList.add("text-gray-400") + + try { + const response = await fetch(`${this.modelsUrlValue}?provider=${provider}`) + this.models = await response.json() + this.populateDropdown("fast", this.models.fast, DEFAULTS[provider]?.fast) + this.populateDropdown("power", this.models.power, DEFAULTS[provider]?.power) + } catch (e) { + this.fastDisplayTarget.textContent = "Failed to load models" + this.powerDisplayTarget.textContent = "Failed to load models" + } + } + + populateDropdown(type, models, defaultValue) { + const hiddenInput = type === "fast" ? this.fastModelTarget : this.powerModelTarget + const display = type === "fast" ? this.fastDisplayTarget : this.powerDisplayTarget + const dropdown = type === "fast" ? this.fastDropdownTarget : this.powerDropdownTarget + + if (!hiddenInput.value && defaultValue) { + hiddenInput.value = defaultValue + } + + const selected = hiddenInput.value + const match = models.find(m => m.value === selected) + if (match) { + display.textContent = match.label + display.classList.remove("text-gray-400") + display.classList.add("text-gray-900") + } else if (defaultValue) { + const def = models.find(m => m.value === defaultValue) + if (def) { + hiddenInput.value = def.value + display.textContent = def.label + display.classList.remove("text-gray-400") + display.classList.add("text-gray-900") + } + } else { + display.textContent = "Select model..." + display.classList.add("text-gray-400") + display.classList.remove("text-gray-900") + } + + this.renderOptions(dropdown, models, hiddenInput.value, defaultValue) + } + + clearDropdown(type) { + const display = type === "fast" ? this.fastDisplayTarget : this.powerDisplayTarget + const dropdown = type === "fast" ? this.fastDropdownTarget : this.powerDropdownTarget + const hiddenInput = type === "fast" ? this.fastModelTarget : this.powerModelTarget + + display.textContent = "Select a provider first" + display.classList.add("text-gray-400") + display.classList.remove("text-gray-900") + hiddenInput.value = "" + dropdown.innerHTML = "" + } + + renderOptions(dropdown, models, selectedValue, defaultValue) { + dropdown.innerHTML = models.map(m => ` + + `).join("") + } + + toggleFast(event) { + event.preventDefault() + event.stopPropagation() + if (!this.providerTarget.value) return + this.powerDropdownTarget.parentElement.classList.add("hidden") + this.fastDropdownTarget.parentElement.classList.toggle("hidden") + if (!this.fastDropdownTarget.parentElement.classList.contains("hidden")) { + this.fastSearchTarget.value = "" + this.fastSearchTarget.focus() + this.filterOptions("fast", "") + } + } + + togglePower(event) { + event.preventDefault() + event.stopPropagation() + if (!this.providerTarget.value) return + this.fastDropdownTarget.parentElement.classList.add("hidden") + this.powerDropdownTarget.parentElement.classList.toggle("hidden") + if (!this.powerDropdownTarget.parentElement.classList.contains("hidden")) { + this.powerSearchTarget.value = "" + this.powerSearchTarget.focus() + this.filterOptions("power", "") + } + } + + filterFast() { + this.filterOptions("fast", this.fastSearchTarget.value) + } + + filterPower() { + this.filterOptions("power", this.powerSearchTarget.value) + } + + filterOptions(type, query) { + const dropdown = type === "fast" ? this.fastDropdownTarget : this.powerDropdownTarget + const q = query.toLowerCase() + dropdown.querySelectorAll(".model-option").forEach(el => { + const text = el.textContent.toLowerCase() + el.style.display = text.includes(q) ? "" : "none" + }) + } + + selectOption(event) { + event.preventDefault() + event.stopPropagation() + const btn = event.currentTarget + const value = btn.dataset.value + const dropdownEl = btn.closest("[data-dropdown-wrapper]") + const type = dropdownEl.dataset.dropdownType + + const hiddenInput = type === "fast" ? this.fastModelTarget : this.powerModelTarget + const display = type === "fast" ? this.fastDisplayTarget : this.powerDisplayTarget + const models = this.models[type] || [] + + hiddenInput.value = value + const match = models.find(m => m.value === value) + if (match) { + display.textContent = match.label + display.classList.remove("text-gray-400") + display.classList.add("text-gray-900") + } + + const dropdown = type === "fast" ? this.fastDropdownTarget : this.powerDropdownTarget + dropdown.querySelectorAll(".model-option").forEach(el => { + el.classList.toggle("bg-indigo-50", el.dataset.value === value) + el.classList.toggle("text-indigo-700", el.dataset.value === value) + }) + + dropdownEl.classList.add("hidden") + } + + closeAll(event) { + if (!this.element.contains(event.target)) { + this.fastDropdownTarget.parentElement.classList.add("hidden") + this.powerDropdownTarget.parentElement.classList.add("hidden") + } + } +} diff --git a/app/jobs/ai_summary_job.rb b/app/jobs/ai_summary_job.rb index 85647493..43102635 100644 --- a/app/jobs/ai_summary_job.rb +++ b/app/jobs/ai_summary_job.rb @@ -30,7 +30,7 @@ def perform(issue_id, event_id, project_id) end github_client = build_github_client(project) - ai = AiSummaryService.new(issue: issue, sample_event: event, github_client: github_client).call + ai = AiSummaryService.new(account: account, issue: issue, sample_event: event, github_client: github_client).call if ai[:summary].present? issue.update(ai_summary: ai[:summary], ai_summary_generated_at: Time.current) diff --git a/app/models/account.rb b/app/models/account.rb index 9cf7ec29..dee86a39 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -12,6 +12,7 @@ class Account < ApplicationRecord has_many :users, dependent: :destroy has_many :projects, dependent: :destroy has_many :api_tokens, through: :projects + has_many :ai_provider_configs, dependent: :destroy # Scopes scope :active, -> { where(active: true) } @@ -118,6 +119,16 @@ def active_subscription? active_subscription_record.present? end + # AI provider configuration + # Database config takes precedence; ENV is fallback only + def ai_provider_config + ai_provider_configs.active.first || env_ai_provider_config + end + + def ai_configured? + ai_provider_config.present? + end + # Account-wide Slack notification settings def slack_webhook_url # Priority: ENV variable > account setting @@ -233,6 +244,19 @@ def has_any_stats? private + def env_ai_provider_config + if ENV["ANTHROPIC_API_KEY"].present? + AiProviderConfig.new(provider: "anthropic", api_key: ENV["ANTHROPIC_API_KEY"], + fast_model: "claude-haiku-4-5-20251001", power_model: "claude-sonnet-4-6") + elsif ENV["OPENAI_API_KEY"].present? + AiProviderConfig.new(provider: "openai", api_key: ENV["OPENAI_API_KEY"], + fast_model: "gpt-4o-mini", power_model: "gpt-4o") + elsif ENV["GEMINI_API_KEY"].present? + AiProviderConfig.new(provider: "gemini", api_key: ENV["GEMINI_API_KEY"], + fast_model: "gemini-2.0-flash", power_model: "gemini-2.5-pro") + end + end + def check_stripe_payment_methods # Ensure API key is set Stripe.api_key ||= ENV["STRIPE_SECRET_KEY"] diff --git a/app/models/ai_provider_config.rb b/app/models/ai_provider_config.rb new file mode 100644 index 00000000..78e9919f --- /dev/null +++ b/app/models/ai_provider_config.rb @@ -0,0 +1,17 @@ +class AiProviderConfig < ApplicationRecord + belongs_to :account + + encrypts :api_key + + validates :provider, presence: true + validates :api_key, presence: true + + scope :active, -> { where(active: true) } + + def activate! + transaction do + account.ai_provider_configs.where.not(id: id).update_all(active: false) + update!(active: true) + end + end +end diff --git a/app/services/ai_performance_summary_service.rb b/app/services/ai_performance_summary_service.rb index 7290c72e..3cbb58c1 100644 --- a/app/services/ai_performance_summary_service.rb +++ b/app/services/ai_performance_summary_service.rb @@ -1,22 +1,25 @@ class AiPerformanceSummaryService + include AiProviderChat + SYSTEM_PROMPT = <<~PROMPT You are a senior Rails performance engineer. Analyze the performance target, recent stats, and a sample event. Provide a concise Root Cause Analysis (RCA), concrete optimization steps, and suggested tests/monitoring. Focus on controller action and database usage; include specific ActiveRecord or N+1 guidance when applicable. PROMPT - def initialize(target:, stats:, sample_event: nil) + def initialize(account:, target:, stats:, sample_event: nil) + @account = account @target = target @stats = stats || {} @event = sample_event end def call - return { error: "missing_api_key", message: "ANTHROPIC_API_KEY not configured" } if api_key.blank? + chat = ai_chat(@account) + return { error: "missing_config", message: "No AI provider configured" } unless chat - content = build_content - response = client_completion(content) - { summary: response } + response = chat.with_instructions(SYSTEM_PROMPT).ask(build_content) + { summary: response.content } rescue => e Rails.logger.error("AI perf summary failed: #{e.class}: #{e.message}") { error: "ai_error", message: e.message } @@ -24,40 +27,6 @@ def call private - def api_key - ENV["ANTHROPIC_API_KEY"] - end - - def client_completion(content) - require "net/http" - require "json" - - uri = URI.parse("https://api.anthropic.com/v1/messages") - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - - body = { - model: "claude-opus-4-20250514", - max_tokens: 2000, - system: SYSTEM_PROMPT, - messages: [ - { role: "user", content: content } - ] - } - - req = Net::HTTP::Post.new(uri.request_uri) - req["x-api-key"] = api_key - req["anthropic-version"] = "2023-06-01" - req["Content-Type"] = "application/json" - req.body = JSON.dump(body) - - res = http.request(req) - raise "Claude error: #{res.code} #{res.body}" unless res.code.to_i.between?(200, 299) - - json = JSON.parse(res.body) - json.dig("content", 0, "text") - end - def build_content parts = [] parts << "Target: #{@target}" diff --git a/app/services/ai_summary_service.rb b/app/services/ai_summary_service.rb index 145a1aba..0ddce42a 100644 --- a/app/services/ai_summary_service.rb +++ b/app/services/ai_summary_service.rb @@ -1,4 +1,6 @@ class AiSummaryService + include AiProviderChat + # Maximum number of files that can be auto-fixed in a single PR (safety limit) # Additional file suggestions will be shown to user for manual review MAX_FILES_PER_FIX = 1 @@ -62,7 +64,8 @@ class AiSummaryService 7. If other files also need changes, add a "## Related Changes" section listing them with brief descriptions - these will be shown as suggestions for the user to fix locally before creating the PR PROMPT - def initialize(issue:, sample_event: nil, github_client: nil) + def initialize(account:, issue:, sample_event: nil, github_client: nil) + @account = account @issue = issue @event = sample_event @github_client = github_client @@ -70,11 +73,12 @@ def initialize(issue:, sample_event: nil, github_client: nil) end def call - return { error: "missing_api_key", message: "ANTHROPIC_API_KEY not configured" } if api_key.blank? + chat = ai_chat(@account) + return { error: "missing_config", message: "No AI provider configured" } unless chat content = build_content - response = client_completion(content) - { summary: response } + response = chat.with_instructions(SYSTEM_PROMPT).ask(content) + { summary: response.content } rescue => e Rails.logger.error("AI summary failed: #{e.class}: #{e.message}") { error: "ai_error", message: e.message } @@ -82,40 +86,6 @@ def call private - def api_key - ENV["ANTHROPIC_API_KEY"] - end - - def client_completion(content) - require "net/http" - require "json" - - uri = URI.parse("https://api.anthropic.com/v1/messages") - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - - body = { - model: "claude-haiku-4-5-20251001", - max_tokens: 5000, - system: SYSTEM_PROMPT, - messages: [ - { role: "user", content: content } - ] - } - - req = Net::HTTP::Post.new(uri.request_uri) - req["x-api-key"] = api_key - req["anthropic-version"] = "2023-06-01" - req["Content-Type"] = "application/json" - req.body = JSON.dump(body) - - res = http.request(req) - raise "Claude error: #{res.code} #{res.body}" unless res.code.to_i.between?(200, 299) - - json = JSON.parse(res.body) - json.dig("content", 0, "text") - end - def build_content parts = [] parts << "# Error: #{@issue.exception_class}" diff --git a/app/services/concerns/ai_provider_chat.rb b/app/services/concerns/ai_provider_chat.rb new file mode 100644 index 00000000..f320932a --- /dev/null +++ b/app/services/concerns/ai_provider_chat.rb @@ -0,0 +1,25 @@ +module AiProviderChat + extend ActiveSupport::Concern + + private + + def ai_chat(account, model_type: :fast) + config = account.ai_provider_config + return nil unless config + + model = model_type == :power ? config.power_model : config.fast_model + + ctx = RubyLLM.context do |c| + case config.provider + when "anthropic" + c.anthropic_api_key = config.api_key + when "openai" + c.openai_api_key = config.api_key + when "gemini" + c.gemini_api_key = config.api_key + end + end + + ctx.chat(model: model) + end +end diff --git a/app/services/github/branch_name_generator.rb b/app/services/github/branch_name_generator.rb index 22282e29..3a823e0d 100644 --- a/app/services/github/branch_name_generator.rb +++ b/app/services/github/branch_name_generator.rb @@ -3,8 +3,10 @@ module Github # Generates branch names for GitHub PRs class BranchNameGenerator - def initialize(anthropic_key: nil) - @anthropic_key = anthropic_key || ENV["ANTHROPIC_API_KEY"] + include AiProviderChat + + def initialize(account:) + @account = account end def generate(issue, custom_branch_name = nil) @@ -16,8 +18,9 @@ def generate(issue, custom_branch_name = nil) end # Try to generate a meaningful branch name using AI - if @anthropic_key.present? - ai_branch = generate_ai_branch_name(issue) + chat = ai_chat(@account) + if chat + ai_branch = generate_ai_branch_name(chat, issue) if ai_branch.present? Rails.logger.info "[GitHub API] Using AI-generated branch name: #{ai_branch}" return ai_branch @@ -54,7 +57,7 @@ def sanitize_branch_name(name) sanitized end - def generate_ai_branch_name(issue) + def generate_ai_branch_name(chat, issue) prompt = <<~PROMPT Generate a short, descriptive git branch name for fixing this error. @@ -78,11 +81,11 @@ def generate_ai_branch_name(issue) PROMPT begin - response = claude_chat_completion(prompt) - return nil if response.blank? + response = chat.ask(prompt) + return nil if response.content.blank? # Clean up the response - branch = response.strip + branch = response.content.strip .gsub(/^["']|["']$/, "") # Remove quotes .gsub(/\s+/, "-") # Replace spaces with dashes .downcase @@ -134,35 +137,5 @@ def generate_fallback_branch_name(issue) "#{branch}-#{Time.now.strftime('%m%d%H%M')}" end - def claude_chat_completion(prompt) - require "net/http" - require "json" - - uri = URI.parse("https://api.anthropic.com/v1/messages") - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - http.read_timeout = 30 - - body = { - model: "claude-opus-4-20250514", - max_tokens: 2000, - system: "You are a senior Rails developer helping fix bugs. Be concise and practical.", - messages: [ - { role: "user", content: prompt } - ] - } - - req = Net::HTTP::Post.new(uri.request_uri) - req["x-api-key"] = @anthropic_key - req["anthropic-version"] = "2023-06-01" - req["Content-Type"] = "application/json" - req.body = JSON.dump(body) - - res = http.request(req) - raise "Claude error: #{res.code}" unless res.code.to_i.between?(200, 299) - - json = JSON.parse(res.body) - json.dig("content", 0, "text") - end end end diff --git a/app/services/github/pr_content_generator.rb b/app/services/github/pr_content_generator.rb index eb24c82b..5f3f6793 100644 --- a/app/services/github/pr_content_generator.rb +++ b/app/services/github/pr_content_generator.rb @@ -3,8 +3,10 @@ module Github # Generates PR content (title, body, code fix) using AI or existing summaries class PrContentGenerator - def initialize(anthropic_key: nil) - @anthropic_key = anthropic_key || ENV["ANTHROPIC_API_KEY"] + include AiProviderChat + + def initialize(account:) + @account = account end def generate(issue) @@ -26,7 +28,7 @@ def generate(issue) file_fixes: parsed[:file_fixes], related_changes: parsed[:related_changes] } - elsif @anthropic_key.present? + elsif @account.ai_configured? # Generate fresh AI analysis for the PR ai_result = generate_ai_pr_analysis(issue, sample_event) title = ai_result[:title] || "Fix #{issue.exception_class} in #{issue.controller_action}" @@ -384,13 +386,14 @@ def build_basic_pr_body(issue, sample_event) end def generate_ai_pr_analysis(issue, sample_event) - return {} unless @anthropic_key.present? + chat = ai_chat(@account, model_type: :power) + return {} unless chat prompt = build_pr_prompt(issue, sample_event) begin - response = claude_chat_completion(prompt) - parse_ai_pr_response(response, issue, sample_event) + response = chat.ask(prompt) + parse_ai_pr_response(response.content, issue, sample_event) rescue => e Rails.logger.error "[GitHub PR] AI analysis failed: #{e.message}" {} @@ -468,37 +471,6 @@ def parse_ai_pr_response(response, issue, sample_event) result end - def claude_chat_completion(prompt) - require "net/http" - require "json" - - uri = URI.parse("https://api.anthropic.com/v1/messages") - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - http.read_timeout = 30 - - body = { - model: "claude-opus-4-20250514", - max_tokens: 2000, - system: "You are a senior Rails developer helping fix bugs. Be concise and practical.", - messages: [ - { role: "user", content: prompt } - ] - } - - req = Net::HTTP::Post.new(uri.request_uri) - req["x-api-key"] = @anthropic_key - req["anthropic-version"] = "2023-06-01" - req["Content-Type"] = "application/json" - req.body = JSON.dump(body) - - res = http.request(req) - raise "Claude error: #{res.code}" unless res.code.to_i.between?(200, 299) - - json = JSON.parse(res.body) - json.dig("content", 0, "text") - end - def validate_method_structure(code) return false if code.blank? diff --git a/app/services/github/pr_service.rb b/app/services/github/pr_service.rb index 1ccefd04..796ee54e 100644 --- a/app/services/github/pr_service.rb +++ b/app/services/github/pr_service.rb @@ -17,7 +17,7 @@ def initialize(project) @project_app_pk = settings["github_app_pk"] @env_app_id = ENV["AR_GH_APP_ID"] @env_app_pk = Github::TokenManager.resolve_env_private_key - @anthropic_key = ENV["ANTHROPIC_API_KEY"] + @account = @project.account # Initialize service dependencies @token_manager = Github::TokenManager.new( @@ -29,8 +29,8 @@ def initialize(project) env_app_id: @env_app_id, env_app_pk: @env_app_pk ) - @branch_name_generator = Github::BranchNameGenerator.new(anthropic_key: @anthropic_key) - @pr_content_generator = Github::PrContentGenerator.new(anthropic_key: @anthropic_key) + @branch_name_generator = Github::BranchNameGenerator.new(account: @account) + @pr_content_generator = Github::PrContentGenerator.new(account: @account) end def create_n_plus_one_fix_pr(sql_fingerprint) @@ -207,7 +207,7 @@ def create_pr_for_issue(issue, custom_branch_name: nil) # Create commit with suggested fix if available # Use SimpleCodeFixApplier for reliable line-based fixes - code_fix_applier = Github::SimpleCodeFixApplier.new(api_client: api_client, anthropic_key: @anthropic_key, source_branch: source_branch) + code_fix_applier = Github::SimpleCodeFixApplier.new(api_client: api_client, account: @account, source_branch: source_branch) commit_result = create_fix_commit(api_client, code_fix_applier, owner, repo, branch, head_sha, issue, code_fix, before_code, pr_body, file_fixes) if commit_result.is_a?(Hash) && commit_result[:error] return { success: false, error: commit_result[:error] } diff --git a/app/services/github/simple_code_fix_applier.rb b/app/services/github/simple_code_fix_applier.rb index dbea18cc..93ea3854 100644 --- a/app/services/github/simple_code_fix_applier.rb +++ b/app/services/github/simple_code_fix_applier.rb @@ -4,9 +4,11 @@ module Github # Simple, reliable code fix applier using line-based replacement # Replaces the complex heuristic-based approach with a straightforward diff-like system class SimpleCodeFixApplier - def initialize(api_client:, anthropic_key: nil, source_branch: nil) + include AiProviderChat + + def initialize(api_client:, account:, source_branch: nil) @api_client = api_client - @anthropic_key = anthropic_key || ENV["ANTHROPIC_API_KEY"] + @account = account @source_branch = source_branch end @@ -517,7 +519,7 @@ def generate_precise_fix(issue, sample_event, error_frame, file_content, existin Rails.logger.info "[SimpleFixApplier] FAST PATH failed, falling back to AI" end - return nil unless @anthropic_key.present? + return nil unless @account.ai_configured? # Build context around error - expand to 30 lines for better understanding lines = file_content.lines @@ -824,7 +826,7 @@ def apply_fix_to_different_file(owner, repo, file_path, issue, existing_fix_code # Generate fix for a file that's different from the error location def generate_fix_for_file(file_path, file_content, issue, existing_fix_code) - return nil unless @anthropic_key.present? + return nil unless @account.ai_configured? lines = file_content.lines # Show first 50 lines for context (usually enough for a model file) @@ -902,34 +904,14 @@ def valid_ruby_syntax?(content) end def claude_completion(prompt) - require "net/http" - require "json" - - uri = URI.parse("https://api.anthropic.com/v1/messages") - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - http.read_timeout = 30 - - body = { - model: "claude-opus-4-20250514", - max_tokens: 2500, - messages: [{ role: "user", content: prompt }] - } - - req = Net::HTTP::Post.new(uri.request_uri) - req["x-api-key"] = @anthropic_key - req["anthropic-version"] = "2023-06-01" - req["Content-Type"] = "application/json" - req.body = JSON.dump(body) - - res = http.request(req) - unless res.code.to_i.between?(200, 299) - Rails.logger.error "[SimpleFixApplier] Claude API error: #{res.code} - #{res.body.first(200)}" - return nil - end + chat = ai_chat(@account, model_type: :power) + return nil unless chat - json = JSON.parse(res.body) - json.dig("content", 0, "text") + response = chat.ask(prompt) + response.content + rescue => e + Rails.logger.error "[SimpleFixApplier] AI API error: #{e.class}: #{e.message}" + nil end end end diff --git a/app/views/account_settings/show.html.erb b/app/views/account_settings/show.html.erb index e0bb788d..880977de 100644 --- a/app/views/account_settings/show.html.erb +++ b/app/views/account_settings/show.html.erb @@ -194,78 +194,152 @@ - +
Configure which notifications you want to receive and where
+Configure which AI provider powers summaries, code fixes, and PR generation
+- Set a personal channel (e.g., #my-alerts or @username) to receive notifications separately from the team -
+<%= config.provider.titleize %>
++ Fast: <%= config.fast_model || "default" %> · Power: <%= config.power_model || "default" %> +
+Used for summaries, branch names
+Used for code fixes, PR generation
+OPENAI_API_KEY to enable AI summaries.