Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ gem "jwt", "~> 2.8"
# Slack notifications
gem "slack-notifier"

# AI provider abstraction
gem "ruby_llm"

# Markdown rendering
gem "redcarpet"

Expand Down
15 changes: 15 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -598,6 +612,7 @@ DEPENDENCIES
resend
rspec-rails (~> 6.0)
rubocop-rails-omakase
ruby_llm
shoulda-matchers
sidekiq
sidekiq-cron
Expand Down
61 changes: 61 additions & 0 deletions app/controllers/ai_provider_configs_controller.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion app/controllers/api/v1/cli/incidents_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 2 additions & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class ApplicationController < ActionController::Base
checkouts
subscriptions
account_settings
ai_provider_configs
settings
].freeze

Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions app/controllers/errors_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/performance_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
7 changes: 4 additions & 3 deletions app/helpers/errors_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down
186 changes: 186 additions & 0 deletions app/javascript/controllers/ai_provider_form_controller.js
Original file line number Diff line number Diff line change
@@ -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 => `
<button type="button"
data-value="${m.value}"
data-action="click->ai-provider-form#selectOption"
class="model-option w-full text-left px-3 py-2 text-sm hover:bg-indigo-50 flex items-center justify-between ${m.value === selectedValue ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700'}">
<span>
<span class="font-medium">${m.label}</span>
<span class="text-xs text-gray-400 ml-1">${m.value}</span>
</span>
${m.value === defaultValue ? '<span class="text-xs text-indigo-500">default</span>' : ''}
</button>
`).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")
}
}
}
2 changes: 1 addition & 1 deletion app/jobs/ai_summary_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading
Loading