rails_error_dashboard 0.6.4 → 0.7.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.
@@ -0,0 +1,368 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module RailsErrorDashboard
8
+ module Services
9
+ class LlmClient
10
+ class ConfigurationError < StandardError; end
11
+ class RequestError < StandardError; end
12
+
13
+ OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses"
14
+ OPENAI_CHAT_COMPLETIONS_URL = "https://api.openai.com/v1/chat/completions"
15
+ ANTHROPIC_MESSAGES_URL = "https://api.anthropic.com/v1/messages"
16
+ ANTHROPIC_VERSION = "2023-06-01"
17
+
18
+ attr_reader :config
19
+
20
+ def self.call(error:, question:, context:)
21
+ new.call(error: error, question: question, context: context)
22
+ end
23
+
24
+ def self.stream(error:, question:, context:, &block)
25
+ new.stream(error: error, question: question, context: context, &block)
26
+ end
27
+
28
+ def initialize(config: RailsErrorDashboard.configuration)
29
+ @config = config
30
+ end
31
+
32
+ def call(error:, question:, context:)
33
+ raise ConfigurationError, "AI Help is not configured" unless config.llm_configured?
34
+
35
+ case config.effective_llm_provider
36
+ when :openai
37
+ openai_response(error: error, question: question, context: context)
38
+ when :anthropic
39
+ anthropic_response(error: error, question: question, context: context)
40
+ else
41
+ raise ConfigurationError, "Unsupported LLM provider: #{config.llm_provider.inspect}"
42
+ end
43
+ end
44
+
45
+ def stream(error:, question:, context:, &block)
46
+ raise ConfigurationError, "AI Help is not configured" unless config.llm_configured?
47
+ raise ArgumentError, "block is required for streaming" unless block
48
+
49
+ case config.effective_llm_provider
50
+ when :openai
51
+ openai_stream(error: error, question: question, context: context, &block)
52
+ when :anthropic
53
+ anthropic_stream(error: error, question: question, context: context, &block)
54
+ else
55
+ raise ConfigurationError, "Unsupported LLM provider: #{config.llm_provider.inspect}"
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def openai_response(error:, question:, context:)
62
+ model = config.effective_llm_model
63
+ mode = openai_endpoint_for(model)
64
+
65
+ if mode == :chat_completions
66
+ response = post_json(
67
+ OPENAI_CHAT_COMPLETIONS_URL,
68
+ openai_headers,
69
+ openai_chat_payload(error: error, question: question, context: context, model: model)
70
+ )
71
+ answer = response.dig("choices", 0, "message", "content")
72
+ else
73
+ response = post_json(
74
+ OPENAI_RESPONSES_URL,
75
+ openai_headers,
76
+ openai_responses_payload(error: error, question: question, context: context, model: model)
77
+ )
78
+ answer = extract_openai_response_text(response)
79
+ end
80
+
81
+ provider_result(answer, :openai, model)
82
+ end
83
+
84
+ def anthropic_response(error:, question:, context:)
85
+ model = config.effective_llm_model
86
+ response = post_json(
87
+ ANTHROPIC_MESSAGES_URL,
88
+ anthropic_headers,
89
+ anthropic_payload(error: error, question: question, context: context, model: model)
90
+ )
91
+ answer = Array(response["content"]).filter_map { |part| part["text"] if part["type"] == "text" }.join("\n\n")
92
+
93
+ provider_result(answer, :anthropic, model)
94
+ end
95
+
96
+ def openai_stream(error:, question:, context:)
97
+ model = config.effective_llm_model
98
+ mode = openai_endpoint_for(model)
99
+
100
+ if mode == :chat_completions
101
+ payload = openai_chat_payload(error: error, question: question, context: context, model: model).merge(stream: true)
102
+ post_stream_json(OPENAI_CHAT_COMPLETIONS_URL, openai_headers, payload) do |event, data|
103
+ handle_openai_stream_event(event, data) { |text| yield text }
104
+ end
105
+ else
106
+ payload = openai_responses_payload(error: error, question: question, context: context, model: model).merge(stream: true)
107
+ post_stream_json(OPENAI_RESPONSES_URL, openai_headers, payload) do |event, data|
108
+ handle_openai_stream_event(event, data) { |text| yield text }
109
+ end
110
+ end
111
+
112
+ { provider: "openai", model: model }
113
+ end
114
+
115
+ def anthropic_stream(error:, question:, context:)
116
+ model = config.effective_llm_model
117
+ payload = anthropic_payload(error: error, question: question, context: context, model: model).merge(stream: true)
118
+
119
+ post_stream_json(ANTHROPIC_MESSAGES_URL, anthropic_headers, payload) do |event, data|
120
+ handle_anthropic_stream_event(event, data) { |text| yield text }
121
+ end
122
+
123
+ { provider: "anthropic", model: model }
124
+ end
125
+
126
+ def openai_endpoint_for(model)
127
+ configured = config.llm_openai_endpoint&.to_sym || :auto
128
+ return configured unless configured == :auto
129
+
130
+ :responses
131
+ end
132
+
133
+ def openai_headers
134
+ {
135
+ "Authorization" => "Bearer #{config.effective_llm_api_key}",
136
+ "Content-Type" => "application/json"
137
+ }
138
+ end
139
+
140
+ def anthropic_headers
141
+ {
142
+ "x-api-key" => config.effective_llm_api_key,
143
+ "anthropic-version" => ANTHROPIC_VERSION,
144
+ "Content-Type" => "application/json"
145
+ }
146
+ end
147
+
148
+ def openai_responses_payload(error:, question:, context:, model:)
149
+ payload = {
150
+ model: model,
151
+ instructions: system_prompt,
152
+ input: user_prompt(error: error, question: question, context: context),
153
+ max_output_tokens: config.llm_max_output_tokens.to_i
154
+ }
155
+
156
+ payload
157
+ end
158
+
159
+ def openai_chat_payload(error:, question:, context:, model:)
160
+ {
161
+ model: model,
162
+ messages: [
163
+ { role: "system", content: system_prompt },
164
+ { role: "user", content: user_prompt(error: error, question: question, context: context) }
165
+ ],
166
+ max_tokens: config.llm_max_output_tokens.to_i
167
+ }
168
+ end
169
+
170
+ def anthropic_payload(error:, question:, context:, model:)
171
+ {
172
+ model: model,
173
+ max_tokens: config.llm_max_output_tokens.to_i,
174
+ system: system_prompt,
175
+ messages: [
176
+ { role: "user", content: user_prompt(error: error, question: question, context: context) }
177
+ ]
178
+ }
179
+ end
180
+
181
+ def system_prompt
182
+ prompt = <<~PROMPT.strip
183
+ You are helping debug a Rails exception from Rails Error Dashboard.
184
+ Answer only from the provided error context unless you clearly label an inference.
185
+ Focus on likely root cause, useful next checks, and concrete Rails code or data fixes.
186
+ Do not ask for secrets, credentials, or unrelated source code.
187
+ PROMPT
188
+
189
+ [ prompt, config.llm_system_prompt.presence ].compact.join("\n\n")
190
+ end
191
+
192
+ def user_prompt(error:, question:, context:)
193
+ <<~PROMPT
194
+ Error ID: #{error.id}
195
+ Error type: #{error.error_type}
196
+ Severity: #{error.severity}
197
+ Occurrences: #{error.occurrence_count}
198
+
199
+ User question:
200
+ #{question}
201
+
202
+ Error context:
203
+ #{context}
204
+ PROMPT
205
+ end
206
+
207
+ def post_json(url, headers, payload)
208
+ uri = URI.parse(url)
209
+ request = Net::HTTP::Post.new(uri.request_uri, headers)
210
+ request.body = JSON.generate(payload)
211
+
212
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
213
+ open_timeout: config.llm_timeout_seconds.to_i,
214
+ read_timeout: config.llm_timeout_seconds.to_i) do |http|
215
+ http.request(request)
216
+ end
217
+
218
+ parsed = parse_json_response(response)
219
+ return parsed if response.is_a?(Net::HTTPSuccess)
220
+
221
+ message = provider_error_message(parsed, response.message)
222
+ raise RequestError, "LLM request failed (#{response.code}): #{message}"
223
+ rescue JSON::ParserError
224
+ raise RequestError, "LLM provider returned invalid JSON"
225
+ rescue Net::OpenTimeout, Net::ReadTimeout
226
+ raise RequestError, "LLM request timed out"
227
+ rescue SocketError, SystemCallError => e
228
+ raise RequestError, "LLM request failed: #{e.message}"
229
+ end
230
+
231
+ def parse_json_response(response)
232
+ body = response.body.to_s
233
+ body.present? ? JSON.parse(body) : {}
234
+ end
235
+
236
+ def extract_openai_response_text(response)
237
+ return response["output_text"] if response["output_text"].present?
238
+
239
+ Array(response["output"]).flat_map do |item|
240
+ Array(item["content"]).filter_map { |part| part["text"] if part["type"] == "output_text" || part["type"] == "text" }
241
+ end.join("\n\n")
242
+ end
243
+
244
+ def provider_result(answer, provider, model)
245
+ raise RequestError, "LLM provider returned an empty answer" if answer.blank?
246
+
247
+ { answer: answer, provider: provider.to_s, model: model }
248
+ end
249
+
250
+ def post_stream_json(url, headers, payload)
251
+ uri = URI.parse(url)
252
+ stream_headers = headers.merge(
253
+ "Accept" => "text/event-stream",
254
+ "Accept-Encoding" => "identity",
255
+ "Cache-Control" => "no-cache"
256
+ )
257
+ request = Net::HTTP::Post.new(uri.request_uri, stream_headers)
258
+ request.body = JSON.generate(payload)
259
+
260
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
261
+ open_timeout: config.llm_timeout_seconds.to_i,
262
+ read_timeout: config.llm_timeout_seconds.to_i) do |http|
263
+ http.request(request) do |response|
264
+ unless response.is_a?(Net::HTTPSuccess)
265
+ body = +""
266
+ response.read_body { |chunk| body << chunk }
267
+ parsed = JSON.parse(body) rescue {}
268
+ message = provider_error_message(parsed, response.message)
269
+ raise RequestError, "LLM request failed (#{response.code}): #{message}"
270
+ end
271
+
272
+ each_sse_event(response) do |event, data|
273
+ next if data == "[DONE]"
274
+
275
+ parsed = JSON.parse(data)
276
+ yield event, parsed
277
+ rescue JSON::ParserError
278
+ raise RequestError, "LLM provider returned invalid streaming JSON"
279
+ end
280
+ end
281
+ end
282
+ rescue Net::OpenTimeout, Net::ReadTimeout
283
+ raise RequestError, "LLM request timed out"
284
+ rescue SocketError, SystemCallError => e
285
+ raise RequestError, "LLM request failed: #{e.message}"
286
+ end
287
+
288
+ def each_sse_event(response)
289
+ buffer = +""
290
+
291
+ response.read_body do |chunk|
292
+ buffer << chunk.to_s
293
+ buffer.gsub!("\r\n", "\n")
294
+ while (separator = buffer.index("\n\n"))
295
+ raw_event = buffer.slice!(0, separator + 2)
296
+ event, data = parse_sse_event(raw_event)
297
+ yield event, data if data.present?
298
+ end
299
+ end
300
+
301
+ event, data = parse_sse_event(buffer)
302
+ yield event, data if data.present?
303
+ end
304
+
305
+ def parse_sse_event(raw_event)
306
+ event = "message"
307
+ data_lines = []
308
+
309
+ raw_event.to_s.each_line do |line|
310
+ line = line.chomp
311
+ next if line.blank? || line.start_with?(":")
312
+
313
+ field, value = line.split(":", 2)
314
+ value = value.to_s.sub(/\A /, "")
315
+
316
+ case field
317
+ when "event"
318
+ event = value
319
+ when "data"
320
+ data_lines << value
321
+ end
322
+ end
323
+
324
+ [ event, data_lines.join("\n") ]
325
+ end
326
+
327
+ def handle_openai_stream_event(event, data)
328
+ if event == "error" || data["type"] == "error"
329
+ message = provider_error_message(data, "OpenAI stream failed")
330
+ raise RequestError, message
331
+ end
332
+
333
+ text = if event == "response.output_text.delta" || data["type"] == "response.output_text.delta"
334
+ data["delta"]
335
+ else
336
+ data.dig("choices", 0, "delta", "content")
337
+ end
338
+
339
+ yield text if text.present?
340
+ end
341
+
342
+ def handle_anthropic_stream_event(event, data)
343
+ if event == "error" || data["type"] == "error"
344
+ message = provider_error_message(data, "Anthropic stream failed")
345
+ raise RequestError, message
346
+ end
347
+
348
+ delta = data["delta"] || {}
349
+ text = delta["text"] if data["type"] == "content_block_delta" && delta["type"] == "text_delta"
350
+ yield text if text.present?
351
+ end
352
+
353
+ def provider_error_message(parsed, fallback)
354
+ return fallback unless parsed.is_a?(Hash)
355
+
356
+ error = parsed["error"]
357
+ case error
358
+ when Hash
359
+ error["message"].presence || parsed["message"].presence || fallback
360
+ when String
361
+ error.presence || parsed["message"].presence || fallback
362
+ else
363
+ parsed["message"].presence || fallback
364
+ end
365
+ end
366
+ end
367
+ end
368
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ # Estimates USD cost for an LLM call given provider, model, and token counts.
6
+ #
7
+ # Prices are stored as USD per 1,000,000 tokens (the canonical unit used
8
+ # by OpenAI, Anthropic, and Google in their pricing pages).
9
+ #
10
+ # IMPORTANT: This is a best-effort estimate using a hardcoded snapshot.
11
+ # Prices change frequently. Users SHOULD configure overrides via
12
+ # `config.llm_pricing_overrides` for production accuracy.
13
+ #
14
+ # Returns nil for unknown models (no override AND not in the built-in table).
15
+ # Never raises — wrapped in rescue.
16
+ class LlmCostEstimator
17
+ # Prices last refreshed: 2026-05 (approximate, see provider pricing pages).
18
+ # Format: { "model-name" => { input: usd_per_1m, output: usd_per_1m } }
19
+ PRICES = {
20
+ # Anthropic
21
+ "claude-opus-4-7" => { input: 15.0, output: 75.0 },
22
+ "claude-sonnet-4-6" => { input: 3.0, output: 15.0 },
23
+ "claude-haiku-4-5" => { input: 0.80, output: 4.0 },
24
+
25
+ # OpenAI
26
+ "gpt-4o" => { input: 2.50, output: 10.0 },
27
+ "gpt-4o-mini" => { input: 0.15, output: 0.60 },
28
+ "gpt-4-turbo" => { input: 10.0, output: 30.0 },
29
+ "o1" => { input: 15.0, output: 60.0 },
30
+ "o1-mini" => { input: 3.0, output: 12.0 },
31
+
32
+ # Google
33
+ "gemini-2.5-pro" => { input: 1.25, output: 5.0 },
34
+ "gemini-2.5-flash" => { input: 0.075, output: 0.30 }
35
+ }.freeze
36
+
37
+ # @param provider [String, Symbol] currently informational only (not used in lookup)
38
+ # @param model [String] model identifier — matched case-insensitively against PRICES
39
+ # @param input_tokens [Integer, nil]
40
+ # @param output_tokens [Integer, nil]
41
+ # @return [Float, nil] estimated USD cost, or nil if model unknown / tokens missing
42
+ def self.estimate(provider:, model:, input_tokens:, output_tokens:)
43
+ return nil if model.nil? || model.to_s.empty?
44
+ return nil if input_tokens.nil? && output_tokens.nil?
45
+
46
+ rate = lookup_rate(model.to_s)
47
+ return nil unless rate
48
+
49
+ in_tokens = input_tokens.to_i
50
+ out_tokens = output_tokens.to_i
51
+
52
+ cost = (in_tokens * rate[:input] + out_tokens * rate[:output]) / 1_000_000.0
53
+ cost.round(6)
54
+ rescue
55
+ nil
56
+ end
57
+
58
+ def self.lookup_rate(model)
59
+ # User-configured override wins. Match case-insensitively on the model key.
60
+ overrides = RailsErrorDashboard.configuration.llm_pricing_overrides || {}
61
+ normalized = model.downcase
62
+
63
+ overrides.each do |key, rate|
64
+ return symbolize_rate(rate) if key.to_s.downcase == normalized
65
+ end
66
+
67
+ PRICES.each do |key, rate|
68
+ return rate if key.downcase == normalized
69
+ end
70
+
71
+ nil
72
+ end
73
+
74
+ def self.symbolize_rate(rate)
75
+ return nil unless rate.is_a?(Hash)
76
+ {
77
+ input: rate[:input] || rate["input"],
78
+ output: rate[:output] || rate["output"]
79
+ }
80
+ end
81
+
82
+ private_class_method :lookup_rate, :symbolize_rate
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ # Pure algorithm: Roll up LLM breadcrumbs into a per-error summary.
6
+ #
7
+ # Operates on already-captured breadcrumb data at display time only —
8
+ # zero runtime cost. Same pattern as CacheAnalyzer / NplusOneDetector.
9
+ #
10
+ # NOTE on string coercion: BreadcrumbCollector#truncate_metadata stringifies
11
+ # every metadata value (input_tokens "42", cost_usd "0.0003", etc.). This
12
+ # service does the `.to_i` / `.to_f` itself so callers don't have to.
13
+ #
14
+ # @example
15
+ # RailsErrorDashboard::Services::LlmSummary.call(breadcrumbs)
16
+ # # => { total_calls: 3, total_tool_calls: 2, total_input_tokens: 1450,
17
+ # # total_output_tokens: 220, total_tokens: 1670,
18
+ # # total_cost_usd: 0.00821, error_count: 1, total_duration_ms: 4321.5,
19
+ # # providers: ["anthropic", "openai"],
20
+ # # by_model: [ { provider: "openai", model: "gpt-4o-mini",
21
+ # # calls: 2, tokens: 800, cost_usd: 0.0042 }, ... ] }
22
+ class LlmSummary
23
+ # @param breadcrumbs [Array<Hash>] Parsed breadcrumb array
24
+ # @return [Hash, nil] Summary hash, or nil if no LLM breadcrumbs present
25
+ def self.call(breadcrumbs)
26
+ return nil unless breadcrumbs.is_a?(Array)
27
+
28
+ llm_crumbs = breadcrumbs.select { |c| c.is_a?(Hash) && c["c"] == "llm" }
29
+ tool_crumbs = breadcrumbs.select { |c| c.is_a?(Hash) && c["c"] == "llm_tool" }
30
+ return nil if llm_crumbs.empty? && tool_crumbs.empty?
31
+
32
+ total_input = 0
33
+ total_output = 0
34
+ total_cost = 0.0
35
+ total_duration = 0.0
36
+ error_count = 0
37
+ providers = {}
38
+ by_model = {}
39
+
40
+ llm_crumbs.each do |crumb|
41
+ meta = crumb["meta"].is_a?(Hash) ? crumb["meta"] : {}
42
+ provider = meta["provider"].to_s
43
+ model = meta["model"].to_s
44
+ input = meta["input_tokens"].to_i
45
+ output = meta["output_tokens"].to_i
46
+ cost = meta["cost_usd"].to_f
47
+ duration = crumb["d"].to_f
48
+ status = meta["status"].to_s
49
+
50
+ total_input += input
51
+ total_output += output
52
+ total_cost += cost
53
+ total_duration += duration
54
+ error_count += 1 unless status == "success" || status == ""
55
+
56
+ providers[provider] = true unless provider.empty?
57
+
58
+ key = [ provider, model ]
59
+ by_model[key] ||= { provider: provider, model: model, calls: 0, tokens: 0, cost_usd: 0.0 }
60
+ by_model[key][:calls] += 1
61
+ by_model[key][:tokens] += input + output
62
+ by_model[key][:cost_usd] += cost
63
+ end
64
+
65
+ # Tool calls also contribute to duration (visible request impact)
66
+ tool_crumbs.each do |crumb|
67
+ total_duration += crumb["d"].to_f
68
+ end
69
+
70
+ {
71
+ total_calls: llm_crumbs.size,
72
+ total_tool_calls: tool_crumbs.size,
73
+ total_input_tokens: total_input,
74
+ total_output_tokens: total_output,
75
+ total_tokens: total_input + total_output,
76
+ total_cost_usd: total_cost.round(6),
77
+ error_count: error_count,
78
+ total_duration_ms: total_duration.round(1),
79
+ providers: providers.keys.sort,
80
+ by_model: by_model.values.sort_by { |row| -row[:calls] }.map { |row|
81
+ row[:cost_usd] = row[:cost_usd].round(6)
82
+ row
83
+ }
84
+ }
85
+ rescue => e
86
+ RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] LlmSummary.call failed: #{e.message}")
87
+ nil
88
+ end
89
+ end
90
+ end
91
+ end
@@ -15,6 +15,7 @@ module RailsErrorDashboard
15
15
  MAX_BACKTRACE_LINES = 15
