llm_logs 0.2.6 → 0.3.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.
- checksums.yaml +4 -4
- data/README.md +47 -3
- data/app/jobs/llm_logs/batch/poll_job.rb +2 -2
- data/app/models/llm_logs/batch/adapters/bedrock.rb +137 -0
- data/app/models/llm_logs/batch/adapters/openai_responses.rb +54 -0
- data/app/models/llm_logs/batch/reconciler.rb +7 -8
- data/app/models/llm_logs/batch/submitter.rb +9 -19
- data/app/models/llm_logs/batch/trace_recorder.rb +2 -2
- data/app/models/llm_logs/batch.rb +35 -0
- data/app/views/llm_logs/batches/index.html.erb +3 -1
- data/app/views/llm_logs/batches/show.html.erb +10 -0
- data/db/migrate/009_add_provider_columns_to_llm_logs_batches.rb +17 -0
- data/lib/llm_logs/configuration.rb +4 -1
- data/lib/llm_logs/version.rb +1 -1
- data/lib/llm_logs.rb +12 -0
- metadata +32 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 042ed62e3a524de1a05ab6a8ce4184abe0634257c49d27da7f91e470d9810a66
|
|
4
|
+
data.tar.gz: 48b40cad0e67865812a1eca95cd6d2968309883ec66c55d8473ef274ec82b56c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 74ff56b932ebe8b4f4fd4f810dfdb2f36be1b2011af5a0dc7fb1d0e43f1866a2e2bdb2ef450e2389d0a9e1660c3a93d6a9d6d98eb5f56ba20cde8bdde75793c6
|
|
7
|
+
data.tar.gz: 584ef268fb5947dcae78f95deb5a379337cbe6db5cede8b1eb6b4b2d89580a93d3cd5aa5b913c39e7fbd2a128881cf21c66fe7523d82137f27dc5215ca99eca7
|
data/README.md
CHANGED
|
@@ -182,9 +182,14 @@ Running the task creates missing prompts, updates metadata, and creates a new pr
|
|
|
182
182
|
|
|
183
183
|
## Batches
|
|
184
184
|
|
|
185
|
-
Send requests through
|
|
185
|
+
Send latency-insensitive requests through a provider's Batch API for roughly half the cost. 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
186
|
|
|
187
|
-
|
|
187
|
+
Two batch backends are supported, selected **per model**:
|
|
188
|
+
|
|
189
|
+
- **[OpenAI Responses Batch API](https://platform.openai.com/docs/guides/batch)** via [`ruby_llm-responses_api`](https://rubygems.org/gems/ruby_llm-responses_api) — the default for OpenAI models.
|
|
190
|
+
- **[AWS Bedrock Batch API](#aws-bedrock-batches)** (`CreateModelInvocationJob`) for Anthropic Claude models.
|
|
191
|
+
|
|
192
|
+
Add the OpenAI provider to your app's Gemfile:
|
|
188
193
|
|
|
189
194
|
```ruby
|
|
190
195
|
gem "ruby_llm-responses_api"
|
|
@@ -246,6 +251,45 @@ LlmLogs::Batch::PollJob.perform_later
|
|
|
246
251
|
|
|
247
252
|
`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
253
|
|
|
254
|
+
### AWS Bedrock batches
|
|
255
|
+
|
|
256
|
+
Anthropic Claude models can batch through the AWS Bedrock Batch API. Bedrock batching is file-based: LlmLogs writes a JSONL manifest to S3, starts a `CreateModelInvocationJob`, and reads the results back from S3. The enqueue/handler/flush/reconcile flow above is identical — only the backend differs.
|
|
257
|
+
|
|
258
|
+
Add the AWS SDKs to your app's Gemfile:
|
|
259
|
+
|
|
260
|
+
```ruby
|
|
261
|
+
gem "aws-sdk-bedrock"
|
|
262
|
+
gem "aws-sdk-s3"
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Configure the Bedrock backend and register its adapter, pointing it at an S3 bucket and an IAM role Bedrock can assume:
|
|
266
|
+
|
|
267
|
+
```ruby
|
|
268
|
+
# config/initializers/llm_logs.rb
|
|
269
|
+
LlmLogs.configuration.bedrock_batch = LlmLogs::Configuration::BedrockBatch.new(
|
|
270
|
+
role_arn: "arn:aws:iam::<account>:role/<bedrock-batch-role>", # role Bedrock assumes to read/write S3
|
|
271
|
+
s3_bucket: "my-bedrock-batch-bucket", # must be in the model's region
|
|
272
|
+
s3_prefix: "llm-batch",
|
|
273
|
+
min_records: 100, # Bedrock's minimum records per job
|
|
274
|
+
model_matcher: /\Aanthropic\./, # model ids that route to Bedrock
|
|
275
|
+
region: "us-east-1"
|
|
276
|
+
)
|
|
277
|
+
LlmLogs.register_batch_adapter(:bedrock, LlmLogs::Batch::Adapters::Bedrock.new)
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Provider selection is per model: `LlmLogs::Batch.batch_provider_for(model)` returns `:bedrock` when the adapter is registered and `model_matcher` matches, otherwise the OpenAI backend when the model resolves there, otherwise `nil` (not batchable — run it synchronously). Bedrock enforces a **minimum records per job**, so check `LlmLogs::Batch.min_records_for(model)` and fall back to a synchronous call when a batch would be under the floor.
|
|
281
|
+
|
|
282
|
+
The adapter builds its AWS clients from the ambient credential chain by default; inject your own to authenticate explicitly:
|
|
283
|
+
|
|
284
|
+
```ruby
|
|
285
|
+
LlmLogs::Batch::Adapters::Bedrock.new(
|
|
286
|
+
s3: Aws::S3::Client.new(region: "us-east-1", credentials: creds),
|
|
287
|
+
bedrock: Aws::Bedrock::Client.new(region: "us-east-1", credentials: creds)
|
|
288
|
+
)
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
**Prerequisites:** an S3 bucket in the model's region, and an IAM service role trusting `bedrock.amazonaws.com` with `s3:GetObject`/`s3:PutObject` on the bucket prefix. The principal that calls the API needs `bedrock:CreateModelInvocationJob`, `bedrock:GetModelInvocationJob`, and `iam:PassRole` on that role.
|
|
292
|
+
|
|
249
293
|
## Web UI
|
|
250
294
|
|
|
251
295
|
Browse traces and manage prompts at `/llm_logs`.
|
|
@@ -266,7 +310,7 @@ LlmLogs.setup do |config|
|
|
|
266
310
|
config.prompts_source_path = Rails.root.join("db/data/prompts")
|
|
267
311
|
config.prompt_subfolders = %w[skills fragments templates]
|
|
268
312
|
config.batch_enabled = true # enable the batch API integration
|
|
269
|
-
config.batch_provider = :openai_responses #
|
|
313
|
+
config.batch_provider = :openai_responses # default (OpenAI) backend; Bedrock is registered separately (see Batches)
|
|
270
314
|
config.page_size = 50 # rows per page on all index pages
|
|
271
315
|
end
|
|
272
316
|
```
|
|
@@ -10,7 +10,7 @@ module LlmLogs
|
|
|
10
10
|
|
|
11
11
|
def perform
|
|
12
12
|
recover_stale_claims
|
|
13
|
-
LlmLogs::Batch.unreconciled.where.not(
|
|
13
|
+
LlmLogs::Batch.unreconciled.where.not(provider_batch_id: nil).find_each do |batch|
|
|
14
14
|
batch.reconcile!
|
|
15
15
|
rescue StandardError => e
|
|
16
16
|
Rails.logger.error("[llm_logs] batch #{batch.id} reconcile failed: #{e.class}: #{e.message}")
|
|
@@ -21,7 +21,7 @@ module LlmLogs
|
|
|
21
21
|
|
|
22
22
|
def recover_stale_claims
|
|
23
23
|
LlmLogs::Batch
|
|
24
|
-
.where(status: :pending,
|
|
24
|
+
.where(status: :pending, provider_batch_id: nil)
|
|
25
25
|
.where("created_at < ?", STALE_CLAIM_AFTER.ago)
|
|
26
26
|
.find_each do |batch|
|
|
27
27
|
batch.requests.update_all(batch_id: nil, status: :pending)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
|
|
4
|
+
module LlmLogs
|
|
5
|
+
class Batch
|
|
6
|
+
module Adapters
|
|
7
|
+
# AWS Bedrock Batch API (CreateModelInvocationJob). Writes a JSONL manifest to S3,
|
|
8
|
+
# starts an async job, polls it, and reads the output JSONL back from S3. Unlike the
|
|
9
|
+
# OpenAI adapter, submission/output is file-based (S3) rather than an upload endpoint.
|
|
10
|
+
class Bedrock
|
|
11
|
+
ANTHROPIC_VERSION = "bedrock-2023-05-31"
|
|
12
|
+
DEFAULT_MAX_TOKENS = 1024
|
|
13
|
+
|
|
14
|
+
Result = Struct.new(:content, :input_tokens, :output_tokens, :model_id, keyword_init: true)
|
|
15
|
+
|
|
16
|
+
def initialize(config: LlmLogs.bedrock_batch, s3: nil, bedrock: nil)
|
|
17
|
+
@config = config
|
|
18
|
+
@s3 = s3
|
|
19
|
+
@bedrock = bedrock
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def submit(batch, requests)
|
|
23
|
+
key = "#{@config.s3_prefix}/#{batch.id}/input.jsonl"
|
|
24
|
+
manifest = requests.map { |r| JSON.generate(record_for(r)) }.join("\n") + "\n"
|
|
25
|
+
s3.put_object(bucket: @config.s3_bucket, key: key, body: manifest)
|
|
26
|
+
|
|
27
|
+
input_uri = "s3://#{@config.s3_bucket}/#{key}"
|
|
28
|
+
output_uri = "s3://#{@config.s3_bucket}/#{@config.s3_prefix}/#{batch.id}/out/"
|
|
29
|
+
job = bedrock.create_model_invocation_job(
|
|
30
|
+
job_name: job_name_for(batch),
|
|
31
|
+
role_arn: @config.role_arn,
|
|
32
|
+
model_id: batch.model,
|
|
33
|
+
input_data_config: {s3_input_data_config: {s3_uri: input_uri}},
|
|
34
|
+
output_data_config: {s3_output_data_config: {s3_uri: output_uri}}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
{
|
|
38
|
+
provider_batch_id: job.job_arn,
|
|
39
|
+
openai_batch_id: nil,
|
|
40
|
+
provider_metadata: {
|
|
41
|
+
"s3_input_uri" => input_uri,
|
|
42
|
+
"s3_output_uri" => output_uri,
|
|
43
|
+
"job_id" => job.job_arn.split("/").last,
|
|
44
|
+
"input_basename" => "input.jsonl"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def terminal_status(batch)
|
|
50
|
+
job = bedrock.get_model_invocation_job(job_identifier: batch.provider_batch_id)
|
|
51
|
+
case job.status
|
|
52
|
+
when "Completed" then "completed"
|
|
53
|
+
when "Failed", "Stopped", "PartiallyCompleted" then "failed"
|
|
54
|
+
when "Expired" then "expired"
|
|
55
|
+
else "in_progress"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def results(batch)
|
|
60
|
+
parsed_output(batch).each_with_object({}) do |line, acc|
|
|
61
|
+
next if line["recordId"].nil? || line["error"]
|
|
62
|
+
|
|
63
|
+
output = line["modelOutput"] || {}
|
|
64
|
+
usage = output["usage"] || {}
|
|
65
|
+
acc[line["recordId"]] = Result.new(
|
|
66
|
+
content: extract_content(output),
|
|
67
|
+
input_tokens: usage["input_tokens"],
|
|
68
|
+
output_tokens: usage["output_tokens"],
|
|
69
|
+
model_id: output["model"] || batch.model
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def error_ids(batch)
|
|
75
|
+
parsed_output(batch).filter_map { |line| line["recordId"] if line["error"] }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
# Bedrock jobName allows only [A-Za-z0-9] plus '-', '+', '.'. Purposes such as
|
|
81
|
+
# "eval_judge" contain underscores, so map any disallowed character to a hyphen.
|
|
82
|
+
def job_name_for(batch)
|
|
83
|
+
"llmlogs-#{batch.purpose}-#{batch.id}-#{SecureRandom.hex(4)}".gsub(/[^a-zA-Z0-9+.-]/, "-")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# One JSONL record: {recordId, modelInput: <native Anthropic Messages body>}.
|
|
87
|
+
def record_for(request)
|
|
88
|
+
payload = request.payload
|
|
89
|
+
{recordId: request.custom_id, modelInput: model_input(payload)}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def model_input(payload)
|
|
93
|
+
body = {
|
|
94
|
+
"anthropic_version" => ANTHROPIC_VERSION,
|
|
95
|
+
"max_tokens" => DEFAULT_MAX_TOKENS,
|
|
96
|
+
"messages" => [{"role" => "user", "content" => payload["input"]}]
|
|
97
|
+
}
|
|
98
|
+
body["system"] = payload["instructions"] if payload["instructions"]
|
|
99
|
+
body.merge!(structured_output(payload["schema"])) if payload["schema"]
|
|
100
|
+
body
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Anthropic structured output via a single forced tool (verify encoding against QA).
|
|
104
|
+
def structured_output(schema)
|
|
105
|
+
spec = schema["schema"] || schema
|
|
106
|
+
{
|
|
107
|
+
"tools" => [{"name" => schema["name"] || "response", "description" => "Return the structured response.", "input_schema" => spec}],
|
|
108
|
+
"tool_choice" => {"type" => "tool", "name" => schema["name"] || "response"}
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def extract_content(output)
|
|
113
|
+
blocks = output["content"] || []
|
|
114
|
+
tool = blocks.find { |b| b["type"] == "tool_use" }
|
|
115
|
+
return JSON.generate(tool["input"]) if tool
|
|
116
|
+
|
|
117
|
+
blocks.filter_map { |b| b["text"] if b["type"] == "text" }.join
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def parsed_output(batch)
|
|
121
|
+
meta = batch.provider_metadata
|
|
122
|
+
key = "#{@config.s3_prefix}/#{batch.id}/out/#{meta["job_id"]}/#{meta["input_basename"]}.out"
|
|
123
|
+
body = s3.get_object(bucket: @config.s3_bucket, key: key).body.read
|
|
124
|
+
body.each_line.filter_map { |l| JSON.parse(l) unless l.strip.empty? }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def s3
|
|
128
|
+
@s3 ||= (require "aws-sdk-s3"; Aws::S3::Client.new(region: @config.region))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def bedrock
|
|
132
|
+
@bedrock ||= (require "aws-sdk-bedrock"; Aws::Bedrock::Client.new(region: @config.region))
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module LlmLogs
|
|
2
|
+
class Batch
|
|
3
|
+
module Adapters
|
|
4
|
+
# Wraps ruby_llm-responses_api's OpenAI Batch API. Contains the remote-API logic
|
|
5
|
+
# previously inline in Submitter/Reconciler; the DB/claim lifecycle stays in those.
|
|
6
|
+
class OpenaiResponses
|
|
7
|
+
PROVIDER = :openai_responses
|
|
8
|
+
|
|
9
|
+
def submit(_batch, requests)
|
|
10
|
+
rubyllm_batch = RubyLLM.batch(model: requests.first.model, provider: PROVIDER)
|
|
11
|
+
requests.each do |request|
|
|
12
|
+
payload = request.payload
|
|
13
|
+
rubyllm_batch.add(
|
|
14
|
+
payload["input"],
|
|
15
|
+
id: request.custom_id,
|
|
16
|
+
instructions: payload["instructions"],
|
|
17
|
+
temperature: payload["temperature"],
|
|
18
|
+
**schema_extra(payload["schema"])
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
rubyllm_batch.create!
|
|
22
|
+
{provider_batch_id: rubyllm_batch.id, openai_batch_id: rubyllm_batch.id, provider_metadata: {}}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def terminal_status(batch)
|
|
26
|
+
resume(batch).status
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def results(batch)
|
|
30
|
+
resume(batch).results
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def error_ids(batch)
|
|
34
|
+
resume(batch).errors.filter_map { |e| e["custom_id"] }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# No memoization: LlmLogs.batch_adapters holds one shared instance of this adapter
|
|
40
|
+
# for the life of the process, so caching the resumed handle here would go stale
|
|
41
|
+
# for a long-running PollJob worker (in-progress batches would never re-fetch a
|
|
42
|
+
# later terminal status) and would grow unbounded. Build a fresh handle every call.
|
|
43
|
+
def resume(batch)
|
|
44
|
+
RubyLLM.batch(id: batch.provider_batch_id, provider: PROVIDER)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def schema_extra(schema)
|
|
48
|
+
format = SchemaFormat.call(schema)
|
|
49
|
+
format ? {text: format} : {}
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -9,12 +9,11 @@ module LlmLogs
|
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def call
|
|
12
|
-
|
|
13
|
-
status =
|
|
14
|
-
|
|
12
|
+
adapter = LlmLogs::Batch.adapter_for(@batch.provider)
|
|
13
|
+
status = adapter.terminal_status(@batch)
|
|
15
14
|
case status
|
|
16
15
|
when "completed"
|
|
17
|
-
reconcile_completed(
|
|
16
|
+
reconcile_completed(adapter)
|
|
18
17
|
when "failed", "expired", "cancelled"
|
|
19
18
|
fail_all(status)
|
|
20
19
|
end
|
|
@@ -23,9 +22,9 @@ module LlmLogs
|
|
|
23
22
|
|
|
24
23
|
private
|
|
25
24
|
|
|
26
|
-
def reconcile_completed(
|
|
27
|
-
results =
|
|
28
|
-
error_ids =
|
|
25
|
+
def reconcile_completed(adapter)
|
|
26
|
+
results = adapter.results(@batch)
|
|
27
|
+
error_ids = adapter.error_ids(@batch)
|
|
29
28
|
|
|
30
29
|
@batch.update!(status: :completed, completed_at: Time.current)
|
|
31
30
|
|
|
@@ -42,7 +41,7 @@ module LlmLogs
|
|
|
42
41
|
end
|
|
43
42
|
|
|
44
43
|
def reconcile_success(request, message)
|
|
45
|
-
trace = TraceRecorder.record(request: request, message: message)
|
|
44
|
+
trace = TraceRecorder.record(request: request, message: message, provider: @batch.provider)
|
|
46
45
|
request.assign_attributes(
|
|
47
46
|
result_content: result_content_for(message.content),
|
|
48
47
|
input_tokens: message.input_tokens,
|
|
@@ -34,7 +34,7 @@ module LlmLogs
|
|
|
34
34
|
|
|
35
35
|
batch = LlmLogs::Batch.create!(
|
|
36
36
|
purpose: @purpose,
|
|
37
|
-
provider: LlmLogs.
|
|
37
|
+
provider: LlmLogs::Batch.batch_provider_for(@model).to_s,
|
|
38
38
|
model: @model,
|
|
39
39
|
status: :pending,
|
|
40
40
|
request_count: requests.size,
|
|
@@ -46,19 +46,14 @@ module LlmLogs
|
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
def submit(batch)
|
|
49
|
-
|
|
50
|
-
batch.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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)
|
|
49
|
+
result = LlmLogs::Batch.adapter_for(batch.provider).submit(batch, batch.requests.to_a)
|
|
50
|
+
batch.update!(
|
|
51
|
+
provider_batch_id: result[:provider_batch_id],
|
|
52
|
+
openai_batch_id: result[:openai_batch_id],
|
|
53
|
+
provider_metadata: result[:provider_metadata] || {},
|
|
54
|
+
status: :submitted,
|
|
55
|
+
submitted_at: Time.current
|
|
56
|
+
)
|
|
62
57
|
rescue StandardError
|
|
63
58
|
# Release the claim so the requests retry on the next flush, and drop the
|
|
64
59
|
# placeholder batch so it isn't polled. Re-raise so the caller/job sees the error.
|
|
@@ -66,11 +61,6 @@ module LlmLogs
|
|
|
66
61
|
batch.destroy
|
|
67
62
|
raise
|
|
68
63
|
end
|
|
69
|
-
|
|
70
|
-
def schema_extra(schema)
|
|
71
|
-
format = SchemaFormat.call(schema)
|
|
72
|
-
format ? { text: format } : {}
|
|
73
|
-
end
|
|
74
64
|
end
|
|
75
65
|
end
|
|
76
66
|
end
|
|
@@ -8,7 +8,7 @@ module LlmLogs
|
|
|
8
8
|
|
|
9
9
|
module_function
|
|
10
10
|
|
|
11
|
-
def record(request:, message:)
|
|
11
|
+
def record(request:, message:, provider:)
|
|
12
12
|
trace = nil
|
|
13
13
|
metadata = request.routing.merge("execution_mode" => "batch")
|
|
14
14
|
LlmLogs.trace(request.purpose, metadata: metadata) do |t|
|
|
@@ -20,7 +20,7 @@ module LlmLogs
|
|
|
20
20
|
name: "batch.complete",
|
|
21
21
|
span_type: "llm",
|
|
22
22
|
model: message.model_id || request.model,
|
|
23
|
-
provider:
|
|
23
|
+
provider: provider.to_s,
|
|
24
24
|
input: request.payload["input"]
|
|
25
25
|
)
|
|
26
26
|
span.update!(
|
|
@@ -42,8 +42,43 @@ module LlmLogs
|
|
|
42
42
|
Reconciler.new(self).call
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
def self.adapter_for(provider)
|
|
46
|
+
LlmLogs.batch_adapters.fetch(provider.to_sym) do
|
|
47
|
+
raise ArgumentError, "no batch adapter registered for provider #{provider.inspect}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
45
51
|
def self.batchable?(model)
|
|
46
52
|
return false unless LlmLogs.batch_enabled?
|
|
53
|
+
|
|
54
|
+
!batch_provider_for(model).nil?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Which batch provider (if any) serves this model. Bedrock wins for Claude models when
|
|
58
|
+
# the Bedrock adapter is configured; otherwise fall back to the OpenAI provider when the
|
|
59
|
+
# model resolves there; otherwise nil (run synchronously).
|
|
60
|
+
def self.batch_provider_for(model)
|
|
61
|
+
return :bedrock if bedrock_serves?(model)
|
|
62
|
+
return :openai_responses if openai_serves?(model)
|
|
63
|
+
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The Bedrock minimum records-per-job floor for this model (0 when Bedrock does not serve it).
|
|
68
|
+
def self.min_records_for(model)
|
|
69
|
+
batch_provider_for(model) == :bedrock ? LlmLogs.bedrock_batch.min_records.to_i : 0
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.bedrock_serves?(model)
|
|
73
|
+
config = LlmLogs.bedrock_batch
|
|
74
|
+
return false if config.nil?
|
|
75
|
+
return false unless LlmLogs.batch_adapters.key?(:bedrock)
|
|
76
|
+
|
|
77
|
+
matcher = config.model_matcher
|
|
78
|
+
matcher.respond_to?(:call) ? matcher.call(model.to_s) : matcher.match?(model.to_s)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.openai_serves?(model)
|
|
47
82
|
return false unless defined?(RubyLLM::Providers::OpenAIResponses)
|
|
48
83
|
|
|
49
84
|
servable_by_batch_provider?(model)
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
|
|
19
19
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Purpose</th>
|
|
20
20
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Model</th>
|
|
21
|
+
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Provider</th>
|
|
21
22
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">OpenAI Batch</th>
|
|
22
23
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Requests</th>
|
|
23
24
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
|
@@ -31,6 +32,7 @@
|
|
|
31
32
|
<td class="px-4 py-3 text-sm"><%= link_to "##{batch.id}", batch_path(batch), class: "text-indigo-600 hover:text-indigo-900 font-mono" %></td>
|
|
32
33
|
<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>
|
|
33
34
|
<td class="px-4 py-3 text-sm text-gray-500"><%= batch.model %></td>
|
|
35
|
+
<td class="px-4 py-3 text-sm text-gray-500"><%= batch.provider %></td>
|
|
34
36
|
<td class="px-4 py-3 text-sm text-gray-500 font-mono"><%= batch.openai_batch_id %></td>
|
|
35
37
|
<td class="px-4 py-3 text-sm text-gray-500 text-right"><%= batch.request_count %></td>
|
|
36
38
|
<td class="px-4 py-3 text-sm">
|
|
@@ -40,7 +42,7 @@
|
|
|
40
42
|
</tr>
|
|
41
43
|
<% end %>
|
|
42
44
|
<% if @batches.empty? %>
|
|
43
|
-
<tr><td colspan="
|
|
45
|
+
<tr><td colspan="8" class="px-4 py-8 text-center text-sm text-gray-500">No batches found.</td></tr>
|
|
44
46
|
<% end %>
|
|
45
47
|
</tbody>
|
|
46
48
|
</table>
|
|
@@ -2,9 +2,19 @@
|
|
|
2
2
|
<%= link_to "← Batches", batches_path, class: "text-sm text-indigo-600 hover:text-indigo-900" %>
|
|
3
3
|
<h1 class="text-2xl font-bold text-gray-900 mt-2"><%= @batch.purpose %> · <%= @batch.model %></h1>
|
|
4
4
|
<p class="text-sm text-gray-500 mt-1">
|
|
5
|
+
Provider: <span class="font-mono"><%= @batch.provider %></span> ·
|
|
5
6
|
OpenAI: <span class="font-mono"><%= @batch.openai_batch_id %></span> · status <%= @batch.status %> ·
|
|
6
7
|
<%= @batch.request_count %> requests
|
|
7
8
|
</p>
|
|
9
|
+
<p class="text-sm text-gray-500 mt-1">
|
|
10
|
+
Provider batch: <span class="font-mono"><%= @batch.provider_batch_id %></span>
|
|
11
|
+
</p>
|
|
12
|
+
<% if @batch.provider_metadata.present? %>
|
|
13
|
+
<p class="text-sm text-gray-500 mt-1">
|
|
14
|
+
S3 output: <span class="font-mono"><%= @batch.provider_metadata["s3_output_uri"] %></span> ·
|
|
15
|
+
Job ID: <span class="font-mono"><%= @batch.provider_metadata["job_id"] %></span>
|
|
16
|
+
</p>
|
|
17
|
+
<% end %>
|
|
8
18
|
</div>
|
|
9
19
|
|
|
10
20
|
<div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-lg overflow-hidden">
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
class AddProviderColumnsToLlmLogsBatches < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
add_column :llm_logs_batches, :provider_batch_id, :string
|
|
4
|
+
add_column :llm_logs_batches, :provider_metadata, :jsonb, null: false, default: {}
|
|
5
|
+
add_index :llm_logs_batches, :provider_batch_id
|
|
6
|
+
|
|
7
|
+
# Backfill existing OpenAI rows so the reconciler/poll job can key off the
|
|
8
|
+
# generic column uniformly.
|
|
9
|
+
up_only do
|
|
10
|
+
execute <<~SQL.squish
|
|
11
|
+
UPDATE llm_logs_batches
|
|
12
|
+
SET provider_batch_id = openai_batch_id
|
|
13
|
+
WHERE openai_batch_id IS NOT NULL
|
|
14
|
+
SQL
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
module LlmLogs
|
|
2
2
|
class Configuration
|
|
3
|
+
BedrockBatch = Struct.new(:role_arn, :s3_bucket, :s3_prefix, :min_records, :model_matcher, :region, keyword_init: true)
|
|
4
|
+
|
|
3
5
|
attr_accessor :enabled, :auto_instrument, :retention_days, :prompts_source_path, :prompt_subfolders,
|
|
4
|
-
:batch_enabled, :batch_provider, :page_size
|
|
6
|
+
:batch_enabled, :batch_provider, :page_size, :bedrock_batch
|
|
5
7
|
|
|
6
8
|
def initialize
|
|
7
9
|
@enabled = true
|
|
@@ -12,6 +14,7 @@ module LlmLogs
|
|
|
12
14
|
@batch_enabled = true
|
|
13
15
|
@batch_provider = :openai_responses
|
|
14
16
|
@page_size = 50
|
|
17
|
+
@bedrock_batch = nil
|
|
15
18
|
end
|
|
16
19
|
end
|
|
17
20
|
|
data/lib/llm_logs/version.rb
CHANGED
data/lib/llm_logs.rb
CHANGED
|
@@ -54,6 +54,10 @@ module LlmLogs
|
|
|
54
54
|
configuration.batch_provider
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
def self.bedrock_batch
|
|
58
|
+
configuration.bedrock_batch
|
|
59
|
+
end
|
|
60
|
+
|
|
57
61
|
def self.register_batch_handler(purpose, handler)
|
|
58
62
|
LlmLogs::Batch::HandlerRegistry.register(purpose, handler)
|
|
59
63
|
end
|
|
@@ -62,6 +66,14 @@ module LlmLogs
|
|
|
62
66
|
LlmLogs::Batch::HandlerRegistry.resolve(purpose)
|
|
63
67
|
end
|
|
64
68
|
|
|
69
|
+
def self.batch_adapters
|
|
70
|
+
@batch_adapters ||= {openai_responses: LlmLogs::Batch::Adapters::OpenaiResponses.new}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.register_batch_adapter(provider, adapter)
|
|
74
|
+
batch_adapters[provider.to_sym] = adapter
|
|
75
|
+
end
|
|
76
|
+
|
|
65
77
|
def self.trace(name, **options, &block)
|
|
66
78
|
LlmLogs::Tracer.start_trace(name, **options, &block)
|
|
67
79
|
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.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Anton
|
|
@@ -135,6 +135,34 @@ dependencies:
|
|
|
135
135
|
- - "~>"
|
|
136
136
|
- !ruby/object:Gem::Version
|
|
137
137
|
version: '3.0'
|
|
138
|
+
- !ruby/object:Gem::Dependency
|
|
139
|
+
name: aws-sdk-bedrock
|
|
140
|
+
requirement: !ruby/object:Gem::Requirement
|
|
141
|
+
requirements:
|
|
142
|
+
- - "~>"
|
|
143
|
+
- !ruby/object:Gem::Version
|
|
144
|
+
version: '1.0'
|
|
145
|
+
type: :development
|
|
146
|
+
prerelease: false
|
|
147
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
148
|
+
requirements:
|
|
149
|
+
- - "~>"
|
|
150
|
+
- !ruby/object:Gem::Version
|
|
151
|
+
version: '1.0'
|
|
152
|
+
- !ruby/object:Gem::Dependency
|
|
153
|
+
name: aws-sdk-s3
|
|
154
|
+
requirement: !ruby/object:Gem::Requirement
|
|
155
|
+
requirements:
|
|
156
|
+
- - "~>"
|
|
157
|
+
- !ruby/object:Gem::Version
|
|
158
|
+
version: '1.0'
|
|
159
|
+
type: :development
|
|
160
|
+
prerelease: false
|
|
161
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
162
|
+
requirements:
|
|
163
|
+
- - "~>"
|
|
164
|
+
- !ruby/object:Gem::Version
|
|
165
|
+
version: '1.0'
|
|
138
166
|
description: Mountable Rails engine that provides hierarchical LLM call tracing and
|
|
139
167
|
versioned prompt management with Mustache templates.
|
|
140
168
|
executables: []
|
|
@@ -157,6 +185,8 @@ files:
|
|
|
157
185
|
- app/jobs/llm_logs/batch/poll_job.rb
|
|
158
186
|
- app/models/llm_logs/application_record.rb
|
|
159
187
|
- app/models/llm_logs/batch.rb
|
|
188
|
+
- app/models/llm_logs/batch/adapters/bedrock.rb
|
|
189
|
+
- app/models/llm_logs/batch/adapters/openai_responses.rb
|
|
160
190
|
- app/models/llm_logs/batch/handler_registry.rb
|
|
161
191
|
- app/models/llm_logs/batch/reconciler.rb
|
|
162
192
|
- app/models/llm_logs/batch/schema_format.rb
|
|
@@ -199,6 +229,7 @@ files:
|
|
|
199
229
|
- db/migrate/006_add_tags_to_prompts.rb
|
|
200
230
|
- db/migrate/007_create_llm_logs_batches.rb
|
|
201
231
|
- db/migrate/008_create_llm_logs_batch_requests.rb
|
|
232
|
+
- db/migrate/009_add_provider_columns_to_llm_logs_batches.rb
|
|
202
233
|
- lib/generators/llm_logs/install_generator.rb
|
|
203
234
|
- lib/generators/llm_logs/templates/initializer.rb
|
|
204
235
|
- lib/llm_logs.rb
|