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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2d74b0f40702d1e844d16656a8c3e9c48f6f8f6fc6114097153b4d93389cd123
4
- data.tar.gz: 20287a2481a84ed43c6967801b09c40ec4e533a78c87e10f315e9dbc37f866d1
3
+ metadata.gz: 042ed62e3a524de1a05ab6a8ce4184abe0634257c49d27da7f91e470d9810a66
4
+ data.tar.gz: 48b40cad0e67865812a1eca95cd6d2968309883ec66c55d8473ef274ec82b56c
5
5
  SHA512:
6
- metadata.gz: 0f24855ed39f9e45b2c734ee66195045846bf6ba6b2834e83ff7f1b91a51af1d6f5042a31053a85ca662913743275d355f4e30616fa60cc6fc0255412c44afdd
7
- data.tar.gz: 1ce3ee37439dfa90986394cf39e85bd67c6631d6d96a9c7138e0c2224af9a27a5d6d7c8661cb7c71950a8ed640f12f0396d7656bf073e4033d31632129cb87ea
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 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.
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
- Batch support uses the [`ruby_llm-responses_api`](https://rubygems.org/gems/ruby_llm-responses_api) provider. Add it to your app's Gemfile:
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 # batch backend
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(openai_batch_id: nil).find_each do |batch|
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, openai_batch_id: nil)
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
- rubyllm_batch = RubyLLM.batch(id: @batch.openai_batch_id, provider: LlmLogs.batch_provider)
13
- status = rubyllm_batch.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(rubyllm_batch)
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(rubyllm_batch)
27
- results = rubyllm_batch.results
28
- error_ids = rubyllm_batch.errors.filter_map { |e| e["custom_id"] }
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.batch_provider.to_s,
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
- 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)
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: LlmLogs.batch_provider.to_s,
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="7" class="px-4 py-8 text-center text-sm text-gray-500">No batches found.</td></tr>
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
 
@@ -1,3 +1,3 @@
1
1
  module LlmLogs
2
- VERSION = "0.2.6"
2
+ VERSION = "0.3.0"
3
3
  end
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.2.6
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