16
16
  MAX_BREADCRUMBS = 10
17
17
  MAX_VARIABLES = 10
18
+ MAX_LLM_CALL_ROWS = 10
18
19
 
19
20
  # @param error [ErrorLog] An error log record
20
21
  # @param related_errors [Array] Related error results with :error and :similarity
@@ -41,6 +42,7 @@ module RailsErrorDashboard
41
42
  sections << local_variables_section
42
43
  sections << instance_variables_section
43
44
  sections << request_context_section
45
+ sections << llm_calls_section
44
46
  sections << breadcrumbs_section
45
47
  sections << environment_section
46
48
  sections << system_health_section
@@ -198,6 +200,91 @@ module RailsErrorDashboard
198
200
  "## Request Context\n\n#{items.join("\n")}"
199
201
  end
200
202
 
203
+ def llm_calls_section
204
+ raw = @error.breadcrumbs
205
+ return nil if raw.blank?
206
+
207
+ crumbs = parse_json(raw)
208
+ return nil unless crumbs.is_a?(Array) && crumbs.any?
209
+
210
+ summary = LlmSummary.call(crumbs)
211
+ return nil unless summary
212
+
213
+ llm_crumbs = crumbs.select { |c| c.is_a?(Hash) && (c["c"] == "llm" || c["c"] == "llm_tool") }
214
+ return nil if llm_crumbs.empty?
215
+
216
+ # One-line totals: tokens / cost / duration / errors
217
+ totals_parts = []
218
+ totals_parts << "#{summary[:total_calls]} call#{summary[:total_calls] == 1 ? "" : "s"}"
219
+ totals_parts << "#{summary[:total_tool_calls]} tool call#{summary[:total_tool_calls] == 1 ? "" : "s"}" if summary[:total_tool_calls] > 0
220
+ totals_parts << "#{summary[:total_tokens]} tokens (in:#{summary[:total_input_tokens]}/out:#{summary[:total_output_tokens]})" if summary[:total_tokens] > 0
221
+ totals_parts << "$#{format("%.6f", summary[:total_cost_usd])}" if summary[:total_cost_usd] > 0
222
+ totals_parts << "#{summary[:total_duration_ms]}ms total"
223
+ totals_parts << "**#{summary[:error_count]} error#{summary[:error_count] == 1 ? "" : "s"}**" if summary[:error_count] > 0
224
+
225
+ out = [ "## LLM Calls", "", totals_parts.join(" · ") ]
226
+
227
+ if summary[:by_model].any?
228
+ out << ""
229
+ out << "**By Model**"
230
+ out << ""
231
+ out << "| Provider | Model | Calls | Tokens | Cost |"
232
+ out << "|----------|-------|-------|--------|------|"
233
+ summary[:by_model].each do |row|
234
+ cost_cell = row[:cost_usd] > 0 ? "$#{format("%.6f", row[:cost_usd])}" : "—"
235
+ out << "| #{row[:provider].presence || "—"} | #{row[:model].presence || "—"} | #{row[:calls]} | #{row[:tokens]} | #{cost_cell} |"
236
+ end
237
+ end
238
+
239
+ # Per-call detail table — last N events (chat + tool, in order)
240
+ recent = llm_crumbs.last(MAX_LLM_CALL_ROWS)
241
+ out << ""
242
+ out << "**Calls (last #{recent.size})**"
243
+ out << ""
244
+ out << "| Time | Type | Provider/Model | Status | Tokens | Cost | Duration |"
245
+ out << "|------|------|----------------|--------|--------|------|----------|"
246
+ recent.each do |c|
247
+ meta = c["meta"].is_a?(Hash) ? c["meta"] : {}
248
+ time = c["t"] ? Time.at(c["t"] / 1000.0).utc.strftime("%H:%M:%S.%L") : "—"
249
+ type = c["c"] == "llm_tool" ? "tool" : "chat"
250
+ provider_model = if c["c"] == "llm_tool"
251
+ "tool: #{truncate_value(meta["tool_name"], 40)}"
252
+ else
253
+ [ meta["provider"].presence, meta["model"].presence ].compact.join("/").presence || "—"
254
+ end
255
+ status = meta["status"].presence || "—"
256
+ tokens = if meta["input_tokens"].present? || meta["output_tokens"].present?
257
+ "#{meta["input_tokens"] || "?"}/#{meta["output_tokens"] || "?"}"
258
+ else
259
+ "—"
260
+ end
261
+ cost = meta["cost_usd"].present? && meta["cost_usd"].to_f > 0 ? "$#{format("%.6f", meta["cost_usd"].to_f)}" : "—"
262
+ duration = c["d"] ? "#{c["d"]}ms" : "—"
263
+ out << "| #{time} | #{type} | #{truncate_value(provider_model, 40)} | #{status} | #{tokens} | #{cost} | #{duration} |"
264
+ end
265
+
266
+ # Surface error messages so the LLM consumer sees them inline rather than
267
+ # having to cross-reference the breadcrumb stream below.
268
+ error_lines = llm_crumbs.select { |c|
269
+ c["c"] == "llm" && c["meta"].is_a?(Hash) && c["meta"]["status"] && c["meta"]["status"] != "success"
270
+ }
271
+ if error_lines.any?
272
+ out << ""
273
+ out << "**Failures**"
274
+ out << ""
275
+ error_lines.each do |c|
276
+ meta = c["meta"]
277
+ label = [ meta["provider"], meta["model"] ].compact.join("/")
278
+ err_class = meta["error_class"].presence
279
+ err_msg = meta["error_message"].presence
280
+ detail = [ err_class, err_msg ].compact.join(": ")
281
+ out << "- `#{label}` — #{meta["status"]}#{detail.empty? ? "" : ": #{detail}"}"
282
+ end
283
+ end
284
+
285
+ out.join("\n")
286
+ end
287
+
201
288
  def breadcrumbs_section
202
289
  raw = @error.breadcrumbs
203
290
  return nil if raw.blank?