llm_logs 0.4.1 → 0.6.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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +35 -16
  3. data/app/jobs/llm_logs/batch/flush_job.rb +1 -0
  4. data/app/jobs/llm_logs/batch/poll_job.rb +1 -0
  5. data/app/models/llm_logs/batch/reconciler.rb +1 -0
  6. data/app/models/llm_logs/batch/submitter.rb +9 -7
  7. data/app/models/llm_logs/batch/trace_recorder.rb +19 -7
  8. data/app/models/llm_logs/batch.rb +15 -28
  9. data/app/models/llm_logs/prompt.rb +11 -2
  10. data/app/models/llm_logs/span.rb +15 -3
  11. data/app/views/llm_logs/spans/show.html.erb +3 -3
  12. data/app/views/llm_logs/traces/index.html.erb +3 -3
  13. data/app/views/llm_logs/traces/show.html.erb +5 -3
  14. data/db/migrate/001_create_llm_logs_traces.rb +1 -1
  15. data/db/migrate/002_create_llm_logs_spans.rb +3 -3
  16. data/db/migrate/004_create_llm_logs_prompt_versions.rb +3 -3
  17. data/db/migrate/006_add_tags_to_prompts.rb +6 -2
  18. data/db/migrate/007_create_llm_logs_batches.rb +1 -1
  19. data/db/migrate/008_create_llm_logs_batch_requests.rb +2 -2
  20. data/db/migrate/009_add_provider_columns_to_llm_logs_batches.rb +1 -1
  21. data/lib/llm_logs/configuration.rb +1 -2
  22. data/lib/llm_logs/engine.rb +10 -1
  23. data/lib/llm_logs/instrumentation/ruby_llm_chat.rb +190 -82
  24. data/lib/llm_logs/ruby_llm_patches/bedrock_sigv4.rb +75 -0
  25. data/lib/llm_logs/ruby_llm_patches/converse_claude_adaptive_thinking.rb +70 -0
  26. data/lib/llm_logs/ruby_llm_patches/converse_foreign_reasoning.rb +80 -0
  27. data/lib/llm_logs/ruby_llm_patches/converse_reasoning_config.rb +78 -0
  28. data/lib/llm_logs/ruby_llm_patches/retry_ssl_error.rb +25 -0
  29. data/lib/llm_logs/ruby_llm_patches.rb +54 -0
  30. data/lib/llm_logs/version.rb +1 -1
  31. data/lib/llm_logs.rb +1 -5
  32. metadata +23 -13
  33. data/app/models/llm_logs/batch/adapters/openai_responses.rb +0 -63
  34. data/app/models/llm_logs/batch/schema_format.rb +0 -25
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8982c29ec3f53e2271e24f78065472f3f71b15828449dd51cda1154122cb8d6f
4
- data.tar.gz: 2a2026e3441b16b61c36497689a7d468f09987bfb0c85c95aebb641ba3ee9d94
3
+ metadata.gz: c131b25cf1499cbebd755808d206f17e7c4dc4199fe18a7d282cabef947b349f
4
+ data.tar.gz: fadedd362588224fad8fc191dc6f9a3ee4d65746ed1d86f239b37e228b448606
5
5
  SHA512:
6
- metadata.gz: d3d62b8c8d71bb12639ef74895d53e2974aba1eca3e4d219bce6387f790cb7712ac2ca46ea24e72c7940bb2970ecc04af976802d7a7d1c2f758bc507a392e3aa
7
- data.tar.gz: 9dfdbe34061fe8b748fda1ca4ad75f8646e57030c264549b91da0cd0b0314577b42b4b0fef8bfa87b336cbac9c2b95bf5d10bf92686c5548780539751b14402b
6
+ metadata.gz: d94d3962f820cce448da3171f364eeaea2995a707a8fd79e863fae6b9889db321e0ccf6bcead6f12ac48aa50709178e5c8dc2de858db6e111aa4be8ba7bc5cce
7
+ data.tar.gz: 8237d89556f129e2010ee04229d85f2ebe03d3bd11ad7799e1150a3d7c88134c1b4c63fe54e8555f9a3e53b1119e604332b184bb787c9586d87559db26453343
data/README.md CHANGED
@@ -209,16 +209,7 @@ Running the task creates missing prompts, updates metadata, and creates a new pr
209
209
 
210
210
  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.
211
211
 
212
- Two batch backends are supported, selected **per model**:
213
-
214
- - **[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.
215
- - **[AWS Bedrock Batch API](#aws-bedrock-batches)** (`CreateModelInvocationJob`) for Anthropic Claude models.
216
-
217
- Add the OpenAI provider to your app's Gemfile:
218
-
219
- ```ruby
220
- gem "ruby_llm-responses_api"
221
- ```
212
+ Batching runs on the **[AWS Bedrock Batch API](#aws-bedrock-batches)** (`CreateModelInvocationJob`) for Anthropic Claude models. Models Bedrock does not serve are not batchable; run them synchronously.
222
213
 
223
214
  ### Enqueue a Request
224
215
 
@@ -227,10 +218,10 @@ Requests are persisted immediately and grouped by `purpose` + `model` when submi
227
218
  ```ruby
228
219
  LlmLogs::Batch.enqueue(
229
220
  purpose: "chat_summary",
230
- model: "gpt-4.1-mini",
221
+ model: "anthropic.claude-haiku-4-5-20251001-v1:0",
231
222
  instructions: "Summarize the conversation in two sentences.",
232
223
  input: conversation_text,
233
- schema: SummarySchema, # optional RubyLLM::Schema for structured output
224
+ schema: SUMMARY_SCHEMA, # optional JSON schema Hash for structured output
234
225
  routing: { conversation_id: 42 }, # your keys, echoed into the trace metadata
235
226
  temperature: 0.2 # optional
236
227
  )
@@ -247,7 +238,8 @@ Register one handler per `purpose`. The gem owns the batch lifecycle; your app o
247
238
  LlmLogs.register_batch_handler("chat_summary", ChatSummaryHandler.new)
248
239
 
249
240
  class ChatSummaryHandler
250
- # Called once a request succeeds. `message` is the RubyLLM::Message.
241
+ # Called once a request succeeds. `message` is a `LlmLogs::Batch::Adapters::Bedrock::Result`
242
+ # (content, input_tokens, output_tokens, model_id).
251
243
  def call(request, message)
252
244
  Conversation.find(request.routing["conversation_id"])
253
245
  .update!(summary: message.content)
@@ -302,7 +294,7 @@ LlmLogs.configuration.bedrock_batch = LlmLogs::Configuration::BedrockBatch.new(
302
294
  LlmLogs.register_batch_adapter(:bedrock, LlmLogs::Batch::Adapters::Bedrock.new)
303
295
  ```
304
296
 
305
- 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.
297
+ Provider selection is per model: `LlmLogs::Batch.batch_provider_for(model)` returns `:bedrock` when the adapter is registered and `model_matcher` matches, 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.
306
298
 
307
299
  The adapter builds its AWS clients from the ambient credential chain by default; inject your own to authenticate explicitly:
308
300
 
@@ -330,16 +322,37 @@ Browse traces and manage prompts at `/llm_logs`.
330
322
  ```ruby
331
323
  LlmLogs.setup do |config|
332
324
  config.enabled = true # master switch for logging
333
- config.auto_instrument = true # auto-prepend on RubyLLM::Chat
325
+ config.auto_instrument = true # subscribe to ruby_llm notifications (needs RubyLLM.config.instrumenter = ActiveSupport::Notifications)
334
326
  config.retention_days = 30 # for future cleanup job
335
327
  config.prompts_source_path = Rails.root.join("db/data/prompts")
336
328
  config.prompt_subfolders = %w[skills fragments templates]
337
329
  config.batch_enabled = true # enable the batch API integration
338
- config.batch_provider = :openai_responses # default (OpenAI) backend; Bedrock is registered separately (see Batches)
339
330
  config.page_size = 50 # rows per page on all index pages
340
331
  end
341
332
  ```
342
333
 
334
+ ## ruby_llm 2.0 integration
335
+
336
+ With `auto_instrument` on, LlmLogs subscribes to ruby_llm's `chat.ruby_llm` and `tool_call.ruby_llm` notifications: one `llm` span per provider round and one tool span per tool call, all siblings under the trace. Tokens and cost come from each round's usage entries. This requires `RubyLLM.config.instrumenter` to be `ActiveSupport::Notifications`; the Railtie sets that default, and `LlmLogs::Instrumentation::RubyLlmChat.install!` sets it when unset.
337
+
338
+ ### Patches for ruby_llm 2.0.x
339
+
340
+ The engine prepends five fixes (`LlmLogs::RubyLLMPatches`) that ruby_llm 2.0.0 lacks. Each one backports an upstream pull request, so dropping it once a ruby_llm release includes that PR changes no behaviour (exceptions are noted). They install only on ruby_llm >= 2.0.0, and log a warning on 2.1 or later so you re-check them.
341
+
342
+ | Patch | Upstream PR |
343
+ |---|---|
344
+ | BedrockSigV4, RetrySSLError | [crmne/ruby_llm#1024](https://github.com/crmne/ruby_llm/pull/1024) |
345
+ | ConverseReasoningConfig, ConverseClaudeAdaptiveThinking | [crmne/ruby_llm#1025](https://github.com/crmne/ruby_llm/pull/1025) |
346
+ | ConverseForeignReasoning | [crmne/ruby_llm#1026](https://github.com/crmne/ruby_llm/pull/1026) |
347
+
348
+ - **BedrockSigV4** ([#1024](https://github.com/crmne/ruby_llm/pull/1024), "Sign each Bedrock attempt when it is sent") re-signs every Bedrock attempt at send time, over the body being sent, and removes an `X-Amz-Security-Token` the new signature no longer carries. Upstream 2.0.0 signs once before the retry middleware, so a retry after a long timeout replays an expired signature (403 "Signature expired"). Drop when a ruby_llm release includes #1024.
349
+ - **RetrySSLError** ([#1024](https://github.com/crmne/ruby_llm/pull/1024), "Retry requests that fail with a TLS error") adds `Faraday::SSLError` (for example "SSL_connect ... unexpected eof") to the retried exceptions. This is the same trade-off ruby_llm already accepts for read timeouts: a request that reached the server may be sent twice. ruby_llm's `retry_if` still refuses to retry requests marked non-idempotent and streams that already delivered content. Drop when a ruby_llm release includes #1024.
350
+ - **ConverseReasoningConfig** ([#1025](https://github.com/crmne/ruby_llm/pull/1025), "Send reasoning_config to models that publish it") sends a reasoning effort on Bedrock Converse as `additionalModelRequestFields: {reasoning_config: "<effort>"}` (including `none`, which is also what `with_thinking(false)` resolves to for these models) when the model's Converse `additionalRequestFieldsSchema`, or that of another registry entry for the same foundation model, publishes a `reasoning_config` enum (OpenAI GPT 5.6/6 Astra, xAI Grok 4.6, ...). Upstream 2.0.0 sends `reasoning_effort`, which Bedrock rejects for GPT (400 unknown_parameter). Thinking turned off, budgets, Claude, Nova, and gpt-oss are unchanged. Beyond #1025, OpenAI GPT ids with no published schema (ruby_llm 2.0.0's registry lacks GPT-6 Sol/Luna) get the same shape, under any region prefix. Drop when a ruby_llm release includes #1025; that fallback goes with it, so first check that the registry you run publishes the schema for every GPT id you use (or add `metadata.converse.additionalRequestFieldsSchema` to your catalog entry), or those ids go back to `reasoning_effort`.
351
+ - **ConverseClaudeAdaptiveThinking** ([#1025](https://github.com/crmne/ruby_llm/pull/1025), "Think adaptively on adaptive-only Claude via Converse") sends adaptive thinking to adaptive-only Claude models on Bedrock Converse (the registry advertises an `effort` option and no `budget_tokens` option: Sonnet 5, Opus 4.7/4.8/5, Fable 5) as `additionalModelRequestFields: {thinking: {type: "adaptive"}, output_config: {effort: ...}}` (every advertised tier, `xhigh` and `max` included; `{thinking: {type: "adaptive"}}` alone when no effort is set, as for `with_thinking(true)` or a `display` alone; nothing for `none`; no `display` is sent). Upstream 2.0.0 sends a fixed `reasoning_config` budget, which these models reject ("thinking.type.enabled" is not supported for this model). Any budget, thinking turned off, budget-style Claude (Haiku 4.5, Sonnet 4.6), and other vendors are unchanged. An unregistered id takes its reasoning options from a registry entry for the same foundation model. Drop when a ruby_llm release includes #1025.
352
+ - **ConverseForeignReasoning** ([#1026](https://github.com/crmne/ruby_llm/pull/1026), "Drop Bedrock reasoning another model family produced" and "Give application inference profiles no vendor") replays a message's reasoning only to a model of the same family on Bedrock Converse. Upstream 2.0.0 replays every assistant message's reasoning to whatever model is next, and Claude and GPT are both the `bedrock` provider: a chat Claude answered and GPT continues fails with "This model doesn't support the reasoningContent.reasoningText.text field", and a chat GPT answered and Claude continues hands Claude GPT's encrypted reasoning. A model's family is `anthropic`, `openai`, or the vendor segment of any other Bedrock id (`amazon`, `meta`, ...), whatever its region prefix; an application inference profile ARN names no family, even with a dot in its id (`.../application-inference-profile/team.prod`). The producer is `RubyLLM::Message#model` (for a persisted message, the model of its last successful usage entry). Reasoning from another family is dropped. Anthropic targets otherwise get upstream's replay unchanged, and so does a target that names no family. **This patch is stricter than #1026 on purpose:** a non-Anthropic target whose producer is unknown or of the same family gets only the `redactedContent` blocks stored in `raw_reasoning["converse"]` (GPT's own encrypted reasoning across tool-call rounds), never `reasoningText` or the thinking text/signature fallback, where #1026 replays them all. Drop when a ruby_llm release includes #1026, accepting that looser replay (or keep the stricter rule as a patch of its own).
353
+
354
+ Not backported: #1025's first commit ("Recognise the in. Bedrock inference profile prefix"), which adds `in` to `Converse::REGION_PREFIXES` and also changes region rewriting and Mantle routing. The patches here match ids under any region prefix instead.
355
+
343
356
  ## Requirements
344
357
 
345
358
  - Rails 8.0+
@@ -349,3 +362,9 @@ end
349
362
  ## License
350
363
 
351
364
  MIT
365
+
366
+ ## Database support
367
+
368
+ Prompts, prompt versions, tag filters, traces, spans, and their UI support PostgreSQL and SQLite. PostgreSQL retains JSONB and array tags; SQLite uses JSON columns and `json_each` for exact tag matching. Provider batch execution requires PostgreSQL; direct calls and jobs raise `LlmLogs::Batch::UnsupportedAdapter` on SQLite. `LlmLogs::Batch.supported_adapter?` reports the capability.
369
+
370
+ Run the shared SQLite tests with `LLM_LOGS_DATABASE=sqlite bundle exec rspec`; PostgreSQL remains the default. SQLite migrations run from the gem's install chain before tests. `script/verify_migrations.rb` verifies install, rollback, and schema reload **only on a disposable database** with `LLM_LOGS_DISPOSABLE_DATABASE=1` and an explicit `DATABASE_URL`.
@@ -4,6 +4,7 @@ module LlmLogs
4
4
  queue_as :default
5
5
 
6
6
  def perform(purpose)
7
+ LlmLogs::Batch.require_supported_adapter!
7
8
  models = LlmLogs::BatchRequest.pending.where(purpose: purpose).distinct.pluck(:model)
8
9
  models.each { |model| LlmLogs::Batch.submit_pending(purpose: purpose, model: model) }
9
10
  end
@@ -9,6 +9,7 @@ module LlmLogs
9
9
  STALE_CLAIM_AFTER = 15.minutes
10
10
 
11
11
  def perform
12
+ LlmLogs::Batch.require_supported_adapter!
12
13
  recover_stale_claims
13
14
  LlmLogs::Batch.unreconciled.where.not(provider_batch_id: nil).find_each do |batch|
14
15
  batch.reconcile!
@@ -9,6 +9,7 @@ module LlmLogs
9
9
  end
10
10
 
11
11
  def call
12
+ LlmLogs::Batch.require_supported_adapter!
12
13
  adapter = LlmLogs::Batch.adapter_for(@batch.provider)
13
14
  status = adapter.terminal_status(@batch)
14
15
  case status
@@ -1,12 +1,13 @@
1
1
  module LlmLogs
2
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.
3
+ # Groups pending BatchRequests of one purpose+model into a single provider batch (Bedrock).
4
+ # To keep two concurrent FlushJobs from double-submitting the same requests, it first
5
+ # CLAIMS the pending rows in a `FOR UPDATE SKIP LOCKED` transaction, assigning them to a
6
+ # placeholder Batch with no provider batch id (stored in the legacy `openai_batch_id`
7
+ # column), which flips them out of the `pending` scope and which PollJob ignores. It then
8
+ # submits the batch and records its id. If submission fails, the claim is released
9
+ # (requests return to `pending`) and the placeholder batch is dropped, so the work retries
10
+ # on the next flush.
10
11
  class Submitter
11
12
  def initialize(purpose:, model:, metadata: {})
12
13
  @purpose = purpose
@@ -15,6 +16,7 @@ module LlmLogs
15
16
  end
16
17
 
17
18
  def call
19
+ LlmLogs::Batch.require_supported_adapter!
18
20
  batch = claim_batch
19
21
  return nil if batch.nil?
20
22
 
@@ -27,23 +27,35 @@ module LlmLogs
27
27
  output: { "content" => span.serialize_content(message.content) },
28
28
  input_tokens: message.input_tokens,
29
29
  output_tokens: message.output_tokens,
30
- cost: compute_cost(message)
30
+ cost: compute_cost(message, request: request, provider: provider)
31
31
  )
32
32
  span.finish
33
33
  end
34
34
  trace
35
35
  end
36
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
37
+ # Prices the request at the submitted model's standard rates for this provider (the
38
+ # Bedrock id, e.g. us.anthropic.claude-haiku-4-5-...), falling back to the id the result
39
+ # reports (a native Anthropic id resolves to the anthropic provider's rates). nil when
40
+ # neither is in the registry or the registry has no price for the tokens used.
41
+ def compute_cost(message, request:, provider:)
42
+ model = pricing_model(request.model, provider) || pricing_model(message.model_id, nil)
43
+ return nil unless model
40
44
 
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)
45
+ tokens = RubyLLM::Tokens.new(input: message.input_tokens.to_i, output: message.output_tokens.to_i)
46
+ total = model.cost_for(tokens).total
47
+ total && (total * BATCH_COST_MULTIPLIER).round(6)
44
48
  rescue StandardError
45
49
  nil
46
50
  end
51
+
52
+ def pricing_model(model_id, provider)
53
+ return nil if model_id.blank?
54
+
55
+ provider.present? ? RubyLLM.models.find(model_id, provider: provider) : RubyLLM.models.find(model_id)
56
+ rescue RubyLLM::ModelNotFoundError
57
+ nil
58
+ end
47
59
  end
48
60
  end
49
61
  end
@@ -1,5 +1,15 @@
1
1
  module LlmLogs
2
2
  class Batch < ApplicationRecord
3
+ class UnsupportedAdapter < StandardError; end
4
+
5
+ def self.supported_adapter?
6
+ connection.adapter_name == "PostgreSQL"
7
+ end
8
+
9
+ def self.require_supported_adapter!
10
+ raise UnsupportedAdapter, "Provider batch execution requires PostgreSQL" unless supported_adapter?
11
+ end
12
+
3
13
  self.table_name = "llm_logs_batches"
4
14
 
5
15
  has_many :requests, class_name: "LlmLogs::BatchRequest", dependent: :destroy
@@ -19,6 +29,7 @@ module LlmLogs
19
29
  scope :unreconciled, -> { where.not(status: %i[reconciled failed expired]) }
20
30
 
21
31
  def self.enqueue(purpose:, model:, input:, instructions:, schema:, routing:, temperature: nil, reasoning_effort: nil)
32
+ require_supported_adapter!
22
33
  BatchRequest.create!(
23
34
  purpose: purpose,
24
35
  model: model,
@@ -50,19 +61,15 @@ module LlmLogs
50
61
  end
51
62
 
52
63
  def self.batchable?(model)
53
- return false unless LlmLogs.batch_enabled?
64
+ return false unless supported_adapter? && LlmLogs.batch_enabled?
54
65
 
55
66
  !batch_provider_for(model).nil?
56
67
  end
57
68
 
58
- # Which batch provider (if any) serves this model. Bedrock wins for Claude models when
59
- # the Bedrock adapter is configured; otherwise fall back to the OpenAI provider when the
60
- # model resolves there; otherwise nil (run synchronously).
69
+ # Which batch provider (if any) serves this model: :bedrock when the Bedrock adapter is
70
+ # registered and its model_matcher matches, otherwise nil (run synchronously).
61
71
  def self.batch_provider_for(model)
62
- return :bedrock if bedrock_serves?(model)
63
- return :openai_responses if openai_serves?(model)
64
-
65
- nil
72
+ bedrock_serves?(model) ? :bedrock : nil
66
73
  end
67
74
 
68
75
  # The Bedrock minimum records-per-job floor for this model (0 when Bedrock does not serve it).
@@ -78,25 +85,5 @@ module LlmLogs
78
85
  matcher = config.model_matcher
79
86
  matcher.respond_to?(:call) ? matcher.call(model.to_s) : matcher.match?(model.to_s)
80
87
  end
81
-
82
- def self.openai_serves?(model)
83
- return false unless defined?(RubyLLM::Providers::OpenAIResponses)
84
-
85
- servable_by_batch_provider?(model)
86
- end
87
-
88
- # The batch path submits via RubyLLM.batch(provider: batch_provider). A model is
89
- # only batchable if that provider can actually serve it -- i.e. the model resolves
90
- # under batch_provider. Models that belong to a different provider (e.g. Bedrock /
91
- # Anthropic) don't resolve there, so they return false and the caller runs them
92
- # synchronously instead of enqueueing work that would fail at submit time.
93
- def self.servable_by_batch_provider?(model)
94
- RubyLLM::Models.resolve(
95
- model, provider: LlmLogs.batch_provider, assume_exists: false, config: RubyLLM.config
96
- )
97
- true
98
- rescue RubyLLM::ModelNotFoundError
99
- false
100
- end
101
88
  end
102
89
  end
@@ -5,8 +5,17 @@ module LlmLogs
5
5
  validates :slug, presence: true, uniqueness: true
6
6
  validates :name, presence: true
7
7
 
8
- scope :with_tag, ->(tag) { where("? = ANY(tags)", tag) }
9
- scope :with_any_tag, ->(tags) { where("tags && ARRAY[?]::varchar[]", Array(tags)) }
8
+ scope :with_tag, ->(tag) { with_any_tag([tag]) }
9
+ scope :with_any_tag, ->(tags) {
10
+ values = Array(tags)
11
+ if values.empty?
12
+ none
13
+ elsif connection.adapter_name == "PostgreSQL"
14
+ where("tags && ARRAY[?]::varchar[]", values)
15
+ else
16
+ where("EXISTS (SELECT 1 FROM json_each(llm_logs_prompts.tags) AS tag WHERE tag.value COLLATE BINARY IN (?))", values)
17
+ end
18
+ }
10
19
 
11
20
  def tags_string
12
21
  Array(tags).join(", ")
@@ -19,11 +19,23 @@ module LlmLogs
19
19
  LlmLogs::Tracer.current_span = parent_span
20
20
  end
21
21
 
22
+ # +message+ is a RubyLLM 2.x Message (or anything with +content+ and +tokens+).
22
23
  def record_response(message)
23
24
  self.output = { content: serialize_content(message.content) }
24
- self.input_tokens = message.input_tokens
25
- self.output_tokens = message.output_tokens
26
- self.cached_tokens = message.cached_tokens
25
+ record_tokens(message.tokens)
26
+ end
27
+
28
+ # Copies a RubyLLM::Tokens onto the span. +input+ is non-cached input (RubyLLM 2.x no
29
+ # longer subtracts cache buckets from it); cache writes and thinking have no column and
30
+ # go to metadata.
31
+ def record_tokens(tokens)
32
+ return unless tokens
33
+
34
+ self.input_tokens = tokens.input
35
+ self.output_tokens = tokens.output
36
+ self.cached_tokens = tokens.cache_read
37
+ set_attribute("cache_write_tokens", tokens.cache_write) unless tokens.cache_write.nil?
38
+ set_attribute("thinking_tokens", tokens.thinking) unless tokens.thinking.nil?
27
39
  end
28
40
 
29
41
  # Structured (schema) responses arrive as a Hash/Array; keep them as-is so the
@@ -24,15 +24,15 @@
24
24
  </div>
25
25
  <div class="bg-white rounded-lg p-4 shadow-sm ring-1 ring-gray-900/5">
26
26
  <dt class="text-xs font-medium text-gray-500 uppercase">Input Tokens</dt>
27
- <dd class="text-lg font-semibold text-gray-900 mt-1"><%= number_with_delimiter(@span.input_tokens || 0) %></dd>
27
+ <dd class="text-lg font-semibold text-gray-900 mt-1"><%= @span.input_tokens.nil? ? "Unavailable" : number_with_delimiter(@span.input_tokens) %></dd>
28
28
  </div>
29
29
  <div class="bg-white rounded-lg p-4 shadow-sm ring-1 ring-gray-900/5">
30
30
  <dt class="text-xs font-medium text-gray-500 uppercase">Output Tokens</dt>
31
- <dd class="text-lg font-semibold text-gray-900 mt-1"><%= number_with_delimiter(@span.output_tokens || 0) %></dd>
31
+ <dd class="text-lg font-semibold text-gray-900 mt-1"><%= @span.output_tokens.nil? ? "Unavailable" : number_with_delimiter(@span.output_tokens) %></dd>
32
32
  </div>
33
33
  <div class="bg-white rounded-lg p-4 shadow-sm ring-1 ring-gray-900/5">
34
34
  <dt class="text-xs font-medium text-gray-500 uppercase">Cached Tokens</dt>
35
- <dd class="text-lg font-semibold text-green-600 mt-1"><%= number_with_delimiter(@span.cached_tokens || 0) %></dd>
35
+ <dd class="text-lg font-semibold text-green-600 mt-1"><%= @span.cached_tokens.nil? ? "Unavailable" : number_with_delimiter(@span.cached_tokens) %></dd>
36
36
  <% if (@span.input_tokens || 0) > 0 && (@span.cached_tokens || 0) > 0 %>
37
37
  <dd class="text-xs text-green-600 mt-0.5"><%= (@span.cached_tokens.to_f / @span.input_tokens * 100).round %>% of input</dd>
38
38
  <% end %>
@@ -50,13 +50,13 @@
50
50
  </td>
51
51
  <td class="px-4 py-3 text-sm text-gray-500 text-right"><%= trace.spans_count %></td>
52
52
  <td class="px-4 py-3 text-sm text-gray-500 text-right">
53
- <%= number_with_delimiter(trace.total_input_tokens) %> &rarr; <%= number_with_delimiter(trace.total_output_tokens) %>
53
+ <%= trace.metadata&.dig("usage_missing") ? "Usage unavailable" : number_with_delimiter(trace.total_input_tokens) %> &rarr; <%= trace.metadata&.dig("usage_missing") ? "Usage unavailable" : number_with_delimiter(trace.total_output_tokens) %>
54
54
  <% if trace.total_cached_tokens > 0 %>
55
- <br><span class="text-xs text-green-600"><%= number_with_delimiter(trace.total_cached_tokens) %> cached</span>
55
+ <br><span class="text-xs text-green-600"><%= trace.metadata&.dig("usage_missing") ? "Usage unavailable" : number_with_delimiter(trace.total_cached_tokens) %> cached</span>
56
56
  <% end %>
57
57
  </td>
58
58
  <td class="px-4 py-3 text-sm text-gray-500 text-right">
59
- <% if trace.total_cost > 0 %>$<%= sprintf('%.4f', trace.total_cost) %><% end %>
59
+ <% if trace.metadata&.dig('billing_mode') == 'subscription' %>Subscription · cost unavailable<% elsif trace.total_cost > 0 %>$<%= sprintf('%.4f', trace.total_cost) %><% end %>
60
60
  </td>
61
61
  <td class="px-4 py-3 text-sm text-gray-500 text-right">
62
62
  <%= format_duration_ms(trace.duration_ms) %>
@@ -23,6 +23,8 @@
23
23
  </div>
24
24
  </div>
25
25
 
26
+ <% if @trace.metadata&.dig("billing_mode") == "subscription" %><p class="text-sm text-gray-500 mb-4">Subscription · cost unavailable</p><% end %>
27
+
26
28
  <div class="grid grid-cols-6 gap-4 mb-6">
27
29
  <div class="bg-white rounded-lg p-4 shadow-sm ring-1 ring-gray-900/5">
28
30
  <dt class="text-xs font-medium text-gray-500 uppercase">Model</dt>
@@ -34,15 +36,15 @@
34
36
  </div>
35
37
  <div class="bg-white rounded-lg p-4 shadow-sm ring-1 ring-gray-900/5">
36
38
  <dt class="text-xs font-medium text-gray-500 uppercase">Input Tokens</dt>
37
- <dd class="text-2xl font-semibold text-gray-900 mt-1"><%= number_with_delimiter(@trace.total_input_tokens) %></dd>
39
+ <dd class="text-2xl font-semibold text-gray-900 mt-1"><%= @trace.metadata&.dig("usage_missing") ? "Usage unavailable" : number_with_delimiter(@trace.total_input_tokens) %></dd>
38
40
  </div>
39
41
  <div class="bg-white rounded-lg p-4 shadow-sm ring-1 ring-gray-900/5">
40
42
  <dt class="text-xs font-medium text-gray-500 uppercase">Output Tokens</dt>
41
- <dd class="text-2xl font-semibold text-gray-900 mt-1"><%= number_with_delimiter(@trace.total_output_tokens) %></dd>
43
+ <dd class="text-2xl font-semibold text-gray-900 mt-1"><%= @trace.metadata&.dig("usage_missing") ? "Usage unavailable" : number_with_delimiter(@trace.total_output_tokens) %></dd>
42
44
  </div>
43
45
  <div class="bg-white rounded-lg p-4 shadow-sm ring-1 ring-gray-900/5">
44
46
  <dt class="text-xs font-medium text-gray-500 uppercase">Cached Tokens</dt>
45
- <dd class="text-2xl font-semibold text-green-600 mt-1"><%= number_with_delimiter(@trace.total_cached_tokens) %></dd>
47
+ <dd class="text-2xl font-semibold text-green-600 mt-1"><%= @trace.metadata&.dig("usage_missing") ? "Usage unavailable" : number_with_delimiter(@trace.total_cached_tokens) %></dd>
46
48
  <% if @trace.total_input_tokens > 0 && @trace.total_cached_tokens > 0 %>
47
49
  <dd class="text-xs text-green-600 mt-0.5"><%= (@trace.total_cached_tokens.to_f / @trace.total_input_tokens * 100).round %>% of input</dd>
48
50
  <% end %>
@@ -3,7 +3,7 @@ class CreateLlmLogsTraces < ActiveRecord::Migration[8.0]
3
3
  create_table :llm_logs_traces do |t|
4
4
  t.string :name, null: false
5
5
  t.string :status, null: false, default: "running"
6
- t.jsonb :metadata, default: {}
6
+ t.column :metadata, (connection.adapter_name == "PostgreSQL" ? :jsonb : :json), default: {}
7
7
  t.integer :total_input_tokens, default: 0
8
8
  t.integer :total_output_tokens, default: 0
9
9
  t.integer :total_cached_tokens, default: 0, null: false
@@ -7,8 +7,8 @@ class CreateLlmLogsSpans < ActiveRecord::Migration[8.0]
7
7
  t.string :span_type, null: false
8
8
  t.string :model
9
9
  t.string :provider
10
- t.jsonb :input
11
- t.jsonb :output
10
+ t.column :input, (connection.adapter_name == "PostgreSQL" ? :jsonb : :json)
11
+ t.column :output, (connection.adapter_name == "PostgreSQL" ? :jsonb : :json)
12
12
  t.integer :input_tokens
13
13
  t.integer :output_tokens
14
14
  t.integer :cached_tokens
@@ -16,7 +16,7 @@ class CreateLlmLogsSpans < ActiveRecord::Migration[8.0]
16
16
  t.float :duration_ms
17
17
  t.string :status, null: false, default: "ok"
18
18
  t.text :error_message
19
- t.jsonb :metadata, default: {}
19
+ t.column :metadata, (connection.adapter_name == "PostgreSQL" ? :jsonb : :json), default: {}
20
20
  t.datetime :started_at, null: false
21
21
  t.datetime :completed_at
22
22
 
@@ -3,10 +3,10 @@ class CreateLlmLogsPromptVersions < ActiveRecord::Migration[8.0]
3
3
  create_table :llm_logs_prompt_versions do |t|
4
4
  t.references :prompt, null: false, foreign_key: { to_table: :llm_logs_prompts }
5
5
  t.integer :version_number, null: false
6
- t.jsonb :messages, null: false, default: []
6
+ t.column :messages, (connection.adapter_name == "PostgreSQL" ? :jsonb : :json), null: false, default: []
7
7
  t.string :model
8
- t.jsonb :model_params, default: {}
9
- t.jsonb :default_variables, default: {}
8
+ t.column :model_params, (connection.adapter_name == "PostgreSQL" ? :jsonb : :json), default: {}
9
+ t.column :default_variables, (connection.adapter_name == "PostgreSQL" ? :jsonb : :json), default: {}
10
10
  t.text :changelog
11
11
 
12
12
  t.timestamps
@@ -1,6 +1,10 @@
1
1
  class AddTagsToPrompts < ActiveRecord::Migration[8.0]
2
2
  def change
3
- add_column :llm_logs_prompts, :tags, :string, array: true, default: [], null: false
4
- add_index :llm_logs_prompts, :tags, using: :gin, name: "index_llm_logs_prompts_on_tags"
3
+ if connection.adapter_name == "PostgreSQL"
4
+ add_column :llm_logs_prompts, :tags, :string, array: true, default: [], null: false
5
+ add_index :llm_logs_prompts, :tags, using: :gin, name: "index_llm_logs_prompts_on_tags"
6
+ else
7
+ add_column :llm_logs_prompts, :tags, :json, default: [], null: false
8
+ end
5
9
  end
6
10
  end
@@ -9,7 +9,7 @@ class CreateLlmLogsBatches < ActiveRecord::Migration[8.0]
9
9
  t.string :openai_error_file_id
10
10
  t.string :status, null: false, default: "pending"
11
11
  t.integer :request_count, null: false, default: 0
12
- t.jsonb :metadata, null: false, default: {}
12
+ t.column :metadata, (connection.adapter_name == "PostgreSQL" ? :jsonb : :json), null: false, default: {}
13
13
  t.datetime :submitted_at
14
14
  t.datetime :completed_at
15
15
  t.datetime :reconciled_at
@@ -6,8 +6,8 @@ class CreateLlmLogsBatchRequests < ActiveRecord::Migration[8.0]
6
6
  t.string :purpose, null: false
7
7
  t.string :status, null: false, default: "pending"
8
8
  t.string :model, null: false
9
- t.jsonb :payload, null: false, default: {}
10
- t.jsonb :routing, null: false, default: {}
9
+ t.column :payload, (connection.adapter_name == "PostgreSQL" ? :jsonb : :json), null: false, default: {}
10
+ t.column :routing, (connection.adapter_name == "PostgreSQL" ? :jsonb : :json), null: false, default: {}
11
11
  t.text :result_content
12
12
  t.integer :input_tokens
13
13
  t.integer :output_tokens
@@ -1,7 +1,7 @@
1
1
  class AddProviderColumnsToLlmLogsBatches < ActiveRecord::Migration[8.0]
2
2
  def change
3
3
  add_column :llm_logs_batches, :provider_batch_id, :string
4
- add_column :llm_logs_batches, :provider_metadata, :jsonb, null: false, default: {}
4
+ add_column :llm_logs_batches, :provider_metadata, (connection.adapter_name == "PostgreSQL" ? :jsonb : :json), null: false, default: {}
5
5
  add_index :llm_logs_batches, :provider_batch_id
6
6
 
7
7
  # Backfill existing OpenAI rows so the reconciler/poll job can key off the
@@ -3,7 +3,7 @@ module LlmLogs
3
3
  BedrockBatch = Struct.new(:role_arn, :s3_bucket, :s3_prefix, :min_records, :model_matcher, :region, keyword_init: true)
4
4
 
5
5
  attr_accessor :enabled, :auto_instrument, :retention_days, :prompts_source_path, :prompt_subfolders,
6
- :batch_enabled, :batch_provider, :page_size, :bedrock_batch, :reasoning_effort_options
6
+ :batch_enabled, :page_size, :bedrock_batch, :reasoning_effort_options
7
7
 
8
8
  def initialize
9
9
  @enabled = true
@@ -12,7 +12,6 @@ module LlmLogs
12
12
  @prompts_source_path = nil
13
13
  @prompt_subfolders = %w[skills fragments templates]
14
14
  @batch_enabled = true
15
- @batch_provider = :openai_responses
16
15
  @page_size = 50
17
16
  @bedrock_batch = nil
18
17
  # Effort tiers offered in the prompt form. Union of what current providers
@@ -6,11 +6,20 @@ module LlmLogs
6
6
  ActiveSupport.on_load(:active_record) do
7
7
  if LlmLogs.auto_instrument && defined?(RubyLLM::Chat)
8
8
  require "llm_logs/instrumentation/ruby_llm_chat"
9
- RubyLLM::Chat.prepend(LlmLogs::Instrumentation::RubyLlmChat)
9
+ LlmLogs::Instrumentation::RubyLlmChat.install!
10
10
  end
11
11
  end
12
12
  end
13
13
 
14
+ # Bedrock fixes missing from upstream ruby_llm 2.0.0. Installed whether or not
15
+ # auto-instrumentation is on: they change request behaviour, not logging.
16
+ initializer "llm_logs.ruby_llm_patches" do
17
+ if defined?(RubyLLM::VERSION)
18
+ require "llm_logs/ruby_llm_patches"
19
+ LlmLogs::RubyLLMPatches.install!
20
+ end
21
+ end
22
+
14
23
  rake_tasks do
15
24
  load File.expand_path("../tasks/llm_logs.rake", __dir__)
16
25
  end