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 +4 -4
- data/README.md +70 -0
- data/app/controllers/llm_logs/batches_controller.rb +15 -0
- data/app/controllers/llm_logs/traces_controller.rb +1 -0
- data/app/helpers/llm_logs/batches_helper.rb +23 -0
- data/app/jobs/llm_logs/batch/flush_job.rb +12 -0
- data/app/jobs/llm_logs/batch/poll_job.rb +35 -0
- data/app/models/llm_logs/batch/handler_registry.rb +24 -0
- data/app/models/llm_logs/batch/reconciler.rb +96 -0
- data/app/models/llm_logs/batch/schema_format.rb +25 -0
- data/app/models/llm_logs/batch/submitter.rb +76 -0
- data/app/models/llm_logs/batch/trace_recorder.rb +49 -0
- data/app/models/llm_logs/batch.rb +51 -0
- data/app/models/llm_logs/batch_request.rb +18 -0
- data/app/models/llm_logs/span.rb +9 -1
- data/app/views/layouts/llm_logs/application.html.erb +2 -0
- data/app/views/llm_logs/batches/index.html.erb +47 -0
- data/app/views/llm_logs/batches/show.html.erb +47 -0
- data/app/views/llm_logs/prompts/show.html.erb +2 -1
- data/app/views/llm_logs/traces/show.html.erb +7 -3
- data/config/routes.rb +2 -0
- data/db/migrate/007_create_llm_logs_batches.rb +22 -0
- data/db/migrate/008_create_llm_logs_batch_requests.rb +22 -0
- data/lib/llm_logs/configuration.rb +4 -1
- data/lib/llm_logs/version.rb +1 -1
- data/lib/llm_logs.rb +16 -0
- metadata +58 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5eb2c037f1c19d83e843722bd9ca664324311e7fb6b75de47a96569919cae05b
|
|
4
|
+
data.tar.gz: 0db8f27945ce97dd74f9e4fedb5c337b68af537a83ab17dfc3f6bcd9ad18d360
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
@@ -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
|
data/app/models/llm_logs/span.rb
CHANGED
|
@@ -20,12 +20,20 @@ module LlmLogs
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def record_response(message)
|
|
23
|
-
self.output = { content: message.content
|
|
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 %> → <%= 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-
|
|
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
|
@@ -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
|
|
data/lib/llm_logs/version.rb
CHANGED
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.
|
|
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
|