lex-llm-ledger 0.2.5 → 0.3.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: 83376ade1924f74e2c734ccf0b9e67b1a1db0ad0494a883469fb86a4eb1763a1
4
- data.tar.gz: 83c5f37ff7b8d63c2865e3f758f0ecbb116746d93817a2b67aadc7a14f66f503
3
+ metadata.gz: bedae52b0629ce6755fc321019b9c35917e8d4f8a304c7294956cbba864fa676
4
+ data.tar.gz: c3f171de566906d37cb02c755f0336df1ce0f09dcdf769e84961f574e3b9d77e
5
5
  SHA512:
6
- metadata.gz: dcd276003a26041f9b0e5aa373547d7d03f74010c1a7fd011cd0a6fd1a9ded022425476ff95e7efc5a466c94c8362b699e5d1e3f8468609eec2bc6391e02b016
7
- data.tar.gz: 0b0168807bc938bdcba4d3a2955c3d5e86f58a6138a93970fea1addbd2bc1f38fef44ed5bbedfcd7d01c3d26e23c451920c645d79acce603b26b98ae7bfffddc
6
+ metadata.gz: f8623ee921c7e0b610217925aea11e9f0f5b315b89b56d2ea239aefa9b0ac9d17b298b1f95978f784f6932ab005fa906c5c32bdd78d1f3eb47dcefb73f3762f9
7
+ data.tar.gz: 278ed50a3f63394470ede944211cc0893e1c8c52bda2200209161040eba3a07a0d62aa47dbfbf37e913ecdaa8d474a4144f1961b60b3e0dcfdb3af936daf155e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0] - 2026-05-08
4
+
5
+ ### Changed
6
+ - Renamed all `portable_identity_*` table references to canonical identity table names (`identities`, `identity_principals`, `identity_providers`).
7
+ - Renamed internal methods: `resolve_portable_identity` → `resolve_identity`, `find_or_create_portable_identity` → `find_or_create_identity`.
8
+
9
+ ## [0.2.9] - 2026-05-07
10
+
11
+ ### Fixed
12
+ - Prefer current publisher identity payloads and AMQP identity headers over stale `caller.requested_by.id` values when normalizing prompt, metering, and tool audit events.
13
+ - Resolve canonical caller identity strings into portable identity provider, principal, and identity rows before writing official inference request foreign keys.
14
+ - Store `runtime_caller_type` from explicit type fields instead of identity strings.
15
+
16
+ ## [0.2.8] - 2026-05-07
17
+
18
+ ### Fixed
19
+ - Extract caller identity from audit event structure (`identity.identity`, `caller.requested_by.identity`) instead of missing top-level `caller_identity_id` / `caller_principal_id` keys.
20
+ - Enrich existing request rows when prompt audit arrives after metering (backfills `caller_identity_id`, `caller_principal_id`, `runtime_caller_type`, `request_json`, `context_message_count`).
21
+
22
+ ## [0.2.7] - 2026-05-07
23
+
24
+ ### Fixed
25
+ - Enrich existing inference response rows when a richer payload arrives (prompt audit backfills `response_message_id`, `response_json`, `tier`, `finish_reason` that metering left null).
26
+
27
+ ## [0.2.6] - 2026-05-07
28
+
29
+ ### Fixed
30
+ - Add `Legion::Logging::Helper` to `OfficialRecordWriter` so `log` is available in rescue blocks.
31
+ - Wrap message inserts in savepoints so PostgreSQL unique constraint violations don't poison the parent transaction and cause `PG::InFailedSqlTransaction` on the fallback query.
32
+
3
33
  ## [0.2.5] - 2026-05-06
4
34
 
5
35
  ### Fixed
@@ -17,21 +17,21 @@ module Legion
17
17
  identity_hash = identity.is_a?(Hash) ? identity : {}
18
18
  extension = hash_value(caller_hash, :extension)
