llm_logs 0.5.1 → 0.7.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 +19 -16
- data/app/models/llm_logs/batch/submitter.rb +8 -7
- data/app/models/llm_logs/batch/trace_recorder.rb +19 -7
- data/app/models/llm_logs/batch.rb +3 -27
- data/app/models/llm_logs/span.rb +15 -3
- data/lib/llm_logs/configuration.rb +1 -2
- data/lib/llm_logs/engine.rb +1 -1
- data/lib/llm_logs/instrumentation/ruby_llm_chat.rb +190 -82
- data/lib/llm_logs/version.rb +1 -1
- data/lib/llm_logs.rb +1 -5
- metadata +18 -14
- data/app/models/llm_logs/batch/adapters/openai_responses.rb +0 -63
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5d03f4e8b8e5607855655d0d756f0c906395e42e808d3bfe255ced558a639d5e
|
|
4
|
+
data.tar.gz: bb172cb33c5d864ec6dde523cfe64e14c9fe4783a0a4b9c3e856dc58717b0cb3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 904aefd977972ab4bb67534972656d9abd55b3d4120c2ed99f961b661b5dfc9c1d2f7861d1afe9b74b8b6f175d86d9a5315ac2a468488ae58f23e737ef8d7c4c
|
|
7
|
+
data.tar.gz: 3e8e0f902575f2e2cbaf656965267343bf2124f781de41c036fe426d1bb6382551bbaede74f5f794ab52cdcaedba858c8adc6452554728ffbcf28786ccd0f53a
|
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
|
-
|
|
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: "
|
|
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:
|
|
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
|
|
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
|
|
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,20 +322,31 @@ 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 #
|
|
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
|
+
### Bedrock fixes in ruby_llm
|
|
339
|
+
|
|
340
|
+
llm_logs no longer patches ruby_llm. The Bedrock bugs it used to patch are fixed in ruby_llm 2.1: [crmne/ruby_llm#1024](https://github.com/crmne/ruby_llm/pull/1024) (re-sign each attempt, retry TLS errors), [#1025](https://github.com/crmne/ruby_llm/pull/1025) (`reasoning_config` for GPT, adaptive thinking for adaptive-only Claude) and [ab4a4f06](https://github.com/crmne/ruby_llm/commit/ab4a4f060fc4760053105b92500c042c52c3d968) (reasoning replays only to the model that produced it). llm_logs 0.7.0 requires ruby_llm 2.1 or later. Two behaviours differ from the old patches:
|
|
341
|
+
|
|
342
|
+
- GPT effort goes out as `reasoning_config` only when the model's registry entry publishes that schema. ruby_llm's registry does for the GPT-6 and GPT-5.6 ids; a catalog entry of your own for a GPT id needs `metadata.converse.additionalRequestFieldsSchema`, or the effort goes out as `reasoning_effort`, which Bedrock rejects.
|
|
343
|
+
- Reasoning replays only to the exact model that produced it, not to any model of the same family.
|
|
344
|
+
|
|
343
345
|
## Requirements
|
|
344
346
|
|
|
345
347
|
- Rails 8.0+
|
|
346
348
|
- Ruby 3.3+
|
|
349
|
+
- ruby_llm 2.1+
|
|
347
350
|
- PostgreSQL
|
|
348
351
|
|
|
349
352
|
## License
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
module LlmLogs
|
|
2
2
|
class Batch
|
|
3
|
-
# Groups pending BatchRequests of one purpose+model into a single
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
# flips them out of the `pending` scope and which PollJob ignores
|
|
8
|
-
#
|
|
9
|
-
# return to `pending`) and the placeholder batch is dropped, so the work retries
|
|
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
|
|
@@ -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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
(
|
|
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
|
|
@@ -66,14 +66,10 @@ module LlmLogs
|
|
|
66
66
|
!batch_provider_for(model).nil?
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
-
# Which batch provider (if any) serves this model
|
|
70
|
-
#
|
|
71
|
-
# 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).
|
|
72
71
|
def self.batch_provider_for(model)
|
|
73
|
-
|
|
74
|
-
return :openai_responses if openai_serves?(model)
|
|
75
|
-
|
|
76
|
-
nil
|
|
72
|
+
bedrock_serves?(model) ? :bedrock : nil
|
|
77
73
|
end
|
|
78
74
|
|
|
79
75
|
# The Bedrock minimum records-per-job floor for this model (0 when Bedrock does not serve it).
|
|
@@ -89,25 +85,5 @@ module LlmLogs
|
|
|
89
85
|
matcher = config.model_matcher
|
|
90
86
|
matcher.respond_to?(:call) ? matcher.call(model.to_s) : matcher.match?(model.to_s)
|
|
91
87
|
end
|
|
92
|
-
|
|
93
|
-
def self.openai_serves?(model)
|
|
94
|
-
return false unless defined?(RubyLLM::Providers::OpenAIResponses)
|
|
95
|
-
|
|
96
|
-
servable_by_batch_provider?(model)
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
# The batch path submits via RubyLLM.batch(provider: batch_provider). A model is
|
|
100
|
-
# only batchable if that provider can actually serve it -- i.e. the model resolves
|
|
101
|
-
# under batch_provider. Models that belong to a different provider (e.g. Bedrock /
|
|
102
|
-
# Anthropic) don't resolve there, so they return false and the caller runs them
|
|
103
|
-
# synchronously instead of enqueueing work that would fail at submit time.
|
|
104
|
-
def self.servable_by_batch_provider?(model)
|
|
105
|
-
RubyLLM::Models.resolve(
|
|
106
|
-
model, provider: LlmLogs.batch_provider, assume_exists: false, config: RubyLLM.config
|
|
107
|
-
)
|
|
108
|
-
true
|
|
109
|
-
rescue RubyLLM::ModelNotFoundError
|
|
110
|
-
false
|
|
111
|
-
end
|
|
112
88
|
end
|
|
113
89
|
end
|
data/app/models/llm_logs/span.rb
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
@@ -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, :
|
|
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
|
data/lib/llm_logs/engine.rb
CHANGED
|
@@ -6,7 +6,7 @@ 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
|
-
|
|
9
|
+
LlmLogs::Instrumentation::RubyLlmChat.install!
|
|
10
10
|
end
|
|
11
11
|
end
|
|
12
12
|
end
|
|
@@ -1,105 +1,213 @@
|
|
|
1
1
|
module LlmLogs
|
|
2
2
|
module Instrumentation
|
|
3
|
+
# Records RubyLLM 2.x chat activity as llm_logs spans by subscribing to the
|
|
4
|
+
# events RubyLLM emits for observability adapters (docs/_advanced/instrumentation.md):
|
|
5
|
+
#
|
|
6
|
+
# chat.ruby_llm -- one per model request (RubyLLM::Chat#generate_once): an "llm" span
|
|
7
|
+
# per provider round. Wraps every transport retry of that round, so a
|
|
8
|
+
# round that fails after its retries surfaces as :exception_object.
|
|
9
|
+
# With fallbacks, each model tried gets its own span.
|
|
10
|
+
# tool_call.ruby_llm -- one per local tool execution (RubyLLM::Chat#execute_tool): a "tool" span.
|
|
11
|
+
#
|
|
12
|
+
# Rounds and tools are siblings under the current span/trace:
|
|
13
|
+
# trace -> llm (round 1, asks for a tool) -> tool.x -> llm (round 2, final answer)
|
|
14
|
+
#
|
|
15
|
+
# The subscription is global (every RubyLLM::Chat, including acts_as_chat's to_llm and
|
|
16
|
+
# RubyLLM::Agent), but only fires while RubyLLM's instrumenter is ActiveSupport::Notifications,
|
|
17
|
+
# which RubyLLM's Railtie sets by default.
|
|
3
18
|
module RubyLlmChat
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
begin
|
|
18
|
-
result = super(&block)
|
|
19
|
-
span.record_response(result)
|
|
20
|
-
span.cost = llm_logs_compute_cost(result)
|
|
21
|
-
result
|
|
22
|
-
rescue => e
|
|
23
|
-
span.record_error(e)
|
|
24
|
-
llm_logs_capture_partial_tokens(span, messages_before)
|
|
25
|
-
raise
|
|
26
|
-
ensure
|
|
27
|
-
span.finish
|
|
19
|
+
SPAN_KEY = :llm_logs_span
|
|
20
|
+
USAGE_START_KEY = :llm_logs_usage_start
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def install!
|
|
24
|
+
return if installed?
|
|
25
|
+
|
|
26
|
+
RubyLLM.config.instrumenter ||= ActiveSupport::Notifications
|
|
27
|
+
@subscribers = [
|
|
28
|
+
ActiveSupport::Notifications.subscribe("chat.ruby_llm", ChatSubscriber.new),
|
|
29
|
+
ActiveSupport::Notifications.subscribe("tool_call.ruby_llm", ToolSubscriber.new)
|
|
30
|
+
]
|
|
28
31
|
end
|
|
29
|
-
end
|
|
30
32
|
|
|
31
|
-
|
|
33
|
+
def uninstall!
|
|
34
|
+
Array(@subscribers).each { |subscriber| ActiveSupport::Notifications.unsubscribe(subscriber) }
|
|
35
|
+
@subscribers = nil
|
|
36
|
+
end
|
|
32
37
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
38
|
+
def installed?
|
|
39
|
+
!@subscribers.nil?
|
|
40
|
+
end
|
|
36
41
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
# Instrumentation must never break the LLM call it observes. ActiveSupport re-raises
|
|
43
|
+
# subscriber exceptions into the instrumented block, so every hook rescues.
|
|
44
|
+
def guard
|
|
45
|
+
yield
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
Rails.logger&.error("[llm_logs] instrumentation error: #{e.class}: #{e.message}")
|
|
48
|
+
nil
|
|
44
49
|
end
|
|
45
50
|
end
|
|
46
51
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
# Serializers shared by both subscribers.
|
|
53
|
+
module Serialize
|
|
54
|
+
module_function
|
|
50
55
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
def messages(list)
|
|
57
|
+
Array(list).map { |message| message_entry(message) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def message_entry(message)
|
|
61
|
+
entry = {role: message.role, content: message.content}
|
|
62
|
+
calls = tool_calls(message.tool_calls)
|
|
63
|
+
entry[:tool_calls] = calls if calls
|
|
64
|
+
entry[:tool_call_id] = message.tool_call_id if message.tool_call_id
|
|
65
|
+
entry
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def tool_calls(calls)
|
|
69
|
+
return nil if calls.nil? || calls.empty?
|
|
70
|
+
|
|
71
|
+
calls.values.map { |call| {id: call.id, name: call.name, arguments: call.arguments} }
|
|
72
|
+
end
|
|
58
73
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
74
|
+
# Structured (schema) answers are JSON text in 2.x; store the parsed Hash so the
|
|
75
|
+
# UI renders nested fields instead of an escaped string.
|
|
76
|
+
def response(message, schema:)
|
|
77
|
+
content = message.content
|
|
78
|
+
content = parse_json(content) if schema && content.is_a?(String) && !content.empty?
|
|
79
|
+
output = {content: content}
|
|
80
|
+
calls = tool_calls(message.tool_calls)
|
|
81
|
+
output[:tool_calls] = calls if calls
|
|
82
|
+
output[:thinking] = message.thinking.text if message.thinking&.text.present?
|
|
83
|
+
output
|
|
84
|
+
end
|
|
62
85
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
86
|
+
def parse_json(text)
|
|
87
|
+
JSON.parse(text)
|
|
88
|
+
rescue JSON::ParserError
|
|
89
|
+
text
|
|
90
|
+
end
|
|
66
91
|
|
|
67
|
-
(
|
|
92
|
+
def tool_result(result)
|
|
93
|
+
case result
|
|
94
|
+
when Hash then result
|
|
95
|
+
when Array then {result: result}
|
|
96
|
+
else {result: result.to_s}
|
|
97
|
+
end
|
|
98
|
+
end
|
|
68
99
|
end
|
|
69
100
|
|
|
70
|
-
|
|
71
|
-
|
|
101
|
+
class ChatSubscriber
|
|
102
|
+
def start(_name, _id, payload)
|
|
103
|
+
RubyLlmChat.guard do
|
|
104
|
+
next unless LlmLogs.enabled?
|
|
105
|
+
|
|
106
|
+
chat = payload[:chat]
|
|
107
|
+
payload[USAGE_START_KEY] = chat.usage_entries.length if chat.respond_to?(:usage_entries)
|
|
108
|
+
payload[SPAN_KEY] = LlmLogs::Tracer.start_span(
|
|
109
|
+
name: "chat.complete",
|
|
110
|
+
span_type: "llm",
|
|
111
|
+
model: payload[:model],
|
|
112
|
+
provider: payload[:provider],
|
|
113
|
+
input: Serialize.messages(payload[:input_messages]),
|
|
114
|
+
metadata: request_metadata(payload)
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
72
118
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
119
|
+
def finish(_name, _id, payload)
|
|
120
|
+
span = payload.delete(SPAN_KEY)
|
|
121
|
+
return unless span
|
|
122
|
+
|
|
123
|
+
RubyLlmChat.guard do
|
|
124
|
+
if (error = payload[:exception_object])
|
|
125
|
+
span.record_error(error)
|
|
126
|
+
else
|
|
127
|
+
span.output = Serialize.response(payload[:response], schema: payload[:schema])
|
|
128
|
+
span.set_attribute("finish_reason", payload[:response].finish_reason&.to_s)
|
|
129
|
+
response_model = payload[:response_model]
|
|
130
|
+
span.set_attribute("response_model", response_model) if response_model && response_model != payload[:model]
|
|
131
|
+
end
|
|
132
|
+
record_usage(span, payload)
|
|
133
|
+
end
|
|
134
|
+
ensure
|
|
135
|
+
RubyLlmChat.guard { span&.finish }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def request_metadata(payload)
|
|
141
|
+
{
|
|
142
|
+
"streaming" => payload[:streaming],
|
|
143
|
+
"tools" => Array(payload[:tools]).map(&:to_s),
|
|
144
|
+
"temperature" => payload[:temperature],
|
|
145
|
+
"thinking" => thinking_metadata(payload[:thinking]),
|
|
146
|
+
"schema" => payload[:schema] && payload[:schema][:name]
|
|
147
|
+
}.compact
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def thinking_metadata(thinking)
|
|
151
|
+
return nil unless thinking
|
|
152
|
+
|
|
153
|
+
{"effort" => thinking.effort&.to_s, "budget" => thinking.budget, "enabled" => thinking.enabled}.compact.presence
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Tokens and cost of every transport attempt this round made (retries included), read
|
|
157
|
+
# from the chat's usage ledger. Falls back to the event's tokens/cost when the ledger is
|
|
158
|
+
# unavailable. A failed attempt whose usage is unknown (e.g. a timeout that may have
|
|
159
|
+
# been billed) is left out of the cost and flagged with cost_complete: false.
|
|
160
|
+
def record_usage(span, payload)
|
|
161
|
+
entries = round_usage_entries(payload)
|
|
162
|
+
if entries
|
|
163
|
+
tokens = RubyLLM::Tokens.aggregate(entries.map(&:tokens))
|
|
164
|
+
cost = RubyLLM::Cost.aggregate(entries.map(&:cost))
|
|
165
|
+
span.set_attribute("attempts", entries.size) if entries.size > 1
|
|
166
|
+
span.set_attribute("cost_complete", false) unless entries.all?(&:cost_available?)
|
|
167
|
+
else
|
|
168
|
+
tokens = payload[:tokens]
|
|
169
|
+
cost = payload[:cost]
|
|
170
|
+
end
|
|
171
|
+
span.record_tokens(tokens)
|
|
172
|
+
span.cost = cost&.total
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def round_usage_entries(payload)
|
|
176
|
+
start = payload[USAGE_START_KEY]
|
|
177
|
+
chat = payload[:chat]
|
|
178
|
+
return nil unless start && chat.respond_to?(:usage_entries)
|
|
179
|
+
|
|
180
|
+
chat.usage_entries.drop(start)
|
|
181
|
+
end
|
|
76
182
|
end
|
|
77
183
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
span
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
184
|
+
class ToolSubscriber
|
|
185
|
+
def start(_name, _id, payload)
|
|
186
|
+
RubyLlmChat.guard do
|
|
187
|
+
next unless LlmLogs.enabled?
|
|
188
|
+
|
|
189
|
+
payload[SPAN_KEY] = LlmLogs::Tracer.start_span(
|
|
190
|
+
name: "tool.#{payload[:tool_name]}",
|
|
191
|
+
span_type: "tool",
|
|
192
|
+
input: payload[:tool_arguments],
|
|
193
|
+
metadata: {tool_name: payload[:tool_name].to_s, tool_call_id: payload[:tool_call_id]}.compact
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def finish(_name, _id, payload)
|
|
199
|
+
span = payload.delete(SPAN_KEY)
|
|
200
|
+
return unless span
|
|
201
|
+
|
|
202
|
+
RubyLlmChat.guard do
|
|
203
|
+
if (error = payload[:exception_object])
|
|
204
|
+
span.record_error(error)
|
|
205
|
+
else
|
|
206
|
+
span.output = Serialize.tool_result(payload[:result])
|
|
207
|
+
end
|
|
208
|
+
end
|
|
101
209
|
ensure
|
|
102
|
-
span
|
|
210
|
+
RubyLlmChat.guard { span&.finish }
|
|
103
211
|
end
|
|
104
212
|
end
|
|
105
213
|
end
|
data/lib/llm_logs/version.rb
CHANGED
data/lib/llm_logs.rb
CHANGED
|
@@ -54,10 +54,6 @@ module LlmLogs
|
|
|
54
54
|
configuration.batch_enabled
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
-
def self.batch_provider
|
|
58
|
-
configuration.batch_provider
|
|
59
|
-
end
|
|
60
|
-
|
|
61
57
|
def self.bedrock_batch
|
|
62
58
|
configuration.bedrock_batch
|
|
63
59
|
end
|
|
@@ -71,7 +67,7 @@ module LlmLogs
|
|
|
71
67
|
end
|
|
72
68
|
|
|
73
69
|
def self.batch_adapters
|
|
74
|
-
@batch_adapters ||= {
|
|
70
|
+
@batch_adapters ||= {}
|
|
75
71
|
end
|
|
76
72
|
|
|
77
73
|
def self.register_batch_adapter(provider, adapter)
|
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.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Anton
|
|
@@ -97,30 +97,36 @@ dependencies:
|
|
|
97
97
|
name: ruby_llm
|
|
98
98
|
requirement: !ruby/object:Gem::Requirement
|
|
99
99
|
requirements:
|
|
100
|
-
- - "
|
|
100
|
+
- - ">="
|
|
101
101
|
- !ruby/object:Gem::Version
|
|
102
|
-
version: '1
|
|
103
|
-
|
|
102
|
+
version: '2.1'
|
|
103
|
+
- - "<"
|
|
104
|
+
- !ruby/object:Gem::Version
|
|
105
|
+
version: '3'
|
|
106
|
+
type: :runtime
|
|
104
107
|
prerelease: false
|
|
105
108
|
version_requirements: !ruby/object:Gem::Requirement
|
|
106
109
|
requirements:
|
|
107
|
-
- - "
|
|
110
|
+
- - ">="
|
|
111
|
+
- !ruby/object:Gem::Version
|
|
112
|
+
version: '2.1'
|
|
113
|
+
- - "<"
|
|
108
114
|
- !ruby/object:Gem::Version
|
|
109
|
-
version: '
|
|
115
|
+
version: '3'
|
|
110
116
|
- !ruby/object:Gem::Dependency
|
|
111
|
-
name:
|
|
117
|
+
name: aws-eventstream
|
|
112
118
|
requirement: !ruby/object:Gem::Requirement
|
|
113
119
|
requirements:
|
|
114
|
-
- - "
|
|
120
|
+
- - ">="
|
|
115
121
|
- !ruby/object:Gem::Version
|
|
116
|
-
version: '0
|
|
122
|
+
version: '0'
|
|
117
123
|
type: :development
|
|
118
124
|
prerelease: false
|
|
119
125
|
version_requirements: !ruby/object:Gem::Requirement
|
|
120
126
|
requirements:
|
|
121
|
-
- - "
|
|
127
|
+
- - ">="
|
|
122
128
|
- !ruby/object:Gem::Version
|
|
123
|
-
version: '0
|
|
129
|
+
version: '0'
|
|
124
130
|
- !ruby/object:Gem::Dependency
|
|
125
131
|
name: webmock
|
|
126
132
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -186,10 +192,8 @@ files:
|
|
|
186
192
|
- app/models/llm_logs/application_record.rb
|
|
187
193
|
- app/models/llm_logs/batch.rb
|
|
188
194
|
- app/models/llm_logs/batch/adapters/bedrock.rb
|
|
189
|
-
- app/models/llm_logs/batch/adapters/openai_responses.rb
|
|
190
195
|
- app/models/llm_logs/batch/handler_registry.rb
|
|
191
196
|
- app/models/llm_logs/batch/reconciler.rb
|
|
192
|
-
- app/models/llm_logs/batch/schema_format.rb
|
|
193
197
|
- app/models/llm_logs/batch/submitter.rb
|
|
194
198
|
- app/models/llm_logs/batch/trace_recorder.rb
|
|
195
199
|
- app/models/llm_logs/batch_request.rb
|
|
@@ -261,7 +265,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
261
265
|
- !ruby/object:Gem::Version
|
|
262
266
|
version: '0'
|
|
263
267
|
requirements: []
|
|
264
|
-
rubygems_version:
|
|
268
|
+
rubygems_version: 3.7.2
|
|
265
269
|
specification_version: 4
|
|
266
270
|
summary: Rails engine for LLM logging and prompt management
|
|
267
271
|
test_files: []
|
|
@@ -1,63 +0,0 @@
|
|
|
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
|
-
**reasoning_extra(payload["reasoning_effort"]),
|
|
19
|
-
**schema_extra(payload["schema"])
|
|
20
|
-
)
|
|
21
|
-
end
|
|
22
|
-
rubyllm_batch.create!
|
|
23
|
-
{provider_batch_id: rubyllm_batch.id, openai_batch_id: rubyllm_batch.id, provider_metadata: {}}
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def terminal_status(batch)
|
|
27
|
-
resume(batch).status
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def results(batch)
|
|
31
|
-
resume(batch).results
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def error_ids(batch)
|
|
35
|
-
resume(batch).errors.filter_map { |e| e["custom_id"] }
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
private
|
|
39
|
-
|
|
40
|
-
# The Responses API nests effort under `reasoning:`; `reasoning_effort` is the
|
|
41
|
-
# Chat Completions spelling and is silently ignored here.
|
|
42
|
-
def reasoning_extra(effort)
|
|
43
|
-
return {} if effort.blank?
|
|
44
|
-
|
|
45
|
-
{reasoning: {effort: effort}}
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
# No memoization: LlmLogs.batch_adapters holds one shared instance of this adapter
|
|
49
|
-
# for the life of the process, so caching the resumed handle here would go stale
|
|
50
|
-
# for a long-running PollJob worker (in-progress batches would never re-fetch a
|
|
51
|
-
# later terminal status) and would grow unbounded. Build a fresh handle every call.
|
|
52
|
-
def resume(batch)
|
|
53
|
-
RubyLLM.batch(id: batch.provider_batch_id, provider: PROVIDER)
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def schema_extra(schema)
|
|
57
|
-
format = SchemaFormat.call(schema)
|
|
58
|
-
format ? {text: format} : {}
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
end
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
module LlmLogs
|
|
2
|
-
class Batch
|
|
3
|
-
# Translates a {name:, schema:, strict:} schema (the shape RubyLLM::Chat#with_schema
|
|
4
|
-
# produces) into the OpenAI Responses API `text.format` block. The batch path builds
|
|
5
|
-
# request bodies directly via RubyLLM.batch#add(**extra), bypassing with_schema, so
|
|
6
|
-
# we must hand the json_schema block in ourselves.
|
|
7
|
-
module SchemaFormat
|
|
8
|
-
module_function
|
|
9
|
-
|
|
10
|
-
def call(schema)
|
|
11
|
-
return nil if schema.nil?
|
|
12
|
-
|
|
13
|
-
schema = schema.symbolize_keys if schema.respond_to?(:symbolize_keys)
|
|
14
|
-
{
|
|
15
|
-
format: {
|
|
16
|
-
type: "json_schema",
|
|
17
|
-
name: schema[:name] || "response",
|
|
18
|
-
schema: schema[:schema] || schema,
|
|
19
|
-
strict: schema.key?(:strict) ? schema[:strict] : true
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
end
|