lex-llm-ledger 0.6.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 70deb6cfb3814da49ebbc893a2025e06ddcf96ee7789a65f794fa66910502f41
4
- data.tar.gz: e0837642064bf0c1ee6bf480704d297f961144f5f6cfb0814b88d8de438bf0f6
3
+ metadata.gz: 7b0e51e42bfec73669b2065b292a856ba029708010d10fc038d086a3442207e5
4
+ data.tar.gz: dbfbc4bf7b969362ff0363096fac13d33e8484080eddbccb09051fe3b794c0ff
5
5
  SHA512:
6
- metadata.gz: '024169e420f778950b01bd074b03b1e63ccc642cb6c0148a14046dc91a74426c8a4d202a70fb33b6a669fbe3248fba62940e72a3a63ba1d776cef0044e0b9e36'
7
- data.tar.gz: 207e66a936b63bf8c52437d2dc1d92931afdcb12caea03976b4e293bf8e67d43f963e62e9ba336c1e80789b3d60ad960b56eb6b85081f3146509f84c760aaf6f
6
+ metadata.gz: 18e7a33c0c64c8cb6ebc6ef09c21b174ac2a73ef0643947029e2898ec59bf523e5e0d8c157672908755147b136c98b87117be1f7d1e701d6542862fd9b58e4b3
7
+ data.tar.gz: f9e869ab4703524b7b054d1c1a44c8ab4d2fd1b53a770aa48f471f69c70f124ad4e4b4a90ecd1ca5399e43817394cdbc05248b00108cdca4ee45b494b8c5d4c9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.0] - 2026-06-09
4
+
5
+ ### Fixed
6
+ - **ASYNC-RACE-01**: Resolved race condition between metering and prompt audit messages causing duplicate response rows and stale `"{}"`/`"null"` JSON placeholders. Added response UUID fallback to `message_inference_request_id` lookup to enrich metering-created shells instead of creating duplicates.
7
+ - **RECONCILIATION-01**: Fixed `UniqueConstraintViolation` in `link_orphaned_tool_calls` by recalculating `tool_call_index` based on existing max index before linking, preventing index collisions.
8
+ - **ENRICHMENT-01**: Implemented strict upsert guards (`upsert_guard?`) to prevent overwriting valid data with `nil`, `''`, or `'{}'`. Centralized validation across `update_if_missing` and `update_if_placeholder`.
9
+ - **PAYLOAD-01**: Updated `request_payload`, `visible_response`, and `thinking_response` to return `nil` instead of `{}` when content is missing. Insert paths now write `nil` to JSON columns, preventing `"{}"` or `"null"` string artifacts.
10
+ - **ENRICHMENT-02**: Expanded `update_if_placeholder` to recognize both `'{}'` and `'null'` as stale markers for retroactive cleanup. Guarded `response_content(body)` against `nil` returns.
11
+
3
12
  ## [0.6.0] - 2026-05-31
4
13
 
5
14
  ### Added
@@ -49,7 +49,7 @@ module Legion
49
49
  Helpers::SubscriptionMessage.runner_args(payload, metadata, message)
50
50
  end
51
51
 
52
- def build_escalation_record(db, body, props, headers) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
52
+ def build_escalation_record(db, body, props, headers)
53
53
  history = Array(body[:history])