19
19
  type = first_present(
20
- hash_value(caller, :type),
21
20
  hash_value(identity_hash, :type),
22
21
  header_value(headers, 'x-legion-caller-type'),
22
+ hash_value(caller, :type),
23
23
  extension && 'extension'
24
24
  )
25
25
 
26
26
  raw_identity = first_present(
27
- hash_value(caller, :id),
28
- hash_value(caller, :canonical_name),
29
27
  hash_value(identity_hash, :id),
30
28
  hash_value(identity_hash, :canonical_name),
31
29
  hash_value(identity_hash, :identity),
32
30
  hash_value(identity_hash, :username),
33
31
  header_value(headers, 'x-legion-identity'),
34
32
  header_value(headers, 'x-legion-caller-identity'),
33
+ hash_value(caller, :id),
34
+ hash_value(caller, :canonical_name),
35
35
  hash_value(caller, :identity),
36
36
  hash_value(caller, :username),
37
37
  extension && "extension:#{extension}"
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Llm
6
6
  module Ledger
7
- VERSION = '0.2.5'
7
+ VERSION = '0.3.0'
8
8
  end
9
9
  end
10
10
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'digest'
4
4
  require 'securerandom'
5
+ require 'legion/logging'
5
6
  require_relative '../helpers/json'
6
7
  require_relative '../helpers/persistence_logging'
7
8
 
@@ -11,6 +12,8 @@ module Legion
11
12
  module Ledger
12
13
  module Writers
13
14
  module OfficialRecordWriter
15
+ extend Legion::Logging::Helper
16
+
14
17
  module_function
15
18
 
16
19
  def write_prompt(payload)
@@ -76,34 +79,46 @@ module Legion
76
79
  return existing if existing
77
80
 
78
81
  seq = body[:message_seq] ? integer(body[:message_seq]) : next_message_seq(db, conversation)
79
- id = insert_row(db, :llm_messages, {
80
- uuid: uuid,
81
- conversation_id: conversation[:id],
82
- seq: seq,
83
- role: 'user',
84
- content_type: 'text',
85
- content: request_content(body),
86
- input_tokens: tokens(body)[:input_tokens],
87
- output_tokens: 0,
88
- created_at: recorded_at(body),
89
- inserted_at: Time.now.utc
90
- }, operation: 'official_record_writer.user_message')
91
- db[:llm_messages][id: id]
82
+ begin
83
+ id = db.transaction(savepoint: true) do
84
+ insert_row(db, :llm_messages, {
85
+ uuid: uuid,
86
+ conversation_id: conversation[:id],
87
+ seq: seq,
88
+ role: 'user',
89
+ content_type: 'text',
90
+ content: request_content(body),
91
+ input_tokens: tokens(body)[:input_tokens],
92
+ output_tokens: 0,
93
+ created_at: recorded_at(body),
94
+ inserted_at: Time.now.utc
95
+ }, operation: 'official_record_writer.user_message')
96
+ end
97
+ db[:llm_messages][id: id]
98
+ rescue Sequel::UniqueConstraintViolation => e
99
+ log.debug("[ledger] seq collision resolved uuid=#{uuid} conversation_id=#{conversation[:id]} error=#{e.class}")
100
+ db[:llm_messages].where(uuid: uuid).first ||
101
+ db[:llm_messages].where(conversation_id: conversation[:id], seq: seq).first
102
+ end
92
103
  end
93
104
 
94
105
  def find_or_create_request(db, conversation, latest_message, body)
95
106
  request_id = request_ref(body)
96
107
  existing = db[:llm_message_inference_requests].where(request_ref: request_id).first
97
- return existing if existing
108
+ if existing
109
+ enrich_request!(db, existing, body)
110
+ return existing
111
+ end
98
112
 
99
113
  operation = operation(body)
114
+ caller_refs = caller_identity_refs(db, body)
100
115
  id = insert_row(db, :llm_message_inference_requests, {
101
116
  uuid: stable_uuid(request_id),
102
117
  conversation_id: conversation[:id],
103
118
  latest_message_id: latest_message[:id],
104
- caller_principal_id: body[:caller_principal_id],
105
- caller_identity_id: body[:caller_identity_id],
106
- runtime_caller_type: body[:caller_type],
119
+ caller_principal_id: caller_refs[:principal_id],
120
+ caller_identity_id: caller_refs[:identity_id],
121
+ runtime_caller_type: caller_type(body),
107
122
  request_ref: request_id,
108
123
  correlation_ref: correlation_id(body),
109
124
  correlation_id: correlation_id(body),
@@ -130,27 +145,39 @@ module Legion
130
145
  return existing if existing
131
146
 
132
147
  latest = db[:llm_messages][id: request[:latest_message_id]]
133
- id = insert_row(db, :llm_messages, {
134
- uuid: uuid,
135
- conversation_id: conversation[:id],
136
- parent_message_id: latest&.dig(:id),
137
- message_inference_request_id: request[:id],
138
- seq: (latest&.dig(:seq) || 1) + 1,
139
- role: 'assistant',
140
- content_type: 'text',
141
- content: response_content(body),
142
- input_tokens: 0,
143
- output_tokens: tokens(body)[:output_tokens],
144
- created_at: recorded_at(body),
145
- inserted_at: Time.now.utc
146
- }, operation: 'official_record_writer.response_message')
147
- db[:llm_messages][id: id]
148
+ seq = (latest&.dig(:seq) || 1) + 1
149
+ begin
150
+ id = db.transaction(savepoint: true) do
151
+ insert_row(db, :llm_messages, {
152
+ uuid: uuid,
153
+ conversation_id: conversation[:id],
154
+ parent_message_id: latest&.dig(:id),
155
+ message_inference_request_id: request[:id],
156
+ seq: seq,
157
+ role: 'assistant',
158
+ content_type: 'text',
159
+ content: response_content(body),
160
+ input_tokens: 0,
161
+ output_tokens: tokens(body)[:output_tokens],
162
+ created_at: recorded_at(body),
163
+ inserted_at: Time.now.utc
164
+ }, operation: 'official_record_writer.response_message')
165
+ end
166
+ db[:llm_messages][id: id]
167
+ rescue Sequel::UniqueConstraintViolation => e
168
+ log.debug("[ledger] seq collision resolved uuid=#{uuid} conversation_id=#{conversation[:id]} error=#{e.class}")
169
+ db[:llm_messages].where(uuid: uuid).first ||
170
+ db[:llm_messages].where(conversation_id: conversation[:id], seq: seq).first
171
+ end
148
172
  end
149
173
 
150
174
  def find_or_create_response(db, request, response_message, body)
151
175
  response_uuid = stable_uuid(reference(body, :provider_response_ref) || "response:#{request_ref(body)}")
152
176
  existing = db[:llm_message_inference_responses].where(uuid: response_uuid).first
153
- return existing if existing
177
+ if existing
178
+ enrich_response!(db, existing, response_message, body)
179
+ return existing
180
+ end
154
181
 
155
182
  id = insert_row(db, :llm_message_inference_responses, {
156
183
  uuid: response_uuid,
@@ -176,6 +203,34 @@ module Legion
176
203
  db[:llm_message_inference_responses][id: id]
177
204
  end
178
205
 
206
+ def enrich_response!(db, existing, response_message, body)
207
+ updates = {}
208
+ update_if_missing(updates, existing, :response_message_id, response_message&.dig(:id))
209
+ update_if_missing(updates, existing, :tier, tier(body))
210
+ update_if_missing(updates, existing, :provider_instance, provider_instance(body))
211
+ update_if_missing(updates, existing, :finish_reason, finish_reason(body))
212
+ update_if_missing(updates, existing, :dispatch_path, body[:dispatch_path] || body[:tier])
213
+
214
+ response_json = json_dump(visible_response(body))
215
+ update_if_placeholder(updates, existing, :response_json, response_json)
216
+
217
+ thinking_json = json_dump(thinking_response(body))
218
+ update_if_placeholder(updates, existing, :response_thinking_json, thinking_json)
219
+
220
+ return if updates.empty?
221
+
222
+ db[:llm_message_inference_responses].where(id: existing[:id]).update(updates)
223
+ log.info("[ledger] enriched response id=#{existing[:id]} fields=#{updates.keys.join(',')}")
224
+ end
225
+
226
+ def update_if_missing(updates, existing, key, value)
227
+ updates[key] = value if existing[key].nil? && present?(value)
228
+ end
229
+
230
+ def update_if_placeholder(updates, existing, key, value)
231
+ updates[key] = value if existing[key].to_s == '{}' && value != '{}'
232
+ end
233
+
179
234
  def find_or_create_metric(db, request, response, body)
180
235
  metric_uuid = stable_uuid(reference(body, :message_id) || "metric:#{request_ref(body)}")
181
236
  existing = db[:llm_message_inference_metrics].where(uuid: metric_uuid).first
@@ -222,6 +277,216 @@ module Legion
222
277
  db[:llm_messages].where(id: response_message[:id]).update(message_inference_response_id: response[:id])
223
278
  end
224
279
 
280
+ def enrich_request!(db, existing, body)
281
+ updates = {}
282
+ caller_refs = caller_identity_refs(db, body)
283
+ updates[:caller_identity_id] = caller_refs[:identity_id] if existing[:caller_identity_id].nil? && caller_refs[:identity_id]
284
+ updates[:caller_principal_id] = caller_refs[:principal_id] if existing[:caller_principal_id].nil? && caller_refs[:principal_id]
285
+ updates[:runtime_caller_type] = caller_type(body) if existing[:runtime_caller_type].nil? && caller_type(body)
286
+
287
+ request_json = json_dump(request_payload(body))
288
+ updates[:request_json] = request_json if existing[:request_json].to_s == '{}' && request_json != '{}'
289
+
290
+ msg_count = Array(body.dig(:request, :messages) || body[:messages]).size
291
+ updates[:context_message_count] = msg_count if existing[:context_message_count].to_i.zero? && msg_count.positive?
292
+
293
+ return if updates.empty?
294
+
295
+ db[:llm_message_inference_requests].where(id: existing[:id]).update(updates)
296
+ log.info("[ledger] enriched request id=#{existing[:id]} fields=#{updates.keys.join(',')}")
297
+ end
298
+
299
+ def caller_identity(body)
300
+ caller_identity_refs(::Legion::Data.connection, body)[:identity_id]
301
+ end
302
+
303
+ def caller_principal(body)
304
+ caller_identity_refs(::Legion::Data.connection, body)[:principal_id]
305
+ end
306
+
307
+ def caller_type(body)
308
+ raw_type = body[:caller_type] ||
309
+ body.dig(:identity, :type) ||
310
+ body.dig(:caller, :requested_by, :type) ||
311
+ body.dig(:caller, :source)
312
+ return normalize_caller_type(raw_type) if present?(raw_type)
313
+
314
+ parsed_identity_descriptor(body)[:kind]
315
+ end
316
+
317
+ def caller_identity_refs(db, body)
318
+ body[:__ledger_caller_identity_refs] ||= begin
319
+ explicit_identity_id = integer_or_nil(body[:caller_identity_id] || body.dig(:caller, :requested_by, :id))
320
+ explicit_principal_id = integer_or_nil(body[:caller_principal_id] ||
321
+ body.dig(:caller, :requested_by, :principal_id))
322
+ refs = { principal_id: explicit_principal_id, identity_id: explicit_identity_id }.compact
323
+ unless refs[:principal_id] && refs[:identity_id]
324
+ if explicit_identity_id && !explicit_principal_id && identity_tables_available?(db)
325
+ row = db[:identities].where(id: explicit_identity_id).first
326
+ refs[:principal_id] = row[:principal_id] if row
327
+ end
328
+
329
+ resolved = resolve_identity(db, body)
330
+ refs[:principal_id] ||= resolved[:principal_id]
331
+ refs[:identity_id] ||= resolved[:identity_id]
332
+ end
333
+ refs.compact
334
+ end
335
+ end
336
+
337
+ def resolve_identity(db, body)
338
+ return {} unless identity_tables_available?(db)
339
+
340
+ descriptor = parsed_identity_descriptor(body)
341
+ return {} unless present?(descriptor[:canonical_name])
342
+
343
+ provider = find_or_create_identity_provider(db, descriptor[:provider_name])
344
+ principal = find_or_create_identity_principal(db, descriptor)
345
+ identity = find_or_create_identity(db, principal, provider, descriptor)
346
+
347
+ { principal_id: principal[:id], identity_id: identity[:id] }
348
+ rescue StandardError => e
349
+ handle_exception(e, level: :warn, handled: true, operation: 'official_record_writer.identity_resolution')
350
+ {}
351
+ end
352
+
353
+ def parsed_identity_descriptor(body)
354
+ raw_identity = body[:caller_identity] ||
355
+ body.dig(:identity, :identity) ||
356
+ body.dig(:identity, :canonical_name) ||
357
+ body.dig(:caller, :requested_by, :identity) ||
358
+ body.dig(:caller, :requested_by, :canonical_name) ||
359
+ body.dig(:caller, :requested_by, :id)
360
+ return {} unless present?(raw_identity)
361
+
362
+ raw_type = body[:caller_type] ||
363
+ body.dig(:identity, :type) ||
364
+ body.dig(:caller, :requested_by, :type) ||
365
+ body.dig(:caller, :source)
366
+ provider_name = body.dig(:identity, :credential) ||
367
+ body.dig(:caller, :requested_by, :credential) ||
368
+ 'local'
369
+ parse_identity_descriptor(raw_identity, raw_type, provider_name)
370
+ end
371
+
372
+ def parse_identity_descriptor(raw_identity, raw_type, provider_name)
373
+ text = raw_identity.to_s
374
+ kind = normalize_caller_type(raw_type)
375
+ canonical = text
376
+
377
+ if text.include?(':') && !text.include?('@')
378
+ prefix, remainder = text.split(':', 2)
379
+ prefix_kind = normalize_caller_type(prefix)
380
+ if prefix_kind && present?(remainder)
381
+ kind ||= prefix_kind
382
+ canonical = remainder
383
+ end
384
+ end
385
+
386
+ {
387
+ canonical_name: canonical,
388
+ kind: kind || 'unknown',
389
+ provider_identity_key: text,
390
+ provider_name: normalize_provider_name(provider_name)
391
+ }
392
+ end
393
+
394
+ def find_or_create_identity_provider(db, provider_name)
395
+ table = db[:identity_providers]
396
+ existing = table.where(name: provider_name).first
397
+ return existing if existing
398
+
399
+ id = insert_row(db, :identity_providers, {
400
+ uuid: deterministic_uuid("identity_provider:#{provider_name}"),
401
+ name: provider_name,
402
+ provider_type: provider_name == 'local' ? 'local' : 'external',
403
+ facing: 'internal',
404
+ source: 'ledger',
405
+ created_at: Time.now.utc,
406
+ updated_at: Time.now.utc
407
+ }, operation: 'official_record_writer.identity_provider')
408
+ table[id: id]
409
+ rescue Sequel::UniqueConstraintViolation => e
410
+ handle_exception(e, level: :debug, handled: true, operation: 'official_record_writer.identity_provider_race')
411
+ table.where(name: provider_name).first
412
+ end
413
+
414
+ def find_or_create_identity_principal(db, descriptor)
415
+ table = db[:identity_principals]
416
+ existing = table.where(canonical_name: descriptor[:canonical_name], kind: descriptor[:kind]).first
417
+ return existing if existing
418
+
419
+ id = insert_row(db, :identity_principals, {
420
+ uuid: deterministic_uuid("identity_principal:#{descriptor[:kind]}:#{descriptor[:canonical_name]}"),
421
+ canonical_name: descriptor[:canonical_name],
422
+ kind: descriptor[:kind],
423
+ display_name: descriptor[:canonical_name],
424
+ last_seen_at: Time.now.utc,
425
+ created_at: Time.now.utc,
426
+ updated_at: Time.now.utc
427
+ }, operation: 'official_record_writer.identity_principal')
428
+ table[id: id]
429
+ rescue Sequel::UniqueConstraintViolation => e
430
+ handle_exception(e, level: :debug, handled: true, operation: 'official_record_writer.identity_principal_race')
431
+ table.where(canonical_name: descriptor[:canonical_name], kind: descriptor[:kind]).first
432
+ end
433
+
434
+ def find_or_create_identity(db, principal, provider, descriptor)
435
+ table = db[:identities]
436
+ existing = table.where(
437
+ principal_id: principal[:id],
438
+ provider_id: provider[:id],
439
+ provider_identity_key: descriptor[:provider_identity_key]
440
+ ).first
441
+ return existing if existing
442
+
443
+ uuid_key = "identity:#{principal[:id]}:#{provider[:id]}:#{descriptor[:provider_identity_key]}"
444
+ id = insert_row(db, :identities, {
445
+ uuid: deterministic_uuid(uuid_key),
446
+ principal_id: principal[:id],
447
+ provider_id: provider[:id],
448
+ provider_identity_key: descriptor[:provider_identity_key],
449
+ last_authenticated_at: Time.now.utc,
450
+ account_type: 'primary',
451
+ is_default: true,
452
+ created_at: Time.now.utc,
453
+ updated_at: Time.now.utc
454
+ }, operation: 'official_record_writer.identity')
455
+ table[id: id]
456
+ rescue Sequel::UniqueConstraintViolation => e
457
+ handle_exception(e, level: :debug, handled: true, operation: 'official_record_writer.identity_race')
458
+ table.where(principal_id: principal[:id], provider_id: provider[:id],
459
+ provider_identity_key: descriptor[:provider_identity_key]).first
460
+ end
461
+
462
+ def identity_tables_available?(db)
463
+ db.table_exists?(:identity_providers) &&
464
+ db.table_exists?(:identity_principals) &&
465
+ db.table_exists?(:identities)
466
+ end
467
+
468
+ def normalize_caller_type(value)
469
+ return nil unless present?(value)
470
+
471
+ normalized = value.to_s.downcase.gsub(/[^a-z0-9_:-]+/, '_').split(':', 2).first
472
+ return 'human' if normalized == 'user'
473
+
474
+ normalized
475
+ end
476
+
477
+ def normalize_provider_name(value)
478
+ raw = present?(value) ? value.to_s : 'local'
479
+ raw.downcase.gsub(/[^a-z0-9_.:-]+/, '-').gsub(/\A-+|-+\z/, '')
480
+ end
481
+
482
+ def integer_or_nil(value)
483
+ return nil if value.nil?
484
+ return value if value.is_a?(Integer)
485
+
486
+ int = value.to_s.to_i
487
+ int.positive? ? int : nil
488
+ end
489
+
225
490
  def correlation_id(body)
226
491
  reference(body, :correlation_id, :correlation_ref) || body.dig(:tracing, :correlation_id)
227
492
  end
@@ -329,6 +594,11 @@ module Legion
329
594
  "#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
330
595
  end
331
596
 
597
+ def deterministic_uuid(value)
598
+ hex = Digest::SHA256.hexdigest(value.to_s)[0, 32]
599
+ "#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
600
+ end
601
+
332
602
  def next_message_seq(db, conversation)
333
603
  db[:llm_messages].where(conversation_id: conversation[:id]).max(:seq).to_i + 1
334
604
  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.2.5
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity