llm_logs 0.1.6 → 0.2.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e59977f8fa67dd219ec626f20f5320c325b0eab9349c7b7b69ba75c200353513
4
- data.tar.gz: cc78c8b7ed85a03e2b2b85ebee10107c51089dc1fe012de14e2fdd3de93cb126
3
+ metadata.gz: 5eb2c037f1c19d83e843722bd9ca664324311e7fb6b75de47a96569919cae05b
4
+ data.tar.gz: 0db8f27945ce97dd74f9e4fedb5c337b68af537a83ab17dfc3f6bcd9ad18d360
5
5
  SHA512:
6
- metadata.gz: f97806146008910196d8072f6c7c051fc67362705c6a28ed6486afe465bc0d58865e0f590efee24b41be57ef2fc853eac942741b64b940d705212a096ac5577e
7
- data.tar.gz: 166a50f08ab90b2568a2cd8bc6b0942ab1bcd75e97839e82d9ceb8f6b91e3a8e5aec7f23cc318af444b96f3b2eafea31f2c8ea10f4f1059678761bd356b34b27
6
+ metadata.gz: fa82e4b390f8528a1d89c386ffb92b3c51f7e2f496ecec4061d4357dac679eb880dec648f6ca2d8885c256b3f175c8821a96143e318ac4e875dfa39aea4fe578
7
+ data.tar.gz: 8f3c2ae33e33667c3b4d1afa60a3afbe3154e6f5f6c9982892e772d4c8d7c60f5b5fa7c437ab5c597f79f9eefe9a0ecafea1642f6280e0508143d91ee20d50b2
data/README.md CHANGED
@@ -180,6 +180,72 @@ messages:
180
180
 
181
181
  Running the task creates missing prompts, updates metadata, and creates a new prompt version only when messages, model, or model parameters changed.
182
182
 
