Track failed LLM API calls in llm_usages table (#360)

* Track failed LLM API calls in llm_usages table

This commit adds comprehensive error tracking for failed LLM API calls:

- Updated LlmUsage model with helper methods to identify failed calls
  and retrieve error details (failed?, http_status_code, error_message)

- Modified Provider::Openai to record failed API calls with error metadata
  including HTTP status codes and error messages in both native and
  generic chat response methods

- Enhanced UsageRecorder concern with record_usage_error method to support
  error tracking for auto-categorization and auto-merchant detection

- Updated LLM usage UI to display failed calls with:
  - Red background highlighting for failed rows
  - Error indicator icon with "Failed" label
  - Interactive tooltip on hover showing error message and HTTP status code

Failed calls are now tracked with zero tokens and null cost, storing
error details in the metadata JSONB column for visibility and debugging.

* Dark mode fixes

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Juan José Mata
2025-11-22 02:15:20 +01:00
committed by GitHub
parent be0b20dfd9
commit f491916411
4 changed files with 137 additions and 7 deletions

View File

@@ -128,6 +128,21 @@ class LlmUsage < ApplicationRecord
estimated_cost.nil? ? "N/A" : "$#{estimated_cost.round(4)}"
end
# Check if this usage record represents a failed API call
def failed?
metadata.present? && metadata["error"].present?
end
# Get the HTTP status code from metadata
def http_status_code
metadata&.dig("http_status_code")
end
# Get the error message from metadata
def error_message
metadata&.dig("error")
end
# Estimate cost for auto-categorizing a batch of transactions
# Based on typical token usage patterns:
# - ~100 tokens per transaction in the prompt

View File

@@ -241,6 +241,7 @@ class Provider::Openai < Provider
session_id: session_id,
user_identifier: user_identifier
)
record_llm_usage(family: family, model: model, operation: "chat", error: e)
raise
end
end
@@ -314,6 +315,7 @@ class Provider::Openai < Provider
session_id: session_id,
user_identifier: user_identifier
)
record_llm_usage(family: family, model: model, operation: "chat", error: e)
raise
end
end
@@ -446,8 +448,36 @@ class Provider::Openai < Provider
Rails.logger.warn("Langfuse logging failed: #{e.message}")
end
def record_llm_usage(family:, model:, operation:, usage:)
return unless family && usage
def record_llm_usage(family:, model:, operation:, usage: nil, error: nil)
return unless family
# For error cases, record with zero tokens
if error.present?
Rails.logger.info("Recording failed LLM usage - Error: #{error.message}")
# Extract HTTP status code if available from the error
http_status_code = extract_http_status_code(error)
inferred_provider = LlmUsage.infer_provider(model)
family.llm_usages.create!(
provider: inferred_provider,
model: model,
operation: operation,
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
estimated_cost: nil,
metadata: {
error: error.message,
http_status_code: http_status_code
}
)
Rails.logger.info("Failed LLM usage recorded successfully - Status: #{http_status_code}")
return
end
return unless usage
Rails.logger.info("Recording LLM usage - Raw usage data: #{usage.inspect}")
@@ -487,4 +517,23 @@ class Provider::Openai < Provider
rescue => e
Rails.logger.error("Failed to record LLM usage: #{e.message}")
end
def extract_http_status_code(error)
# Try to extract HTTP status code from various error types
# OpenAI gem errors may have status codes in different formats
if error.respond_to?(:code)
error.code
elsif error.respond_to?(:http_status)
error.http_status
elsif error.respond_to?(:status_code)
error.status_code
elsif error.respond_to?(:response) && error.response.respond_to?(:code)
error.response.code.to_i
elsif error.message =~ /(\d{3})/
# Extract 3-digit HTTP status code from error message
$1.to_i
else
nil
end
end
end

View File

@@ -44,4 +44,53 @@ module Provider::Openai::Concerns::UsageRecorder
rescue => e
Rails.logger.error("Failed to record LLM usage: #{e.message}")
end
# Records failed LLM usage for a family with error details
def record_usage_error(model_name, operation:, error:, metadata: {})
return unless family
Rails.logger.info("Recording failed LLM usage - Operation: #{operation}, Error: #{error.message}")
# Extract HTTP status code if available from the error
http_status_code = extract_http_status_code(error)
error_metadata = metadata.merge(
error: error.message,
http_status_code: http_status_code
)
inferred_provider = LlmUsage.infer_provider(model_name)
family.llm_usages.create!(
provider: inferred_provider,
model: model_name,
operation: operation,
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
estimated_cost: nil,
metadata: error_metadata
)
Rails.logger.info("Failed LLM usage recorded - Operation: #{operation}, Status: #{http_status_code}")
rescue => e
Rails.logger.error("Failed to record LLM usage error: #{e.message}")
end
def extract_http_status_code(error)
# Try to extract HTTP status code from various error types
if error.respond_to?(:code)
error.code
elsif error.respond_to?(:http_status)
error.http_status
elsif error.respond_to?(:status_code)
error.status_code
elsif error.respond_to?(:response) && error.response.respond_to?(:code)
error.response.code.to_i
elsif error.message =~ /(\d{3})/
# Extract 3-digit HTTP status code from error message
$1.to_i
else
nil
end
end
end

View File

@@ -117,7 +117,7 @@
</thead>
<tbody class="divide-y divide-gray-100">
<% @llm_usages.each do |usage| %>
<tr>
<tr class="<%= 'bg-red-50 theme-dark:bg-red-950/30' if usage.failed? %>">
<td class="px-4 py-3 text-sm text-primary whitespace-nowrap">
<%= usage.created_at.strftime("%b %d, %Y %I:%M %p") %>
</td>
@@ -128,10 +128,27 @@
<%= usage.model %>
</td>
<td class="px-4 py-3 text-sm text-primary text-right whitespace-nowrap">
<%= number_with_delimiter(usage.total_tokens) %>
<span class="text-xs text-secondary">
(<%= number_with_delimiter(usage.prompt_tokens) %>/<%= number_with_delimiter(usage.completion_tokens) %>)
</span>
<% if usage.failed? %>
<div data-controller="tooltip" class="inline-flex justify-end">
<div class="inline-flex items-center gap-1 cursor-help">
<%= icon "alert-circle", class: "w-4 h-4 text-red-600 theme-dark:text-red-400" %>
<span class="text-red-600 theme-dark:text-red-400 font-medium">Failed</span>
</div>
<div role="tooltip" data-tooltip-target="tooltip" class="tooltip bg-gray-800 text-sm p-3 rounded w-72 text-left break-words whitespace-normal shadow-lg hidden">
<div class="text-white">
<% if usage.http_status_code.present? %>
<p class="text-xs mt-1 text-gray-300">HTTP Status: <%= usage.http_status_code %></p>
<% end %>
<p class="text-xs"><%= usage.error_message %></p>
</div>
</div>
</div>
<% else %>
<%= number_with_delimiter(usage.total_tokens) %>
<span class="text-xs text-secondary">
(<%= number_with_delimiter(usage.prompt_tokens) %>/<%= number_with_delimiter(usage.completion_tokens) %>)
</span>
<% end %>
</td>
<td class="px-4 py-3 text-sm font-medium text-primary text-right whitespace-nowrap">
<%= usage.formatted_cost %>