llm_logs 0.4.1 → 0.5.1

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: 8982c29ec3f53e2271e24f78065472f3f71b15828449dd51cda1154122cb8d6f
4
- data.tar.gz: 2a2026e3441b16b61c36497689a7d468f09987bfb0c85c95aebb641ba3ee9d94
3
+ metadata.gz: a687cddb64dc51d51cdf1bfa833833523b54194054ae32fe631354f187c9f256
4
+ data.tar.gz: ec0375795a8d27afa1266c1a8ebacd0984395fd8d41753c6ebd4c2658cfef026
5
5
  SHA512:
6
- metadata.gz: d3d62b8c8d71bb12639ef74895d53e2974aba1eca3e4d219bce6387f790cb7712ac2ca46ea24e72c7940bb2970ecc04af976802d7a7d1c2f758bc507a392e3aa
7
- data.tar.gz: 9dfdbe34061fe8b748fda1ca4ad75f8646e57030c264549b91da0cd0b0314577b42b4b0fef8bfa87b336cbac9c2b95bf5d10bf92686c5548780539751b14402b
6
+ metadata.gz: fe87b0ed824568ba3c1104e600fdc670a43919c5b061159a7e1e94bffe59257272b74914fd83256633236fd02da3b1ae99efd240ea8a6e645c93880e07d3a7af
7
+ data.tar.gz: 81986848eec310e3c2360ceecfbe894a58fc5836adc1a5a6832c90a2c60abbc72898521c6a7b44cf9a1aa118c0837aea861d2e919aeb4fd33b5d83c251e6b22d
data/README.md CHANGED
@@ -349,3 +349,9 @@ end
349
349
  ## License
350
350
 
351
351
  MIT
352
+
353
+ ## Database support
354
+
355
+ 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.
356
+
357
+ 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
@@ -15,6 +15,7 @@ module LlmLogs
15
15
  end
16
16
 
17
17
  def call
18
+ LlmLogs::Batch.require_supported_adapter!
18
19
  batch = claim_batch
19
20
  return nil if batch.nil?
20
21
 
@@ -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,7 +61,7 @@ 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
@@ -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(", ")
@@ -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
@@ -1,3 +1,3 @@
1
1
  module LlmLogs
2
- VERSION = "0.4.1"
2
+ VERSION = "0.5.1"
3
3
  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.1
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Anton
@@ -261,7 +261,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
261
261
  - !ruby/object:Gem::Version
262
262
  version: '0'
263
263
  requirements: []
264
- rubygems_version: 3.7.2
264
+ rubygems_version: 4.0.20
265
265
  specification_version: 4
266
266
  summary: Rails engine for LLM logging and prompt management
267
267
  test_files: []