183
+ ## Batches
184
+
185
+ Send requests through the [OpenAI Responses Batch API](https://platform.openai.com/docs/guides/batch) for roughly half the cost when latency doesn't matter. LlmLogs persists each request, groups pending requests into a provider batch, reconciles results, and records a trace per request — so batched work shows up in the dashboard alongside synchronous calls.
186
+
187
+ Batch support uses the [`ruby_llm-responses_api`](https://rubygems.org/gems/ruby_llm-responses_api) provider. Add it to your app's Gemfile:
188
+
189
+ ```ruby
190
+ gem "ruby_llm-responses_api"
191
+ ```
192
+
193
+ ### Enqueue a Request
194
+
195
+ Requests are persisted immediately and grouped by `purpose` + `model` when submitted:
196
+
197
+ ```ruby
198
+ LlmLogs::Batch.enqueue(
199
+ purpose: "chat_summary",
200
+ model: "gpt-4.1-mini",
201
+ instructions: "Summarize the conversation in two sentences.",
202
+ input: conversation_text,
203
+ schema: SummarySchema, # optional RubyLLM::Schema for structured output
204
+ routing: { conversation_id: 42 }, # your keys, echoed into the trace metadata
205
+ temperature: 0.2 # optional
206
+ )
207
+ ```
208
+
209
+ `routing` is arbitrary metadata you control. It rides along with the request and is copied onto the recorded trace, so you can trace a result back to your own records.
210
+
211
+ ### Handle Results
212
+
213
+ Register one handler per `purpose`. The gem owns the batch lifecycle; your app owns what happens with each result:
214
+
215
+ ```ruby
216
+ # config/initializers/llm_logs.rb
217
+ LlmLogs.register_batch_handler("chat_summary", ChatSummaryHandler.new)
218
+
219
+ class ChatSummaryHandler
220
+ # Called once a request succeeds. `message` is the RubyLLM::Message.
221
+ def call(request, message)
222
+ Conversation.find(request.routing["conversation_id"])
223
+ .update!(summary: message.content)
224
+ end
225
+
226
+ # Called when a request fails or its batch expires.
227
+ def on_failure(request, error)
228
+ Rails.logger.warn("[chat_summary] #{request.custom_id} failed: #{error}")
229
+ end
230
+ end
231
+ ```
232
+
233
+ A request is marked `succeeded` only after its handler completes; a handler that raises leaves the request `failed` with the error visible in the dashboard, so a result is never silently lost.
234
+
235
+ ### Submit and Reconcile
236
+
237
+ Two background jobs drive the lifecycle — schedule them on your own cadence (e.g. via cron, `solid_queue` recurring tasks, or `sidekiq-cron`):
238
+
239
+ ```ruby
240
+ # Group this purpose's pending requests into provider batches and submit them.
241
+ LlmLogs::Batch::FlushJob.perform_later("chat_summary")
242
+
243
+ # Reconcile every in-flight batch: fetch results, run handlers, recover stale claims.
244
+ LlmLogs::Batch::PollJob.perform_later
245
+ ```
246
+
247
+ `FlushJob` claims pending rows with `FOR UPDATE SKIP LOCKED`, so concurrent runs never double-submit. `PollJob` reconciles all unfinished batches and recovers requests stranded by an interrupted submission. Both are idempotent at the request level — already-resolved requests are skipped on re-run.
248
+
183
249
  ## Web UI
184
250
 
185
251
  Browse traces and manage prompts at `/llm_logs`.
@@ -188,6 +254,8 @@ Browse traces and manage prompts at `/llm_logs`.
188
254
 
189
255
  **Prompts** — CRUD with Mustache template editor, model configuration, and version history.
190
256
 
257
+ **Batches** — list batches with status and request counts, drill into per-request results, tokens, routing metadata, and linked traces.
258
+
191
259
  ## Configuration
192
260
 
193
261
  ```ruby
@@ -197,6 +265,8 @@ LlmLogs.setup do |config|
197
265
  config.retention_days = 30 # for future cleanup job
198
266
  config.prompts_source_path = Rails.root.join("db/data/prompts")
199
267
  config.prompt_subfolders = %w[skills fragments templates]
268
+ config.batch_enabled = true # enable the batch API integration
269
+ config.batch_provider = :openai_responses # batch backend
200
270
  end
201
271
  ```
202
272
 
@@ -0,0 +1,15 @@
1
+ module LlmLogs
2
+ class BatchesController < ApplicationController
3
+ def index
4
+ @batches = Batch.recent
5
+ @batches = @batches.where(purpose: params[:purpose]) if params[:purpose].present?
6
+ @batches = @batches.where(status: params[:status]) if params[:status].present?
7
+ @batches = @batches.page(params[:page]).per(50)
8
+ end
9
+
10
+ def show
11
+ @batch = Batch.find(params[:id])
12
+ @requests = @batch.requests.order(:created_at).page(params[:page]).per(100)
13
+ end
14
+ end
15
+ end
@@ -13,6 +13,7 @@ module LlmLogs
13
13
  def show
14
14
  @trace = Trace.includes(prompt_version: :prompt).find(params[:id])
15
15
  @root_spans = @trace.root_spans
16
+ @models = @trace.spans.where(span_type: "llm").distinct.pluck(:model).compact
16
17
  end
17
18
 
18
19
  end
@@ -0,0 +1,23 @@
1
+ module LlmLogs
2
+ module BatchesHelper
3
+ ROUTING_VALUE_LENGTH = 80
4
+
5
+ def routing_display_value(value)
6
+ full_value = routing_full_value(value)
7
+ return full_value if full_value.length <= ROUTING_VALUE_LENGTH
8
+
9
+ "#{full_value.first(ROUTING_VALUE_LENGTH - 3)}..."
10
+ end
11
+
12
+ def routing_full_value(value)
13
+ case value
14
+ when Hash, Array
15
+ JSON.generate(value)
16
+ when nil
17
+ "null"
18
+ else
19
+ value.to_s
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,12 @@
1
+ module LlmLogs
2
+ class Batch
3
+ class FlushJob < ::ActiveJob::Base
4
+ queue_as :default
5
+
6
+ def perform(purpose)
7
+ models = LlmLogs::BatchRequest.pending.where(purpose: purpose).distinct.pluck(:model)
8
+ models.each { |model| LlmLogs::Batch.submit_pending(purpose: purpose, model: model) }
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,35 @@
1
+ module LlmLogs
2
+ class Batch
3
+ class PollJob < ::ActiveJob::Base
4
+ queue_as :default
5
+
6
+ # A placeholder claim (status "pending", no openai_batch_id) is only meant to exist
7
+ # for the sub-second window of Submitter#submit. Anything older died mid-submit
8
+ # (e.g. the worker was killed), so its requests are stranded. Recover them.
9
+ STALE_CLAIM_AFTER = 15.minutes
10
+
11
+ def perform
12
+ recover_stale_claims
13
+ LlmLogs::Batch.unreconciled.where.not(openai_batch_id: nil).find_each do |batch|
14
+ batch.reconcile!
15
+ rescue StandardError => e
16
+ Rails.logger.error("[llm_logs] batch #{batch.id} reconcile failed: #{e.class}: #{e.message}")
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def recover_stale_claims
23
+ LlmLogs::Batch
24
+ .where(status: :pending, openai_batch_id: nil)
25
+ .where("created_at < ?", STALE_CLAIM_AFTER.ago)
26
+ .find_each do |batch|
27
+ batch.requests.update_all(batch_id: nil, status: :pending)
28
+ batch.destroy
29
+ rescue StandardError => e
30
+ Rails.logger.error("[llm_logs] batch #{batch.id} stale-claim recovery failed: #{e.class}: #{e.message}")
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,24 @@
1
+ module LlmLogs
2
+ class Batch
3
+ # Maps a batch purpose (e.g. "chat_summary") to a handler object. Handlers respond
4
+ # to `call(request, message)` for successful results and `on_failure(request, error)`
5
+ # for failed/expired requests. The gem owns the lifecycle; the host app owns handlers.
6
+ module HandlerRegistry
7
+ @handlers = {}
8
+
9
+ module_function
10
+
11
+ def register(purpose, handler)
12
+ @handlers[purpose.to_s] = handler
13
+ end
14
+
15
+ def resolve(purpose)
16
+ @handlers[purpose.to_s]
17
+ end
18
+
19
+ def clear!
20
+ @handlers = {}
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,96 @@
1
+ module LlmLogs
2
+ class Batch
3
+ # Resumes a submitted batch by id, and once terminal, records a trace per request,
4
+ # routes each result to its registered handler, and updates statuses. Idempotent at
5
+ # the request level (succeeded/failed/fell_back requests are skipped on re-run).
6
+ class Reconciler
7
+ def initialize(batch)
8
+ @batch = batch
9
+ end
10
+
11
+ def call
12
+ rubyllm_batch = RubyLLM.batch(id: @batch.openai_batch_id, provider: LlmLogs.batch_provider)
13
+ status = rubyllm_batch.status
14
+
15
+ case status
16
+ when "completed"
17
+ reconcile_completed(rubyllm_batch)
18
+ when "failed", "expired", "cancelled"
19
+ fail_all(status)
20
+ end
21
+ @batch
22
+ end
23
+
24
+ private
25
+
26
+ def reconcile_completed(rubyllm_batch)
27
+ results = rubyllm_batch.results
28
+ error_ids = rubyllm_batch.errors.filter_map { |e| e["custom_id"] }
29
+
30
+ @batch.update!(status: :completed, completed_at: Time.current)
31
+
32
+ @batch.requests.where.not(status: %i[succeeded failed fell_back]).find_each do |request|
33
+ message = results[request.custom_id]
34
+ if message
35
+ reconcile_success(request, message)
36
+ else
37
+ reconcile_failure(request, "no result for custom_id (in error file: #{error_ids.include?(request.custom_id)})")
38
+ end
39
+ end
40
+
41
+ @batch.update!(status: :reconciled, reconciled_at: Time.current)
42
+ end
43
+
44
+ def reconcile_success(request, message)
45
+ trace = TraceRecorder.record(request: request, message: message)
46
+ request.assign_attributes(
47
+ result_content: result_content_for(message.content),
48
+ input_tokens: message.input_tokens,
49
+ output_tokens: message.output_tokens,
50
+ cost: trace.total_cost,
51
+ trace_id: trace.id
52
+ )
53
+ handler = LlmLogs.batch_handler(request.purpose)
54
+ handler&.call(request, message)
55
+ request.succeeded!
56
+ rescue StandardError => e
57
+ # The LLM result was produced, but the handler (or persistence) failed. Mark the
58
+ # request failed-with-error rather than a misleading "succeeded" so the dropped
59
+ # result is visible in the dashboard instead of silently lost. The trace/tokens
60
+ # are still recorded (the spend happened); only delivery failed.
61
+ request.update!(status: :failed, error: "handler error: #{e.class}: #{e.message}")
62
+ end
63
+
64
+ # `result_content` is a text column. Structured (schema) results arrive as a Hash;
65
+ # store them as JSON so the snapshot stays machine-readable instead of Ruby inspect
66
+ # syntax ("key" => "value").
67
+ def result_content_for(content)
68
+ content.is_a?(Hash) || content.is_a?(Array) ? content.to_json : content.to_s
69
+ end
70
+
71
+ def reconcile_failure(request, error)
72
+ request.update!(status: :failed, error: error.to_s)
73
+ invoke_handler(request) { |handler| handler.on_failure(request, error) }
74
+ end
75
+
76
+ def fail_all(status)
77
+ # STATUSES does not include "cancelled"; treat a cancelled batch as failed so the
78
+ # batch record stays valid, while preserving the real status in the request error.
79
+ batch_status = status == "cancelled" ? :failed : status.to_sym
80
+ @batch.update!(status: batch_status, completed_at: Time.current)
81
+ @batch.requests.where.not(status: %i[succeeded failed fell_back]).find_each do |request|
82
+ reconcile_failure(request, "batch #{status}")
83
+ end
84
+ end
85
+
86
+ def invoke_handler(request)
87
+ handler = LlmLogs.batch_handler(request.purpose)
88
+ return unless handler
89
+
90
+ yield handler
91
+ rescue StandardError => e
92
+ request.update!(error: "handler error: #{e.class}: #{e.message}")
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,25 @@
1
+ module LlmLogs
2
+ class Batch
3
+ # Translates a {name:, schema:, strict:} schema (the shape RubyLLM::Chat#with_schema
4
+ # produces) into the OpenAI Responses API `text.format` block. The batch path builds
5
+ # request bodies directly via RubyLLM.batch#add(**extra), bypassing with_schema, so
6
+ # we must hand the json_schema block in ourselves.
7
+ module SchemaFormat
8
+ module_function
9
+
10
+ def call(schema)
11
+ return nil if schema.nil?
12
+
13
+ schema = schema.symbolize_keys if schema.respond_to?(:symbolize_keys)
14
+ {
15
+ format: {
16
+ type: "json_schema",
17
+ name: schema[:name] || "response",
18
+ schema: schema[:schema] || schema,
19
+ strict: schema.key?(:strict) ? schema[:strict] : true
20
+ }
21
+ }
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,76 @@
1
+ module LlmLogs
2
+ class Batch
3
+ # Groups pending BatchRequests of one purpose+model into a single OpenAI batch via
4
+ # ruby_llm-responses_api. To prevent two concurrent FlushJobs from double-submitting
5
+ # the same requests, it first CLAIMS the pending rows in a `FOR UPDATE SKIP LOCKED`
6
+ # transaction (assigning them to a placeholder Batch with no openai_batch_id, which
7
+ # flips them out of the `pending` scope and which PollJob ignores). It then submits to
8
+ # OpenAI and records the batch id. If submission fails, the claim is released (requests
9
+ # return to `pending`) and the placeholder batch is dropped, so the work retries next flush.
10
+ class Submitter
11
+ def initialize(purpose:, model:, metadata: {})
12
+ @purpose = purpose
13
+ @model = model
14
+ @metadata = metadata
15
+ end
16
+
17
+ def call
18
+ batch = claim_batch
19
+ return nil if batch.nil?
20
+
21
+ submit(batch)
22
+ batch
23
+ end
24
+
25
+ private
26
+
27
+ def claim_batch
28
+ BatchRequest.transaction do
29
+ requests = BatchRequest.pending
30
+ .where(purpose: @purpose, model: @model)
31
+ .lock("FOR UPDATE SKIP LOCKED")
32
+ .to_a
33
+ next nil if requests.empty?
34
+
35
+ batch = LlmLogs::Batch.create!(
36
+ purpose: @purpose,
37
+ provider: LlmLogs.batch_provider.to_s,
38
+ model: @model,
39
+ status: :pending,
40
+ request_count: requests.size,
41
+ metadata: @metadata
42
+ )
43
+ BatchRequest.where(id: requests.map(&:id)).update_all(batch_id: batch.id, status: :submitted)
44
+ batch
45
+ end
46
+ end
47
+
48
+ def submit(batch)
49
+ rubyllm_batch = RubyLLM.batch(model: @model, provider: LlmLogs.batch_provider)
50
+ batch.requests.each do |request|
51
+ payload = request.payload
52
+ rubyllm_batch.add(
53
+ payload["input"],
54
+ id: request.custom_id,
55
+ instructions: payload["instructions"],
56
+ temperature: payload["temperature"],
57
+ **schema_extra(payload["schema"])
58
+ )
59
+ end
60
+ rubyllm_batch.create!
61
+ batch.update!(openai_batch_id: rubyllm_batch.id, status: :submitted, submitted_at: Time.current)
62
+ rescue StandardError
63
+ # Release the claim so the requests retry on the next flush, and drop the
64
+ # placeholder batch so it isn't polled. Re-raise so the caller/job sees the error.
65
+ batch.requests.update_all(batch_id: nil, status: :pending)
66
+ batch.destroy
67
+ raise
68
+ end
69
+
70
+ def schema_extra(schema)
71
+ format = SchemaFormat.call(schema)
72
+ format ? { text: format } : {}
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,49 @@
1
+ module LlmLogs
2
+ class Batch
3
+ # Records a completed trace + llm span for a reconciled batch request, mirroring
4
+ # what the synchronous chat.complete auto-instrumentation captures (model, provider,
5
+ # tokens, cost). Cost applies the 50% Batch API discount.
6
+ module TraceRecorder
7
+ BATCH_COST_MULTIPLIER = 0.5
8
+
9
+ module_function
10
+
11
+ def record(request:, message:)
12
+ trace = nil
13
+ metadata = request.routing.merge("execution_mode" => "batch")
14
+ LlmLogs.trace(request.purpose, metadata: metadata) do |t|
15
+ trace = t
16
+ prompt_version_id = request.routing["prompt_version_id"]
17
+ t.update_column(:prompt_version_id, prompt_version_id) if prompt_version_id
18
+
19
+ span = LlmLogs::Tracer.start_span(
20
+ name: "batch.complete",
21
+ span_type: "llm",
22
+ model: message.model_id || request.model,
23
+ provider: LlmLogs.batch_provider.to_s,
24
+ input: request.payload["input"]
25
+ )
26
+ span.update!(
27
+ output: { "content" => span.serialize_content(message.content) },
28
+ input_tokens: message.input_tokens,
29
+ output_tokens: message.output_tokens,
30
+ cost: compute_cost(message)
31
+ )
32
+ span.finish
33
+ end
34
+ trace
35
+ end
36
+
37
+ def compute_cost(message)
38
+ model_info = RubyLLM.models.find(message.model_id)
39
+ return nil unless model_info&.input_price_per_million && model_info&.output_price_per_million
40
+
41
+ raw = (message.input_tokens.to_f * model_info.input_price_per_million +
42
+ message.output_tokens.to_f * model_info.output_price_per_million) / 1_000_000
43
+ (raw * BATCH_COST_MULTIPLIER).round(6)
44
+ rescue StandardError
45
+ nil
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,51 @@
1
+ module LlmLogs
2
+ class Batch < ApplicationRecord
3
+ self.table_name = "llm_logs_batches"
4
+
5
+ has_many :requests, class_name: "LlmLogs::BatchRequest", dependent: :destroy
6
+
7
+ enum :status, {
8
+ pending: "pending",
9
+ submitted: "submitted",
10
+ completed: "completed",
11
+ failed: "failed",
12
+ expired: "expired",
13
+ reconciled: "reconciled"
14
+ }, default: :pending
15
+
16
+ validates :purpose, :model, presence: true
17
+
18
+ scope :recent, -> { order(created_at: :desc) }
19
+ scope :unreconciled, -> { where.not(status: %i[reconciled failed expired]) }
20
+
21
+ def self.enqueue(purpose:, model:, input:, instructions:, schema:, routing:, temperature: nil)
22
+ BatchRequest.create!(
23
+ purpose: purpose,
24
+ model: model,
25
+ status: :pending,
26
+ custom_id: "req_#{SecureRandom.hex(8)}",
27
+ routing: routing,
28
+ payload: {
29
+ "input" => input,
30
+ "instructions" => instructions,
31
+ "schema" => schema,
32
+ "temperature" => temperature
33
+ }.compact
34
+ )
35
+ end
36
+
37
+ def self.submit_pending(purpose:, model:, metadata: {})
38
+ Submitter.new(purpose: purpose, model: model, metadata: metadata).call
39
+ end
40
+
41
+ def reconcile!
42
+ Reconciler.new(self).call
43
+ end
44
+
45
+ def self.batchable?(model)
46
+ return false unless LlmLogs.batch_enabled?
47
+
48
+ !defined?(RubyLLM::Providers::OpenAIResponses).nil?
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,18 @@
1
+ module LlmLogs
2
+ class BatchRequest < ApplicationRecord
3
+ self.table_name = "llm_logs_batch_requests"
4
+
5
+ belongs_to :batch, class_name: "LlmLogs::Batch", optional: true
6
+
7
+ enum :status, {
8
+ pending: "pending",
9
+ submitted: "submitted",
10
+ succeeded: "succeeded",
11
+ failed: "failed",
12
+ fell_back: "fell_back"
13
+ }, default: :pending
14
+
15
+ validates :custom_id, presence: true, uniqueness: true
16
+ validates :purpose, :model, presence: true
17
+ end
18
+ end
@@ -20,12 +20,20 @@ module LlmLogs
20
20
  end
21
21
 
22
22
  def record_response(message)
23
- self.output = { content: message.content.to_s }
23
+ self.output = { content: serialize_content(message.content) }
24
24
  self.input_tokens = message.input_tokens
25
25
  self.output_tokens = message.output_tokens
26
26
  self.cached_tokens = message.cached_tokens
27
27
  end
28
28
 
29
+ # Structured (schema) responses arrive as a Hash/Array; keep them as-is so the
30
+ # JSON `output` column stores real JSON and the UI renders nested fields. Calling
31
+ # `.to_s` here would serialize a Hash with Ruby inspect syntax ("key" => "value"),
32
+ # which is not valid JSON and shows up as an escaped blob in the dashboard.
33
+ def serialize_content(content)
34
+ content.is_a?(Hash) || content.is_a?(Array) ? content : content.to_s
35
+ end
36
+
29
37
  def record_error(exception)
30
38
  self.status = "error"
31
39
  self.error_message = "#{exception.class}: #{exception.message}"
@@ -114,6 +114,8 @@
114
114
  <div class="flex space-x-1">
115
115
  <%= link_to "Traces", llm_logs.traces_path,
116
116
  class: "px-3 py-2 rounded-md text-sm font-medium #{request.path.start_with?(llm_logs.traces_path) || request.path == llm_logs.root_path ? 'bg-gray-800 text-white' : 'text-gray-300 hover:bg-gray-700 hover:text-white'}" %>
117
+ <%= link_to "Batches", llm_logs.batches_path,
118
+ class: "px-3 py-2 rounded-md text-sm font-medium #{request.path.start_with?(llm_logs.batches_path) ? 'bg-gray-800 text-white' : 'text-gray-300 hover:bg-gray-700 hover:text-white'}" %>
117
119
  <%= link_to "Prompts", llm_logs.prompts_path,
118
120
  class: "px-3 py-2 rounded-md text-sm font-medium #{request.path.start_with?(llm_logs.prompts_path) ? 'bg-gray-800 text-white' : 'text-gray-300 hover:bg-gray-700 hover:text-white'}" %>
119
121
  </div>
@@ -0,0 +1,47 @@
1
+ <div class="flex items-center justify-between mb-6">
2
+ <h1 class="text-2xl font-bold text-gray-900">Batches</h1>
3
+ <%= form_tag batches_path, method: :get, class: "flex items-center space-x-2" do %>
4
+ <select name="status" class="rounded-md border-gray-300 text-sm py-1.5 px-3 bg-white border shadow-sm">
5
+ <option value="">All statuses</option>
6
+ <% LlmLogs::Batch.statuses.keys.each do |status| %>
7
+ <option value="<%= status %>" <%= 'selected' if params[:status] == status %>><%= status %></option>
8
+ <% end %>
9
+ </select>
10
+ <button type="submit" class="bg-gray-900 text-white px-3 py-1.5 rounded-md text-sm hover:bg-gray-700">Filter</button>
11
+ <% end %>
12
+ </div>
13
+
14
+ <div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-lg overflow-hidden">
15
+ <table class="min-w-full divide-y divide-gray-200">
16
+ <thead class="bg-gray-50">
17
+ <tr>
18
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Purpose</th>
19
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Model</th>
20
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">OpenAI Batch</th>
21
+ <th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Requests</th>
22
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
23
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Submitted</th>
24
+ </tr>
25
+ </thead>
26
+ <tbody class="divide-y divide-gray-200">
27
+ <% status_colors = { "pending" => "bg-gray-100 text-gray-800", "submitted" => "bg-yellow-100 text-yellow-800", "completed" => "bg-blue-100 text-blue-800", "reconciled" => "bg-green-100 text-green-800", "failed" => "bg-red-100 text-red-800", "expired" => "bg-red-100 text-red-800" } %>
28
+ <% @batches.each do |batch| %>
29
+ <tr class="hover:bg-gray-50">
30
+ <td class="px-4 py-3 text-sm"><%= link_to batch.purpose, batch_path(batch), class: "text-indigo-600 hover:text-indigo-900 font-medium" %></td>
31
+ <td class="px-4 py-3 text-sm text-gray-500"><%= batch.model %></td>
32
+ <td class="px-4 py-3 text-sm text-gray-500 font-mono"><%= batch.openai_batch_id %></td>
33
+ <td class="px-4 py-3 text-sm text-gray-500 text-right"><%= batch.request_count %></td>
34
+ <td class="px-4 py-3 text-sm">
35
+ <span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium <%= status_colors[batch.status] %>"><%= batch.status %></span>
36
+ </td>
37
+ <td class="px-4 py-3 text-sm text-gray-500"><%= batch.submitted_at&.strftime('%b %d %H:%M') %></td>
38
+ </tr>
39
+ <% end %>
40
+ <% if @batches.empty? %>
41
+ <tr><td colspan="6" class="px-4 py-8 text-center text-sm text-gray-500">No batches found.</td></tr>
42
+ <% end %>
43
+ </tbody>
44
+ </table>
45
+ </div>
46
+
47
+ <%= paginate @batches, theme: "tailwind" %>
@@ -0,0 +1,47 @@
1
+ <div class="mb-6">
2
+ <%= link_to "← Batches", batches_path, class: "text-sm text-indigo-600 hover:text-indigo-900" %>
3
+ <h1 class="text-2xl font-bold text-gray-900 mt-2"><%= @batch.purpose %> · <%= @batch.model %></h1>
4
+ <p class="text-sm text-gray-500 mt-1">
5
+ OpenAI: <span class="font-mono"><%= @batch.openai_batch_id %></span> · status <%= @batch.status %> ·
6
+ <%= @batch.request_count %> requests
7
+ </p>
8
+ </div>
9
+
10
+ <div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-lg overflow-hidden">
11
+ <table class="w-full table-fixed divide-y divide-gray-200">
12
+ <thead class="bg-gray-50">
13
+ <tr>
14
+ <th class="w-56 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase" data-column="custom-id">Custom ID</th>
15
+ <th class="w-28 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
16
+ <th class="w-28 px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Tokens</th>
17
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Metadata</th>
18
+ <th class="w-16 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Trace</th>
19
+ <th class="w-48 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Error</th>
20
+ </tr>
21
+ </thead>
22
+ <tbody class="divide-y divide-gray-200">
23
+ <% req_colors = { "pending" => "bg-gray-100 text-gray-800", "submitted" => "bg-yellow-100 text-yellow-800", "succeeded" => "bg-green-100 text-green-800", "failed" => "bg-red-100 text-red-800", "fell_back" => "bg-orange-100 text-orange-800" } %>
24
+ <% @requests.each do |request| %>
25
+ <tr class="hover:bg-gray-50 align-top">
26
+ <td class="px-4 py-3 text-sm font-mono text-gray-700 whitespace-nowrap"><%= request.custom_id %></td>
27
+ <td class="px-4 py-3 text-sm"><span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium <%= req_colors[request.status] %>"><%= request.status %></span></td>
28
+ <td class="px-4 py-3 text-sm text-gray-500 text-right"><%= request.input_tokens %> &rarr; <%= request.output_tokens %></td>
29
+ <td class="px-4 py-3 text-xs text-gray-500 min-w-0">
30
+ <dl class="grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-1 min-w-0" data-routing>
31
+ <% request.routing.each do |key, value| %>
32
+ <dt class="font-medium text-gray-700"><%= key %></dt>
33
+ <dd class="min-w-0 truncate" title="<%= routing_full_value(value) %>"><%= routing_display_value(value) %></dd>
34
+ <% end %>
35
+ </dl>
36
+ </td>
37
+ <td class="px-4 py-3 text-sm">
38
+ <%= link_to "trace", trace_path(request.trace_id), class: "text-indigo-600 hover:text-indigo-900" if request.trace_id %>
39
+ </td>
40
+ <td class="px-4 py-3 text-xs text-red-600"><%= request.error %></td>
41
+ </tr>
42
+ <% end %>
43
+ </tbody>
44
+ </table>
45
+ </div>
46
+
47
+ <%= paginate @requests, theme: "tailwind" %>
@@ -1,6 +1,7 @@
1
1
  <div class="mb-6">
2
+ <%= link_to "← Prompts", prompts_path, class: "text-sm text-indigo-600 hover:text-indigo-900" %>
2
3
  <div class="flex items-center justify-between">
3
- <div>
4
+ <div class="mt-2">
4
5
  <h1 class="text-2xl font-bold text-gray-900"><%= @prompt.name %></h1>
5
6
  <p class="text-sm text-gray-500 mt-1">
6
7
  <span class="font-mono"><%= @prompt.slug %></span>
@@ -1,6 +1,7 @@
1
1
  <div class="mb-6">
2
+ <%= link_to "← Traces", traces_path, class: "text-sm text-indigo-600 hover:text-indigo-900" %>
2
3
  <div class="flex items-center justify-between">
3
- <div>
4
+ <div class="mt-2">
4
5
  <div class="flex items-center space-x-3">
5
6
  <h1 class="text-2xl font-bold text-gray-900"><%= @trace.name %></h1>
6
7
  <% status_colors = { "running" => "bg-blue-100 text-blue-800", "completed" => "bg-green-100 text-green-800", "error" => "bg-red-100 text-red-800" } %>
@@ -19,11 +20,14 @@
19
20
  </p>
20
21
  <% end %>
21
22
  </div>
22
- <%= link_to "Back to traces", traces_path, class: "text-sm text-gray-600 hover:text-gray-900" %>
23
23
  </div>
24
24
  </div>
25
25
 
26
- <div class="grid grid-cols-5 gap-4 mb-6">
26
+ <div class="grid grid-cols-6 gap-4 mb-6">
27
+ <div class="bg-white rounded-lg p-4 shadow-sm ring-1 ring-gray-900/5">
28
+ <dt class="text-xs font-medium text-gray-500 uppercase">Model</dt>
29
+ <dd class="text-lg font-semibold text-gray-900 mt-1 break-words"><%= @models.join(", ").presence || "—" %></dd>
30
+ </div>
27
31
  <div class="bg-white rounded-lg p-4 shadow-sm ring-1 ring-gray-900/5">
28
32
  <dt class="text-xs font-medium text-gray-500 uppercase">Spans</dt>
29
33
  <dd class="text-2xl font-semibold text-gray-900 mt-1"><%= @trace.spans.count %></dd>
data/config/routes.rb CHANGED
@@ -5,6 +5,8 @@ LlmLogs::Engine.routes.draw do
5
5
  resources :spans, only: [:show]
6
6
  end
7
7
 
8
+ resources :batches, only: [:index, :show]
9
+
8
10
  resources :prompts do
9
11
  resources :versions, only: [:index, :show, :destroy], controller: "prompt_versions" do
10
12
  member do
@@ -0,0 +1,22 @@
1
+ class CreateLlmLogsBatches < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :llm_logs_batches do |t|
4
+ t.string :purpose, null: false
5
+ t.string :provider, null: false, default: "openai_responses"
6
+ t.string :model, null: false
7
+ t.string :openai_batch_id
8
+ t.string :openai_output_file_id
9
+ t.string :openai_error_file_id
10
+ t.string :status, null: false, default: "pending"
11
+ t.integer :request_count, null: false, default: 0
12
+ t.jsonb :metadata, null: false, default: {}
13
+ t.datetime :submitted_at
14
+ t.datetime :completed_at
15
+ t.datetime :reconciled_at
16
+ t.timestamps
17
+ end
18
+ add_index :llm_logs_batches, :openai_batch_id, unique: true
19
+ add_index :llm_logs_batches, :status
20
+ add_index :llm_logs_batches, :purpose
21
+ end
22
+ end
@@ -0,0 +1,22 @@
1
+ class CreateLlmLogsBatchRequests < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :llm_logs_batch_requests do |t|
4
+ t.references :batch, foreign_key: { to_table: :llm_logs_batches }
5
+ t.string :custom_id, null: false
6
+ t.string :purpose, null: false
7
+ t.string :status, null: false, default: "pending"
8
+ t.string :model, null: false
9
+ t.jsonb :payload, null: false, default: {}
10
+ t.jsonb :routing, null: false, default: {}
11
+ t.text :result_content
12
+ t.integer :input_tokens
13
+ t.integer :output_tokens
14
+ t.decimal :cost, precision: 10, scale: 6
15
+ t.bigint :trace_id
16
+ t.text :error
17
+ t.timestamps
18
+ end
19
+ add_index :llm_logs_batch_requests, :custom_id, unique: true
20
+ add_index :llm_logs_batch_requests, [:purpose, :status]
21
+ end
22
+ end
@@ -1,6 +1,7 @@
1
1
  module LlmLogs
2
2
  class Configuration
3
- attr_accessor :enabled, :auto_instrument, :retention_days, :prompts_source_path, :prompt_subfolders
3
+ attr_accessor :enabled, :auto_instrument, :retention_days, :prompts_source_path, :prompt_subfolders,
4
+ :batch_enabled, :batch_provider
4
5
 
5
6
  def initialize
6
7
  @enabled = true
@@ -8,6 +9,8 @@ module LlmLogs
8
9
  @retention_days = 30
9
10
  @prompts_source_path = nil
10
11
  @prompt_subfolders = %w[skills fragments templates]
12
+ @batch_enabled = true
13
+ @batch_provider = :openai_responses
11
14
  end
12
15
  end
13
16
 
@@ -1,3 +1,3 @@
1
1
  module LlmLogs
2
- VERSION = "0.1.6"
2
+ VERSION = "0.2.2"
3
3
  end
data/lib/llm_logs.rb CHANGED
@@ -38,6 +38,22 @@ module LlmLogs
38
38
  configuration.retention_days = retention_days
39
39
  end
40
40
 
41
+ def self.batch_enabled?
42
+ configuration.batch_enabled
43
+ end
44
+
45
+ def self.batch_provider
46
+ configuration.batch_provider
47
+ end
48
+
49
+ def self.register_batch_handler(purpose, handler)
50
+ LlmLogs::Batch::HandlerRegistry.register(purpose, handler)
51
+ end
52
+
53
+ def self.batch_handler(purpose)
54
+ LlmLogs::Batch::HandlerRegistry.resolve(purpose)
55
+ end
56
+
41
57
  def self.trace(name, **options, &block)
42
58
  LlmLogs::Tracer.start_trace(name, **options, &block)
43
59
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llm_logs
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.6
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Anton
@@ -93,6 +93,48 @@ dependencies:
93
93
  - - "~>"
94
94
  - !ruby/object:Gem::Version
95
95
  version: '1.1'
96
+ - !ruby/object:Gem::Dependency
97
+ name: ruby_llm
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '1.16'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '1.16'
110
+ - !ruby/object:Gem::Dependency
111
+ name: ruby_llm-responses_api
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '0.6'
117
+ type: :development
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '0.6'
124
+ - !ruby/object:Gem::Dependency
125
+ name: webmock
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - "~>"
129
+ - !ruby/object:Gem::Version
130
+ version: '3.0'
131
+ type: :development
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - "~>"
136
+ - !ruby/object:Gem::Version
137
+ version: '3.0'
96
138
  description: Mountable Rails engine that provides hierarchical LLM call tracing and
97
139
  versioned prompt management with Mustache templates.
98
140
  executables: []
@@ -103,13 +145,24 @@ files:
103
145
  - README.md
104
146
  - Rakefile
105
147
  - app/controllers/llm_logs/application_controller.rb
148
+ - app/controllers/llm_logs/batches_controller.rb
106
149
  - app/controllers/llm_logs/prompt_versions_controller.rb
107
150
  - app/controllers/llm_logs/prompts_controller.rb
108
151
  - app/controllers/llm_logs/spans_controller.rb
109
152
  - app/controllers/llm_logs/traces_controller.rb
153
+ - app/helpers/llm_logs/batches_helper.rb
110
154
  - app/helpers/llm_logs/formatting_helper.rb
111
155
  - app/helpers/llm_logs/prompts_helper.rb
156
+ - app/jobs/llm_logs/batch/flush_job.rb
157
+ - app/jobs/llm_logs/batch/poll_job.rb
112
158
  - app/models/llm_logs/application_record.rb
159
+ - app/models/llm_logs/batch.rb
160
+ - app/models/llm_logs/batch/handler_registry.rb
161
+ - app/models/llm_logs/batch/reconciler.rb
162
+ - app/models/llm_logs/batch/schema_format.rb
163
+ - app/models/llm_logs/batch/submitter.rb
164
+ - app/models/llm_logs/batch/trace_recorder.rb
165
+ - app/models/llm_logs/batch_request.rb
113
166
  - app/models/llm_logs/prompt.rb
114
167
  - app/models/llm_logs/prompt_version.rb
115
168
  - app/models/llm_logs/span.rb
@@ -123,6 +176,8 @@ files:
123
176
  - app/views/kaminari/tailwind/_paginator.html.erb
124
177
  - app/views/kaminari/tailwind/_prev_page.html.erb
125
178
  - app/views/layouts/llm_logs/application.html.erb
179
+ - app/views/llm_logs/batches/index.html.erb
180
+ - app/views/llm_logs/batches/show.html.erb
126
181
  - app/views/llm_logs/prompt_versions/compare.html.erb
127
182
  - app/views/llm_logs/prompt_versions/index.html.erb
128
183
  - app/views/llm_logs/prompt_versions/show.html.erb
@@ -142,6 +197,8 @@ files:
142
197
  - db/migrate/004_create_llm_logs_prompt_versions.rb
143
198
  - db/migrate/005_add_prompt_version_to_traces.rb
144
199
  - db/migrate/006_add_tags_to_prompts.rb
200
+ - db/migrate/007_create_llm_logs_batches.rb
201
+ - db/migrate/008_create_llm_logs_batch_requests.rb
145
202
  - lib/generators/llm_logs/install_generator.rb
146
203
  - lib/generators/llm_logs/templates/initializer.rb
147
204
  - lib/llm_logs.rb