54
54
  identity = Helpers::CallerIdentity.normalize(
55
55
  caller_raw: body[:caller], identity: body[:identity], headers: headers
@@ -28,8 +28,16 @@ module Legion
28
28
  response = find_response_for_tool_call(db, tool_call)
29
29
  next unless response
30
30
 
31
+ # Recalculate tool_call_index to avoid unique constraint collisions.
32
+ # Orphaned tool calls were inserted with index 0 (no response known),
33
+ # but the response may already have tool calls at those indices.
34
+ next_index = db[:llm_tool_calls]
35
+ .where(message_inference_response_id: response[:id])
36
+ .max(:tool_call_index).to_i + 1
37
+
31
38
  db[:llm_tool_calls].where(id: tool_call[:id]).update(
32
- message_inference_response_id: response[:id]
39
+ message_inference_response_id: response[:id],
40
+ tool_call_index: next_index
33
41
  )
34
42
  linked += 1
35
43
  end
@@ -101,7 +101,7 @@ module Legion
101
101
  conv&.[](:id)
102
102
  end
103
103
 
104
- def find_or_create_tool_call(db, response, body, ctx, tool, headers, identity_attrs, conversation_id) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
104
+ def find_or_create_tool_call(db, response, body, ctx, tool, headers, identity_attrs, conversation_id)
105
105
  tool_uuid = derive_tool_call_uuid(body, ctx, tool, headers)
106
106
  existing = db[:llm_tool_calls].where(uuid: tool_uuid).first
107
107
  return [existing, false] if existing # rubocop:disable Legion/Extension/RunnerReturnHash
@@ -152,7 +152,7 @@ module Legion
152
152
  [row, false]
153
153
  end
154
154
 
155
- def find_or_create_tool_call_attempt(db, tool_call_row, tool, body, props, headers, identity_attrs) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
155
+ def find_or_create_tool_call_attempt(db, tool_call_row, tool, body, props, headers, identity_attrs)
156
156
  return nil unless tool_call_row # rubocop:disable Legion/Extension/RunnerReturnHash
157
157
 
158
158
  tool_call_id = tool_call_row[:id]
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Llm
6
6
  module Ledger
7
- VERSION = '0.6.0'
7
+ VERSION = '0.7.0'
8
8
  end
9
9
  end
10
10
  end
@@ -112,7 +112,7 @@ module Legion
112
112
  end
113
113
  end
114
114
 
115
- def find_or_create_request(db, conversation, latest_message, body) # rubocop:disable Metrics/AbcSize
115
+ def find_or_create_request(db, conversation, latest_message, body)
116
116
  request_id = request_ref(body)
117
117
  existing = db[:llm_message_inference_requests].where(request_ref: request_id).first
118
118
  return enrich_request!(db, existing, body, latest_message) if existing
@@ -140,7 +140,10 @@ module Legion
140
140
  status: 'responded',
141
141
  context_message_count: Array(body.dig(:request, :messages) || body[:messages]).size,
142
142
  request_capture_mode: 'full',
143
- request_json: phi_protect(json_dump(request_payload(body)), contains_phi?(body)),
143
+ request_json: if request_payload(body)
144
+ phi_protect(json_dump(request_payload(body)),
145
+ contains_phi?(body))
146
+ end,
144
147
  classification_level: classification_level(body),
145
148
  cost_center: billing(body)[:cost_center],
146
149
  budget_key: billing(body)[:budget_id] || billing(body)[:budget_key],
@@ -194,15 +197,29 @@ module Legion
194
197
  end
195
198
  end
196
199
 
197
- def find_or_create_response(db, request, response_message, body) # rubocop:disable Metrics/AbcSize
200
+ def find_or_create_response(db, request, response_message, body)
198
201
  response_uuid = stable_uuid(reference(body, :provider_response_ref) || "response:#{request_ref(body)}:#{body[:provider] || 'unknown'}")
199
202
  existing = db[:llm_message_inference_responses].where(uuid: response_uuid).first
203
+
204
+ # Fallback: if we couldn't find a response by UUID, check if a response
205
+ # already exists for this request (e.g., metering arrived first and created
206
+ # a response with a different UUID). Enrich it instead of creating a duplicate.
207
+ unless existing
208
+ existing = db[:llm_message_inference_responses]
209
+ .where(message_inference_request_id: request[:id])
210
+ .first
211
+ log.debug("[ledger] response fallback: found existing response id=#{existing[:id]} for request_id=#{request[:id]}") if existing
212
+ end
213
+
200
214
  if existing
201
215
  enrich_response!(db, existing, response_message, body)
202
216
  return existing
203
217
  end
204
218
 
219
+ vis_resp = visible_response(body)
220
+ think_resp = thinking_response(body)
205
221
  phi = contains_phi?(body)
222
+
206
223
  id = insert_with_savepoint(db, :llm_message_inference_responses, {
207
224
  uuid: response_uuid,
208
225
  message_inference_request_id: request[:id],
@@ -218,8 +235,8 @@ module Legion
218
235
  latency_ms: integer(body[:latency_ms]),
219
236
  wall_clock_ms: integer(body[:wall_clock_ms]),
220
237
  response_capture_mode: 'full',
221
- response_json: phi_protect(json_dump(visible_response(body)), phi),
222
- response_thinking_json: phi_protect(json_dump(thinking_response(body)), phi),
238
+ response_json: vis_resp ? phi_protect(json_dump(vis_resp), phi) : nil,
239
+ response_thinking_json: think_resp ? phi_protect(json_dump(think_resp), phi) : nil,
223
240
  dispatch_path: body[:dispatch_path] || body[:tier],
224
241
  error_category: body[:error_category] || body.dig(:error, :category),
225
242
  error_code: body[:error_code] || body.dig(:error, :code),
@@ -254,11 +271,21 @@ module Legion
254
271
  update_if_missing(updates, existing, :dispatch_path, body[:dispatch_path] || body[:tier])
255
272
  update_if_missing(updates, existing, :identity_canonical_name, identity_canonical_name(body))
256
273
 
257
- response_json = json_dump(visible_response(body))
258
- update_if_placeholder(updates, existing, :response_json, response_json)
274
+ vis = visible_response(body)
275
+ if vis
276
+ response_json = json_dump(vis)
277
+ update_if_placeholder(updates, existing, :response_json, response_json)
278
+ elsif existing[:response_json].nil?
279
+ # Nothing to add, but also don't overwrite existing data with nil
280
+ end
259
281
 
260
- thinking_json = json_dump(thinking_response(body))
261
- update_if_placeholder(updates, existing, :response_thinking_json, thinking_json)
282
+ think = thinking_response(body)
283
+ if think
284
+ thinking_json = json_dump(think)
285
+ update_if_placeholder(updates, existing, :response_thinking_json, thinking_json)
286
+ elsif existing[:response_thinking_json].nil?
287
+ # Nothing to add
288
+ end
262
289
 
263
290
  return if updates.empty?
264
291
 
@@ -266,12 +293,27 @@ module Legion
266
293
  log.info("[ledger] enriched response id=#{existing[:id]} fields=#{updates.keys.join(',')}")
267
294
  end
268
295
 
296
+ # Core guard: never upsert a value that is nil, empty string, or empty JSON object.
297
+ # This prevents a leaner message (e.g., metering) from overwriting valid data
298
+ # written by a richer message (e.g., prompt audit).
299
+ def upsert_guard?(value)
300
+ return false if value.nil?
301
+ return false if value.is_a?(String) && value.strip.empty?
302
+ return false if value.to_s == '{}'
303
+
304
+ true
305
+ end
306
+
269
307
  def update_if_missing(updates, existing, key, value)
270
- updates[key] = value if existing[key].nil? && present?(value)
308
+ updates[key] = value if existing[key].nil? && upsert_guard?(value)
271
309
  end
272
310
 
273
311
  def update_if_placeholder(updates, existing, key, value)
274
- updates[key] = value if existing[key].to_s == '{}' && value != '{}'
312
+ return unless upsert_guard?(value)
313
+
314
+ existing_val = existing[key]
315
+ is_placeholder = ['{}', 'null'].include?(existing_val.to_s)
316
+ updates[key] = value if is_placeholder
275
317
  end
276
318
 
277
319
  def find_or_create_metric(db, request, response, body)
@@ -339,15 +381,19 @@ module Legion
339
381
  updates = {}
340
382
  update_if_missing(updates, existing, :latest_message_id, latest_message&.dig(:id))
341
383
  caller_refs = caller_identity_refs(db, body)
342
- updates[:caller_identity_id] = caller_refs[:identity_id] if existing[:caller_identity_id].nil? && caller_refs[:identity_id]
343
- updates[:caller_principal_id] = caller_refs[:principal_id] if existing[:caller_principal_id].nil? && caller_refs[:principal_id]
344
- updates[:runtime_caller_type] = caller_type(body) if existing[:runtime_caller_type].nil? && caller_type(body)
384
+ update_if_missing(updates, existing, :caller_identity_id, caller_refs[:identity_id])
385
+ update_if_missing(updates, existing, :caller_principal_id, caller_refs[:principal_id])
386
+ update_if_missing(updates, existing, :runtime_caller_type, caller_type(body))
345
387
  update_if_missing(updates, existing, :runtime_caller_class, runtime_caller_class(body))
346
388
  update_if_missing(updates, existing, :runtime_caller_client, runtime_caller_client(body))
347
389
  update_if_missing(updates, existing, :identity_canonical_name, identity_canonical_name(body))
348
390
 
349
- request_json = json_dump(request_payload(body))
350
- updates[:request_json] = request_json if existing[:request_json].to_s == '{}' && request_json != '{}'
391
+ request_json = request_payload(body) ? json_dump(request_payload(body)) : nil
392
+ if request_json
393
+ update_if_placeholder(updates, existing, :request_json, request_json)
394
+ elsif existing[:request_json].nil?
395
+ # Nothing to add
396
+ end
351
397
 
352
398
  msg_count = Array(body.dig(:request, :messages) || body[:messages]).size
353
399
  updates[:context_message_count] = msg_count if existing[:context_message_count].to_i.zero? && msg_count.positive?
@@ -574,7 +620,7 @@ module Legion
574
620
  end
575
621
 
576
622
  def resolve_parent_request_id(db, body)
577
- parent_ref = body[:parent_request_id] || body.dig(:context, :parent_request_id)
623
+ parent_ref = body[:parent_request_id] || body.dig(:context, :parent_request_id) || body.dig(:caller, :parent_request_ref)
578
624
  return nil unless present?(parent_ref)
579
625
 
580
626
  if parent_ref.is_a?(Integer)
@@ -630,7 +676,7 @@ module Legion
630
676
  end
631
677
 
632
678
  def request_payload(body)
633
- body[:request] || body[:messages] || {}
679
+ body[:request] || body[:messages]
634
680
  end
635
681
 
636
682
  def request_content(body)
@@ -641,7 +687,9 @@ module Legion
641
687
  end
642
688
 
643
689
  def visible_response(body)
644
- response = body[:response] || body[:response_content] || body[:content] || {}
690
+ response = body[:response] || body[:response_content] || body[:content]
691
+ return nil if response.nil? || (response.is_a?(Hash) && response.empty?)
692
+
645
693
  if response.is_a?(String)
646
694
  clean, _thinking = extract_inline_thinking(response)
647
695
  return { content: clean }
@@ -661,10 +709,10 @@ module Legion
661
709
  end
662
710
 
663
711
  content_str = body[:response_content] || body[:response] || body[:content]
664
- return {} unless content_str.is_a?(String)
712
+ return nil unless content_str.is_a?(String)
665
713
 
666
714
  _clean, extracted = extract_inline_thinking(content_str)
667
- extracted ? { content: extracted } : {}
715
+ extracted ? { content: extracted } : nil
668
716
  end
669
717
 
670
718
  def extract_inline_thinking(text)
@@ -677,7 +725,10 @@ module Legion
677
725
  end
678
726
 
679
727
  def response_content(body)
680
- stringify_content(visible_response(body)[:content] || visible_response(body).dig(:message, :content))
728
+ vis = visible_response(body)
729
+ return nil unless vis
730
+
731
+ stringify_content(vis[:content] || vis.dig(:message, :content))
681
732
  end
682
733
 
683
734
  def finish_reason(body)
@@ -788,9 +839,7 @@ module Legion
788
839
  def resolve_context_tokens(body)
789
840
  raw = body[:tokens] || body[:audit] || body
790
841
  val = raw[:input_tokens] || raw[:input] || raw[:context_tokens] || raw[:prompt_tokens]
791
- return nil unless present?(val)
792
-
793
- val.to_i
842
+ present?(val) ? val.to_i : 0
794
843
  end
795
844
  end
796
845
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-llm-ledger
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity