lex-llm-ledger 0.6.0 → 0.7.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: 70deb6cfb3814da49ebbc893a2025e06ddcf96ee7789a65f794fa66910502f41
4
- data.tar.gz: e0837642064bf0c1ee6bf480704d297f961144f5f6cfb0814b88d8de438bf0f6
3
+ metadata.gz: 9dc0c64e738b5fe99d37289f48ce3264b7991f7f521d570df80a63615d419972
4
+ data.tar.gz: a551479f32400fb90df6e0e6bc15423c627d783149f9a17f675421cf3d3f9243
5
5
  SHA512:
6
- metadata.gz: '024169e420f778950b01bd074b03b1e63ccc642cb6c0148a14046dc91a74426c8a4d202a70fb33b6a669fbe3248fba62940e72a3a63ba1d776cef0044e0b9e36'
7
- data.tar.gz: 207e66a936b63bf8c52437d2dc1d92931afdcb12caea03976b4e293bf8e67d43f963e62e9ba336c1e80789b3d60ad960b56eb6b85081f3146509f84c760aaf6f
6
+ metadata.gz: 4cdf842b8a22047e92f2bd1adba9a953eefaf5bf86b255d54b6e8d22fc9f64545a45c03486348c9732539e34739e74fc43ce61c77d52772aeea93e640f0298ee
7
+ data.tar.gz: cd05c364405a72b9f1bfed2e539778fb301fa36f20e646f645755eb87e21a98261f5b9442a3c2b0938662a640f2708eb8886c0632dbe5118848e7616c48ad4c0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.1] - 2026-06-11
4
+
5
+ ### Fixed
6
+ - **IDENTITY-07**: `OfficialRouteAttemptWriter` called non-existent `CallerIdentity.resolve_principal_id` and `CallerIdentity.resolve_identity_id`. Delegated to `OfficialRecordWriter.caller_identity_refs` which already implements full identity resolution with header fallbacks and DB lookups.
7
+
8
+ ## [0.7.0] - 2026-06-09
9
+
10
+ ### Fixed
11
+ - **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.
12
+ - **RECONCILIATION-01**: Fixed `UniqueConstraintViolation` in `link_orphaned_tool_calls` by recalculating `tool_call_index` based on existing max index before linking, preventing index collisions.
13
+ - **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`.
14
+ - **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.
15
+ - **ENRICHMENT-02**: Expanded `update_if_placeholder` to recognize both `'{}'` and `'null'` as stale markers for retroactive cleanup. Guarded `response_content(body)` against `nil` returns.
16
+
3
17
  ## [0.6.0] - 2026-05-31
4
18
 
5
19
  ### 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
@@ -3,6 +3,7 @@
3
3
  require_relative '../helpers/caller_identity'
4
4
  require_relative '../helpers/json'
5
5
  require_relative '../helpers/persistence_logging'
6
+ require_relative '../writers/official_record_writer'
6
7
 
7
8
  module Legion
8
9
  module Extensions
@@ -66,6 +67,8 @@ module Legion
66
67
  worker_id: runtime[:worker_id] || runtime[:worker],
67
68
  node_id: runtime[:node_id] || runtime[:host_id],
68
69
  identity_canonical_name: extract_canonical_name(body, headers),
70
+ identity_principal_id: extract_identity_principal_id(body, headers),
71
+ identity_id: extract_identity_id(body, headers),
69
72
  offering_json: json_dump(offering),
70
73
  runtime_json: json_dump(runtime),
71
74
  capacity_json: json_dump(body[:capacity] || {}),
@@ -89,6 +92,44 @@ module Legion
89
92
  raw.to_s
90
93
  end
91
94
 
95
+ # Resolve identity_principal_id from AMQP headers or body.
96
+ # Uses OfficialRecordWriter.identity_tables_available? to check if FK resolution is possible.
97
+ def extract_identity_principal_id(body, headers)
98
+ raw = headers['x-legion-identity'] ||
99
+ body.dig(:identity, :identity) ||
100
+ body.dig(:identity, :canonical_name)
101
+ return nil unless raw && !raw.to_s.empty? # rubocop:disable Legion/Extension/RunnerReturnHash
102
+
103
+ db = ::Legion::Data.connection
104
+ return nil unless Writers::OfficialRecordWriter.identity_tables_available?(db) # rubocop:disable Legion/Extension/RunnerReturnHash
105
+
106
+ body_with_identity = body.merge(caller_identity: raw)
107
+ refs = Writers::OfficialRecordWriter.resolve_identity(db, body_with_identity)
108
+ refs[:principal_id]
109
+ rescue StandardError => e
110
+ handle_exception(e, level: :warn, handled: true, operation: 'write_registry_availability_record.identity_principal')
111
+ nil
112
+ end
113
+
114
+ # Resolve identity_id from AMQP headers or body.
115
+ # Uses OfficialRecordWriter.identity_tables_available? to check if FK resolution is possible.
116
+ def extract_identity_id(body, headers)
117
+ raw = headers['x-legion-identity'] ||
118
+ body.dig(:identity, :identity) ||
119
+ body.dig(:identity, :canonical_name)
120
+ return nil unless raw && !raw.to_s.empty? # rubocop:disable Legion/Extension/RunnerReturnHash
121
+
122
+ db = ::Legion::Data.connection
123
+ return nil unless Writers::OfficialRecordWriter.identity_tables_available?(db) # rubocop:disable Legion/Extension/RunnerReturnHash
124
+
125
+ body_with_identity = body.merge(caller_identity: raw)
126
+ refs = Writers::OfficialRecordWriter.resolve_identity(db, body_with_identity)
127
+ refs[:identity_id]
128
+ rescue StandardError => e
129
+ handle_exception(e, level: :warn, handled: true, operation: 'write_registry_availability_record.identity')
130
+ nil
131
+ end
132
+
92
133
  def lane_key(lane)
93
134
  if lane.is_a?(String)
94
135
  lane
@@ -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.1'
8
8
  end
9
9
  end
10
10
  end
@@ -6,6 +6,7 @@ require 'legion/logging'
6
6
  require 'legion/extensions/llm/responses/thinking_extractor'
7
7
  require_relative '../helpers/json'
8
8
  require_relative '../helpers/persistence_logging'
9
+ require_relative 'official_route_attempt_writer'
9
10
 
10
11
  module Legion
11
12
  module Extensions
@@ -30,6 +31,7 @@ module Legion
30
31
  response = find_or_create_response(db, request, response_message, body)
31
32
  link_response_message!(db, response_message, response)
32
33
  metric = find_or_create_metric(db, request, response, body)
34
+ OfficialRouteAttemptWriter.write_route_attempts(db, request, response, body)
33
35
  result = { result: :ok, request_id: request[:id], response_id: response[:id], metric_id: metric[:id] }
34
36
  end
35
37
 
@@ -46,6 +48,7 @@ module Legion
46
48
  request = find_or_create_request(db, conversation, nil, body)
47
49
  response = find_or_create_response(db, request, nil, body)
48
50
  metric = find_or_create_metric(db, request, response, body)
51
+ OfficialRouteAttemptWriter.write_route_attempts(db, request, response, body)
49
52
  result = { result: :ok, request_id: request[:id], response_id: response[:id], metric_id: metric[:id] }
50
53
  end
51
54
 
@@ -112,7 +115,7 @@ module Legion
112
115
  end
113
116
  end
114
117
 
115
- def find_or_create_request(db, conversation, latest_message, body) # rubocop:disable Metrics/AbcSize
118
+ def find_or_create_request(db, conversation, latest_message, body)
116
119
  request_id = request_ref(body)
117
120
  existing = db[:llm_message_inference_requests].where(request_ref: request_id).first
118
121
  return enrich_request!(db, existing, body, latest_message) if existing
@@ -140,13 +143,16 @@ module Legion
140
143
  status: 'responded',
141
144
  context_message_count: Array(body.dig(:request, :messages) || body[:messages]).size,
142
145
  request_capture_mode: 'full',
143
- request_json: phi_protect(json_dump(request_payload(body)), contains_phi?(body)),
146
+ request_json: if request_payload(body)
147
+ phi_protect(json_dump(request_payload(body)),
148
+ contains_phi?(body))
149
+ end,
144
150
  classification_level: classification_level(body),
145
151
  cost_center: billing(body)[:cost_center],
146
152
  budget_key: billing(body)[:budget_id] || billing(body)[:budget_key],
147
153
  injected_tool_count: Array(body.dig(:audit, :injected_tools) || body[:injected_tools]).size,
148
154
  context_tokens: resolve_context_tokens(body),
149
- request_content_hash: compute_content_hash(body.dig(:request, :content) || body.dig(:audit, :request_content)),
155
+ request_content_hash: resolve_request_content_hash(body),
150
156
  curation_strategy: body[:curation_strategy] || body.dig(:audit, :curation_strategy),
151
157
  tool_policy: body[:tool_policy] || body.dig(:audit, :tool_policy),
152
158
  requested_at: recorded_at(body),
@@ -194,15 +200,29 @@ module Legion
194
200
  end
195
201
  end
196
202
 
197
- def find_or_create_response(db, request, response_message, body) # rubocop:disable Metrics/AbcSize
203
+ def find_or_create_response(db, request, response_message, body)
198
204
  response_uuid = stable_uuid(reference(body, :provider_response_ref) || "response:#{request_ref(body)}:#{body[:provider] || 'unknown'}")
199
205
  existing = db[:llm_message_inference_responses].where(uuid: response_uuid).first
206
+
207
+ # Fallback: if we couldn't find a response by UUID, check if a response
208
+ # already exists for this request (e.g., metering arrived first and created
209
+ # a response with a different UUID). Enrich it instead of creating a duplicate.
210
+ unless existing
211
+ existing = db[:llm_message_inference_responses]
212
+ .where(message_inference_request_id: request[:id])
213
+ .first
214
+ log.debug("[ledger] response fallback: found existing response id=#{existing[:id]} for request_id=#{request[:id]}") if existing
215
+ end
216
+
200
217
  if existing
201
218
  enrich_response!(db, existing, response_message, body)
202
219
  return existing
203
220
  end
204
221
 
222
+ vis_resp = visible_response(body)
223
+ think_resp = thinking_response(body)
205
224
  phi = contains_phi?(body)
225
+
206
226
  id = insert_with_savepoint(db, :llm_message_inference_responses, {
207
227
  uuid: response_uuid,
208
228
  message_inference_request_id: request[:id],
@@ -218,13 +238,13 @@ module Legion
218
238
  latency_ms: integer(body[:latency_ms]),
219
239
  wall_clock_ms: integer(body[:wall_clock_ms]),
220
240
  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),
241
+ response_json: vis_resp ? phi_protect(json_dump(vis_resp), phi) : nil,
242
+ response_thinking_json: think_resp ? phi_protect(json_dump(think_resp), phi) : nil,
223
243
  dispatch_path: body[:dispatch_path] || body[:tier],
224
244
  error_category: body[:error_category] || body.dig(:error, :category),
225
245
  error_code: body[:error_code] || body.dig(:error, :code),
226
246
  error_message: body[:error_message] || body.dig(:error, :message),
227
- response_content_hash: compute_content_hash(body[:response_content] || body.dig(:audit, :response_content)),
247
+ response_content_hash: resolve_response_content_hash(body),
228
248
  route_attempts: (body[:route_attempts] || body.dig(:audit, :route_attempts)).to_i,
229
249
  escalation_chain_ref: body[:escalation_chain_ref],
230
250
  identity_principal_id: caller_identity_refs(db, body)[:principal_id],
@@ -253,12 +273,23 @@ module Legion
253
273
  update_if_missing(updates, existing, :finish_reason, finish_reason(body))
254
274
  update_if_missing(updates, existing, :dispatch_path, body[:dispatch_path] || body[:tier])
255
275
  update_if_missing(updates, existing, :identity_canonical_name, identity_canonical_name(body))
276
+ update_if_missing(updates, existing, :response_content_hash, resolve_response_content_hash(body))
277
+
278
+ vis = visible_response(body)
279
+ if vis
280
+ response_json = json_dump(vis)
281
+ update_if_placeholder(updates, existing, :response_json, response_json)
282
+ elsif existing[:response_json].nil?
283
+ # Nothing to add, but also don't overwrite existing data with nil
284
+ end
256
285
 
257
- response_json = json_dump(visible_response(body))
258
- update_if_placeholder(updates, existing, :response_json, response_json)
259
-
260
- thinking_json = json_dump(thinking_response(body))
261
- update_if_placeholder(updates, existing, :response_thinking_json, thinking_json)
286
+ think = thinking_response(body)
287
+ if think
288
+ thinking_json = json_dump(think)
289
+ update_if_placeholder(updates, existing, :response_thinking_json, thinking_json)
290
+ elsif existing[:response_thinking_json].nil?
291
+ # Nothing to add
292
+ end
262
293
 
263
294
  return if updates.empty?
264
295
 
@@ -266,12 +297,27 @@ module Legion
266
297
  log.info("[ledger] enriched response id=#{existing[:id]} fields=#{updates.keys.join(',')}")
267
298
  end
268
299
 
300
+ # Core guard: never upsert a value that is nil, empty string, or empty JSON object.
301
+ # This prevents a leaner message (e.g., metering) from overwriting valid data
302
+ # written by a richer message (e.g., prompt audit).
303
+ def upsert_guard?(value)
304
+ return false if value.nil?
305
+ return false if value.is_a?(String) && value.strip.empty?
306
+ return false if value.to_s == '{}'
307
+
308
+ true
309
+ end
310
+
269
311
  def update_if_missing(updates, existing, key, value)
270
- updates[key] = value if existing[key].nil? && present?(value)
312
+ updates[key] = value if existing[key].nil? && upsert_guard?(value)
271
313
  end
272
314
 
273
315
  def update_if_placeholder(updates, existing, key, value)
274
- updates[key] = value if existing[key].to_s == '{}' && value != '{}'
316
+ return unless upsert_guard?(value)
317
+
318
+ existing_val = existing[key]
319
+ is_placeholder = ['{}', 'null'].include?(existing_val.to_s)
320
+ updates[key] = value if is_placeholder
275
321
  end
276
322
 
277
323
  def find_or_create_metric(db, request, response, body)
@@ -339,15 +385,20 @@ module Legion
339
385
  updates = {}
340
386
  update_if_missing(updates, existing, :latest_message_id, latest_message&.dig(:id))
341
387
  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)
388
+ update_if_missing(updates, existing, :caller_identity_id, caller_refs[:identity_id])
389
+ update_if_missing(updates, existing, :caller_principal_id, caller_refs[:principal_id])
390
+ update_if_missing(updates, existing, :runtime_caller_type, caller_type(body))
345
391
  update_if_missing(updates, existing, :runtime_caller_class, runtime_caller_class(body))
346
392
  update_if_missing(updates, existing, :runtime_caller_client, runtime_caller_client(body))
347
393
  update_if_missing(updates, existing, :identity_canonical_name, identity_canonical_name(body))
394
+ update_if_missing(updates, existing, :request_content_hash, resolve_request_content_hash(body))
348
395
 
349
- request_json = json_dump(request_payload(body))
350
- updates[:request_json] = request_json if existing[:request_json].to_s == '{}' && request_json != '{}'
396
+ request_json = request_payload(body) ? json_dump(request_payload(body)) : nil
397
+ if request_json
398
+ update_if_placeholder(updates, existing, :request_json, request_json)
399
+ elsif existing[:request_json].nil?
400
+ # Nothing to add
401
+ end
351
402
 
352
403
  msg_count = Array(body.dig(:request, :messages) || body[:messages]).size
353
404
  updates[:context_message_count] = msg_count if existing[:context_message_count].to_i.zero? && msg_count.positive?
@@ -574,7 +625,7 @@ module Legion
574
625
  end
575
626
 
576
627
  def resolve_parent_request_id(db, body)
577
- parent_ref = body[:parent_request_id] || body.dig(:context, :parent_request_id)
628
+ parent_ref = body[:parent_request_id] || body.dig(:context, :parent_request_id) || body.dig(:caller, :parent_request_ref)
578
629
  return nil unless present?(parent_ref)
579
630
 
580
631
  if parent_ref.is_a?(Integer)
@@ -630,7 +681,7 @@ module Legion
630
681
  end
631
682
 
632
683
  def request_payload(body)
633
- body[:request] || body[:messages] || {}
684
+ body[:request] || body[:messages]
634
685
  end
635
686
 
636
687
  def request_content(body)
@@ -641,7 +692,9 @@ module Legion
641
692
  end
642
693
 
643
694
  def visible_response(body)
644
- response = body[:response] || body[:response_content] || body[:content] || {}
695
+ response = body[:response] || body[:response_content] || body[:content]
696
+ return nil if response.nil? || (response.is_a?(Hash) && response.empty?)
697
+
645
698
  if response.is_a?(String)
646
699
  clean, _thinking = extract_inline_thinking(response)
647
700
  return { content: clean }
@@ -661,10 +714,10 @@ module Legion
661
714
  end
662
715
 
663
716
  content_str = body[:response_content] || body[:response] || body[:content]
664
- return {} unless content_str.is_a?(String)
717
+ return nil unless content_str.is_a?(String)
665
718
 
666
719
  _clean, extracted = extract_inline_thinking(content_str)
667
- extracted ? { content: extracted } : {}
720
+ extracted ? { content: extracted } : nil
668
721
  end
669
722
 
670
723
  def extract_inline_thinking(text)
@@ -677,7 +730,10 @@ module Legion
677
730
  end
678
731
 
679
732
  def response_content(body)
680
- stringify_content(visible_response(body)[:content] || visible_response(body).dig(:message, :content))
733
+ vis = visible_response(body)
734
+ return nil unless vis
735
+
736
+ stringify_content(vis[:content] || vis.dig(:message, :content))
681
737
  end
682
738
 
683
739
  def finish_reason(body)
@@ -785,12 +841,26 @@ module Legion
785
841
  Digest::SHA256.hexdigest(json_dump(content))[0..31]
786
842
  end
787
843
 
844
+ # Prefer precomputed hash from emitter (A4: hash ships instead of raw content).
845
+ # Falls back to computing from raw content for backward compatibility.
846
+ def resolve_request_content_hash(body)
847
+ return body[:request_content_hash] if present?(body[:request_content_hash])
848
+
849
+ compute_content_hash(body.dig(:request, :content) || body.dig(:audit, :request_content))
850
+ end
851
+
852
+ # Prefer precomputed hash from emitter (A4: hash ships instead of raw content).
853
+ # Falls back to computing from raw content for backward compatibility.
854
+ def resolve_response_content_hash(body)
855
+ return body[:response_content_hash] if present?(body[:response_content_hash])
856
+
857
+ compute_content_hash(body[:response_content] || body.dig(:audit, :response_content))
858
+ end
859
+
788
860
  def resolve_context_tokens(body)
789
861
  raw = body[:tokens] || body[:audit] || body
790
862
  val = raw[:input_tokens] || raw[:input] || raw[:context_tokens] || raw[:prompt_tokens]
791
- return nil unless present?(val)
792
-
793
- val.to_i
863
+ present?(val) ? val.to_i : 0
794
864
  end
795
865
  end
796
866
  end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require_relative '../helpers/json'
5
+ require_relative '../helpers/persistence_logging'
6
+
7
+ module Legion
8
+ module Extensions
9
+ module Llm
10
+ module Ledger
11
+ module Writers
12
+ module OfficialRouteAttemptWriter
13
+ extend Legion::Logging::Helper
14
+
15
+ module_function
16
+
17
+ # Persist route attempt details into llm_route_attempts table.
18
+ # Called from write_prompt/write_metering after the response row exists.
19
+ #
20
+ # Maps emitter keys to table columns:
21
+ # provider -> provider
22
+ # instance -> route_target
23
+ # model -> model_key
24
+ # operation -> operation
25
+ # dispatch_path -> dispatch_path
26
+ # status -> status
27
+ # failure_reason -> failure_reason
28
+ # idempotency_key -> idempotency_key
29
+ def write_route_attempts(db, request, response, body)
30
+ attempts = Array(body[:route_attempt_details])
31
+ return if attempts.empty?
32
+
33
+ attempts.each_with_index do |attempt, idx|
34
+ next unless attempt.is_a?(Hash)
35
+
36
+ attempt_no = (attempt[:attempt_no] || (idx + 1)).to_i
37
+ uuid = stable_uuid("#{request[:uuid]}:attempt:#{attempt_no}")
38
+
39
+ existing = db[:llm_route_attempts].where(uuid: uuid).first
40
+ next if existing
41
+
42
+ begin
43
+ Helpers::PersistenceLogging.insert_row(
44
+ db,
45
+ :llm_route_attempts,
46
+ {
47
+ uuid: uuid,
48
+ message_inference_request_id: request[:id],
49
+ message_inference_response_id: response[:id],
50
+ attempt_no: attempt_no,
51
+ provider: attempt[:provider] || body[:provider],
52
+ model_key: attempt[:model] || attempt[:model_key] || body[:model_id],
53
+ tier: attempt[:tier] || body[:tier],
54
+ route_target: attempt[:route_target] || attempt[:instance],
55
+ status: (attempt[:status] || 'success').to_s,
56
+ failure_reason: attempt[:failure_reason],
57
+ latency_ms: (attempt[:latency_ms] || 0).to_i,
58
+ operation: attempt[:operation],
59
+ dispatch_path: attempt[:dispatch_path],
60
+ idempotency_key: attempt[:idempotency_key],
61
+ started_at: attempt[:started_at],
62
+ ended_at: attempt[:ended_at],
63
+ identity_principal_id: resolve_identity_principal_id(db, body),
64
+ identity_id: resolve_identity_id(db, body),
65
+ identity_canonical_name: identity_canonical_name(body),
66
+ inserted_at: Time.now.utc
67
+ },
68
+ operation: 'official_route_attempt_writer.insert'
69
+ )
70
+ rescue Sequel::UniqueConstraintViolation => e
71
+ log.debug("[ledger] route_attempt collision resolved uuid=#{uuid} error=#{e.class}")
72
+ end
73
+ end
74
+ end
75
+
76
+ def resolve_identity_principal_id(db, body)
77
+ OfficialRecordWriter.caller_identity_refs(db, body)[:principal_id]
78
+ rescue StandardError => e
79
+ handle_exception(e, level: :warn, handled: true, operation: 'route_attempt_writer.identity_principal')
80
+ nil
81
+ end
82
+
83
+ def resolve_identity_id(db, body)
84
+ OfficialRecordWriter.caller_identity_refs(db, body)[:identity_id]
85
+ rescue StandardError => e
86
+ handle_exception(e, level: :warn, handled: true, operation: 'route_attempt_writer.identity')
87
+ nil
88
+ end
89
+
90
+ def identity_canonical_name(body)
91
+ raw = body.dig(:identity, :canonical_name) ||
92
+ body.dig(:identity, :identity) ||
93
+ body[:caller_identity]
94
+ return nil unless raw && !raw.to_s.empty?
95
+
96
+ raw.to_s
97
+ end
98
+
99
+ def stable_uuid(value)
100
+ raw = value.to_s
101
+ return raw if raw.length <= 36
102
+
103
+ hex = Digest::SHA256.hexdigest(raw)[0, 32]
104
+ "#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
111
+ 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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -246,6 +246,7 @@ files:
246
246
  - lib/legion/extensions/llm/ledger/writers/official_metering_writer.rb
247
247
  - lib/legion/extensions/llm/ledger/writers/official_prompt_writer.rb
248
248
  - lib/legion/extensions/llm/ledger/writers/official_record_writer.rb
249
+ - lib/legion/extensions/llm/ledger/writers/official_route_attempt_writer.rb
249
250
  - lib/lex-llm-ledger.rb
250
251
  homepage: https://github.com/LegionIO/lex-llm-ledger
251
252
  licenses: