lex-llm-ledger 0.3.3 → 0.4.2

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: bfcca825db5b0e313cbeafc6c9f396b3f964dd4debd911854c89affe3bcb79ef
4
- data.tar.gz: 1a4ea16c0c4ca34cc1ae4e3d0efa19cab3948c932162d9072c21127dae1ea375
3
+ metadata.gz: 9d96639d6f6bf123c338c0df58c4e25d1375d0102873318197f6297eeda70a80
4
+ data.tar.gz: 1ebcbc88558e3783595e3c07c13eb0f9d0f3fbb82a2c096cb52752592c469d2a
5
5
  SHA512:
6
- metadata.gz: 362b96b3b385cfcca6c2edbe3fa9c6e30a23fbaa80bd5136fa1f95cc35af8ea5aa93ea89cd7cf1415999ac82ce881311aeea084ee44ba19cffadcbef0858a71b
7
- data.tar.gz: 89c63b437c02355a9cc39182d42ac0a1971b7dcdf80473d7ef237d92bbb39cef96c13dd0ea3b34f13d6ed1f662cb6bb30aa473658dde119739b641bbf7b92dac
6
+ metadata.gz: 670f7c45555f557aae91ba858831f1ce40e74eaf92a5390a965e2d5e6487a6b6deb9700527a6098bd8454b2085fbe1de567195d4a7f54b8f910921d6dde9535f
7
+ data.tar.gz: a72b7dac0f47bf49ca637f060698899d8eb2fc1d7436e2271afbddb8ef5684a00e5b75ae264ac6d635bc9ac58b9e79fab84070b4b51878324615d939942a9ef7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.2] - 2026-05-22
4
+
5
+ ### Fixed
6
+ - Dead-letter tool audit messages with missing parent response rows via `UnrecoverableMessageError` so the subscription rejects the RabbitMQ delivery with `requeue: false` instead of acknowledging, republishing, or blocking inside a runner-local sleep/retry loop.
7
+ - Set the `llm.registry.availability` subscription actor prefetch to 4 so registry availability events can drain with modest concurrency.
8
+
9
+ ## [0.4.1] - 2026-05-18
10
+
11
+ ### Fixed
12
+ - Tool write retries once after 1s when parent response row is not yet committed (race between async metering publish and tool audit AMQP delivery)
13
+ - Raises `ResponseNotReady` instead of silently returning nil when response row is missing
14
+
15
+
16
+ ## [0.4.0] - 2026-05-17
17
+
18
+ ### Changed
19
+ - Rewrite `Runners::Tools` to write tool audit events to the official `llm_tool_calls` and
20
+ `llm_tool_call_attempts` tables instead of the legacy `llm_tool_records` table.
21
+ - Each tool audit event produces one `llm_tool_calls` row (linked to the parent
22
+ `llm_message_inference_responses` row) and one `llm_tool_call_attempts` row containing
23
+ the execution outcome.
24
+ - Argument and result payloads are stored as SHA-256 fingerprints in `arguments_ref` /
25
+ `result_ref` (the official schema columns are 255-char refs, not full JSON blobs).
26
+ - Idempotency is enforced via UUID derived from `tool_call.id` (or request/message context);
27
+ a second write for the same tool call returns `{ result: :duplicate }`.
28
+ - Tool call writes that cannot be linked to an existing inference response are logged at
29
+ `warn` and dropped gracefully (returns `{ result: :ok }`).
30
+ - Populate `identity_canonical_name` on every insert in `OfficialRecordWriter`:
31
+ `llm_conversations`, `llm_messages` (user and assistant), `llm_message_inference_requests`,
32
+ `llm_message_inference_responses`, and `llm_message_inference_metrics`.
33
+ - Populate `identity_principal_id` and `identity_id` on inserts into `llm_messages`,
34
+ `llm_message_inference_responses`, and `llm_message_inference_metrics`.
35
+ - Backfill `identity_canonical_name` in `enrich_request!` and `enrich_response!` when the
36
+ enrichment opportunity arrives after a metering-first write.
37
+ - `Runners::Tools` extracts identity via `CallerIdentity.normalize` and writes
38
+ `identity_canonical_name`, `identity_principal_id`, and `identity_id` to both
39
+ `llm_tool_calls` and `llm_tool_call_attempts`.
40
+ - `Runners::RegistryAvailability` writes `identity_canonical_name` from AMQP headers or
41
+ body identity fields when present; `identity_principal_id` and `identity_id` FK columns
42
+ are not resolved because node/service identities may not be registered in identity tables.
43
+
3
44
  ## [0.3.3] - 2026-05-17
4
45
 
5
46
  ### Fixed
@@ -28,7 +28,7 @@ Gem::Specification.new do |spec|
28
28
  end
29
29
  spec.require_paths = ['lib']
30
30
 
31
- spec.add_dependency 'legion-data', '>= 1.8.0'
31
+ spec.add_dependency 'legion-data', '>= 1.8.7'
32
32
  spec.add_dependency 'legion-json', '>= 1.2'
33
33
  spec.add_dependency 'legion-logging', '>= 1.3'
34
34
  spec.add_dependency 'legion-settings', '>= 1.3'
@@ -11,6 +11,8 @@ module Legion
11
11
  class RegistryAvailability < Legion::Extensions::Actors::Subscription
12
12
  include Helpers::SubscriptionActor
13
13
 
14
+ prefetch 4
15
+
14
16
  def runner_class = Legion::Extensions::Llm::Ledger::Runners::RegistryAvailability
15
17
 
16
18
  def runner_function
@@ -13,9 +13,9 @@ module Legion
13
13
  extend Legion::Logging::Helper
14
14
 
15
15
  LEGACY_TABLES = %i[
16
- llm_prompt_records
17
- llm_metering_records
18
- llm_tool_records
16
+ z_archive_llm_prompt_records
17
+ z_archive_llm_metering_records
18
+ z_archive_llm_tool_records
19
19
  llm_registry_availability_records
20
20
  ].freeze
21
21
 
@@ -43,11 +43,11 @@ module Legion
43
43
 
44
44
  def backfill_row(table, row)
45
45
  case table
46
- when :llm_prompt_records
46
+ when :z_archive_llm_prompt_records
47
47
  backfill_prompt(row)
48
- when :llm_metering_records
48
+ when :z_archive_llm_metering_records
49
49
  backfill_metering(row)
50
- when :llm_tool_records
50
+ when :z_archive_llm_tool_records
51
51
  backfill_tool(row)
52
52
  when :llm_registry_availability_records
53
53
  backfill_registry(row)
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sequel.migration do
4
+ up do
5
+ alter_table(:llm_registry_availability_records) do
6
+ add_column :access_scope, String, size: 20, null: false, default: 'global'
7
+ add_column :identity_principal_id, Integer, null: true
8
+ add_column :identity_id, Integer, null: true
9
+ add_column :identity_canonical_name, String, size: 255, null: true
10
+ end
11
+
12
+ alter_table(:llm_registry_availability_records) do
13
+ add_index :identity_principal_id
14
+ add_index :identity_id
15
+ add_index :access_scope
16
+ end
17
+ end
18
+
19
+ down do
20
+ alter_table(:llm_registry_availability_records) do
21
+ drop_index :identity_principal_id
22
+ drop_index :identity_id
23
+ drop_index :access_scope
24
+ drop_column :access_scope
25
+ drop_column :identity_principal_id
26
+ drop_column :identity_id
27
+ drop_column :identity_canonical_name
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sequel.migration do
4
+ up do
5
+ rename_table :llm_metering_records, :z_archive_llm_metering_records
6
+ rename_table :llm_prompt_records, :z_archive_llm_prompt_records
7
+ rename_table :llm_tool_records, :z_archive_llm_tool_records
8
+ end
9
+
10
+ down do
11
+ rename_table :z_archive_llm_metering_records, :llm_metering_records
12
+ rename_table :z_archive_llm_prompt_records, :llm_prompt_records
13
+ rename_table :z_archive_llm_tool_records, :llm_tool_records
14
+ end
15
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../helpers/caller_identity'
3
4
  require_relative '../helpers/json'
4
5
  require_relative '../helpers/persistence_logging'
5
6
 
@@ -13,10 +14,11 @@ module Legion
13
14
 
14
15
  def write_registry_availability_record(payload = nil, metadata = {}, **message)
15
16
  payload, metadata = normalize_runner_args(payload, metadata, message)
16
- props = metadata[:properties] || {}
17
+ headers = Helpers::SubscriptionMessage.extract_headers(payload, metadata)
18
+ props = metadata[:properties] || {}
17
19
 
18
20
  body = symbolize(payload)
19
- record = build_registry_availability_record(body, props)
21
+ record = build_registry_availability_record(body, props, headers)
20
22
  Helpers::PersistenceLogging.insert_row(
21
23
  ::Legion::Data.connection,
22
24
  :llm_registry_availability_records,
@@ -38,41 +40,55 @@ module Legion
38
40
  Helpers::SubscriptionMessage.runner_args(payload, metadata, message)
39
41
  end
40
42
 
41
- def build_registry_availability_record(body, props)
43
+ def build_registry_availability_record(body, props, headers)
42
44
  offering = body[:offering] || {}
43
45
  runtime = body[:runtime] || {}
44
46
  lane = body[:lane]
45
47
 
46
48
  {
47
- event_id: body[:event_id],
48
- message_id: props[:message_id],
49
- correlation_id: props[:correlation_id],
50
- routing_key: props[:routing_key],
51
- event_type: body[:event_type].to_s,
52
- occurred_at: body[:occurred_at],
53
- offering_id: offering[:offering_id],
54
- provider_family: offering[:provider_family]&.to_s,
55
- provider_instance: offering[:provider_instance]&.to_s,
56
- instance_id: offering[:instance_id]&.to_s,
57
- model_family: offering[:model_family]&.to_s,
58
- model_id: offering[:model],
59
- canonical_model: offering[:canonical_model_alias],
60
- provider_model: offering[:provider_model],
61
- usage_type: offering[:usage_type]&.to_s,
62
- transport: offering[:transport]&.to_s,
63
- lane_key: lane_key(lane),
64
- worker_id: runtime[:worker_id] || runtime[:worker],
65
- node_id: runtime[:node_id] || runtime[:host_id],
66
- offering_json: json_dump(offering),
67
- runtime_json: json_dump(runtime),
68
- capacity_json: json_dump(body[:capacity] || {}),
69
- health_json: json_dump(body[:health] || {}),
70
- lane_json: json_dump(lane || {}),
71
- metadata_json: json_dump(body[:metadata] || {}),
72
- inserted_at: Time.now.utc
49
+ event_id: body[:event_id],
50
+ message_id: props[:message_id],
51
+ correlation_id: props[:correlation_id],
52
+ routing_key: props[:routing_key],
53
+ event_type: body[:event_type].to_s,
54
+ occurred_at: body[:occurred_at],
55
+ offering_id: offering[:offering_id],
56
+ provider_family: offering[:provider_family]&.to_s,
57
+ provider_instance: offering[:provider_instance]&.to_s,
58
+ instance_id: offering[:instance_id]&.to_s,
59
+ model_family: offering[:model_family]&.to_s,
60
+ model_id: offering[:model],
61
+ canonical_model: offering[:canonical_model_alias],
62
+ provider_model: offering[:provider_model],
63
+ usage_type: offering[:usage_type]&.to_s,
64
+ transport: offering[:transport]&.to_s,
65
+ lane_key: lane_key(lane),
66
+ worker_id: runtime[:worker_id] || runtime[:worker],
67
+ node_id: runtime[:node_id] || runtime[:host_id],
68
+ identity_canonical_name: extract_canonical_name(body, headers),
69
+ offering_json: json_dump(offering),
70
+ runtime_json: json_dump(runtime),
71
+ capacity_json: json_dump(body[:capacity] || {}),
72
+ health_json: json_dump(body[:health] || {}),
73
+ lane_json: json_dump(lane || {}),
74
+ metadata_json: json_dump(body[:metadata] || {}),
75
+ inserted_at: Time.now.utc
73
76
  }
74
77
  end
75
78
 
79
+ # Extract identity_canonical_name from AMQP headers or body.
80
+ # No FK resolution — node/service identities may not be registered in
81
+ # identity tables, but the canonical name string is still valuable for
82
+ # tracking which identity is publishing which model availability.
83
+ def extract_canonical_name(body, headers)
84
+ raw = headers['x-legion-identity'] ||
85
+ body.dig(:identity, :identity) ||
86
+ body.dig(:identity, :canonical_name)
87
+ return nil unless raw && !raw.to_s.empty? # rubocop:disable Legion/Extension/RunnerReturnHash
88
+
89
+ raw.to_s
90
+ end
91
+
76
92
  def lane_key(lane)
77
93
  if lane.is_a?(String)
78
94
  lane
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'digest'
4
+ require 'securerandom'
3
5
  require_relative '../helpers/caller_identity'
4
6
  require_relative '../helpers/json'
5
7
  require_relative '../helpers/persistence_logging'
@@ -8,6 +10,8 @@ module Legion
8
10
  module Extensions
9
11
  module Llm
10
12
  module Ledger
13
+ class ResponseNotReady < StandardError; end
14
+
11
15
  module Runners
12
16
  module Tools
13
17
  extend self
@@ -21,19 +25,25 @@ module Legion
21
25
  ctx = body[:message_context] || {}
22
26
  tool = body[:tool_call] || {}
23
27
 
24
- expires_at = Helpers::Retention.resolve(
28
+ Helpers::Retention.resolve(
25
29
  retention: headers['x-legion-retention'],
26
30
  contains_phi: headers['x-legion-contains-phi'] == 'true'
27
31
  )
28
32
 
29
- record = build_tool_record(body, ctx, tool, props, headers, expires_at)
30
- Helpers::PersistenceLogging.insert_row(
31
- ::Legion::Data.connection,
32
- :llm_tool_records,
33
- record,
34
- operation: 'write_tool_record'
35
- )
36
- { result: :ok }
33
+ db = ::Legion::Data.connection
34
+ write_result = [:ok]
35
+ db.transaction do
36
+ response = find_or_resolve_response(db, body, ctx, props, headers)
37
+ identity_attrs = extract_identity_attrs(body, headers, db)
38
+ tool_call_row, new_tool_call = find_or_create_tool_call(db, response, body, ctx, tool, headers, identity_attrs)
39
+ if tool_call_row && !new_tool_call
40
+ write_result[0] = :duplicate
41
+ elsif new_tool_call
42
+ find_or_create_tool_call_attempt(db, tool_call_row, tool, body, props, headers, identity_attrs)
43
+ end
44
+ end
45
+
46
+ { result: write_result[0] }
37
47
  rescue Sequel::UniqueConstraintViolation => e
38
48
  log.warn("write_tool_record duplicate insert ignored: #{e.message}")
39
49
  { result: :duplicate }
@@ -43,6 +53,9 @@ module Legion
43
53
  rescue Helpers::DecryptionFailed => e
44
54
  handle_exception(e, level: :error, handled: true, operation: 'write_tool_record.decrypt')
45
55
  raise
56
+ rescue ResponseNotReady => e
57
+ log.warn("[ledger] write_tool_record: parent response not yet written, dead-lettering message (#{e.message})")
58
+ raise unrecoverable_message_error(e)
46
59
  rescue StandardError => e
47
60
  handle_exception(e, level: :error, handled: true, operation: 'write_tool_record')
48
61
  { result: :error, error: e.message }
@@ -54,46 +67,209 @@ module Legion
54
67
  Helpers::SubscriptionMessage.runner_args(payload, metadata, message)
55
68
  end
56
69
 
57
- def build_tool_record(body, ctx, tool, props, headers, expires_at) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
58
- src = tool[:source] || {}
59
- cls = body[:classification] || {}
60
- ts = body[:timestamps] || {}
61
- tracing = body[:tracing] || {}
62
- caller_raw = body[:caller] || {}
63
- identity = body[:identity] || {}
70
+ def unrecoverable_message_error(error)
71
+ if defined?(Legion::Extensions::Actors::UnrecoverableMessageError)
72
+ Legion::Extensions::Actors::UnrecoverableMessageError.new(error.message)
73
+ else
74
+ error
75
+ end
76
+ end
77
+
78
+ # Resolve the llm_message_inference_responses row this tool call belongs to.
79
+ # Returns nil if we cannot link at all.
80
+ def find_or_resolve_response(db, body, ctx, props, headers)
81
+ request_ref = ctx[:request_id] || body[:request_id] ||
82
+ props[:correlation_id] || headers['x-legion-llm-request-id']
83
+ return nil unless request_ref # rubocop:disable Legion/Extension/RunnerReturnHash
84
+
85
+ request = db[:llm_message_inference_requests].where(request_ref: request_ref).first
86
+ return nil unless request # rubocop:disable Legion/Extension/RunnerReturnHash
87
+
88
+ db[:llm_message_inference_responses]
89
+ .where(message_inference_request_id: request[:id]).first
90
+ end
91
+
92
+ def find_or_create_tool_call(db, response, body, ctx, tool, headers, identity_attrs)
93
+ tool_uuid = derive_tool_call_uuid(body, ctx, tool, headers)
94
+ existing = db[:llm_tool_calls].where(uuid: tool_uuid).first
95
+ return [existing, false] if existing # rubocop:disable Legion/Extension/RunnerReturnHash
96
+
97
+ response_id = resolve_response_id(db, response, body, ctx, headers, tool_uuid)
98
+ return [nil, false] unless response_id # rubocop:disable Legion/Extension/RunnerReturnHash
99
+
100
+ next_index = db[:llm_tool_calls]
101
+ .where(message_inference_response_id: response_id)
102
+ .max(:tool_call_index).to_i + 1
103
+
104
+ src = tool[:source] || {}
105
+ status = tool[:status] || headers['x-legion-tool-status'] || 'success'
106
+ ts = body[:timestamps] || {}
107
+
108
+ id = insert_with_savepoint(db, :llm_tool_calls, {
109
+ uuid: tool_uuid,
110
+ message_inference_response_id: response_id,
111
+ tool_call_index: next_index,
112
+ provider_tool_call_ref: tool[:id],
113
+ tool_name: tool[:name] || headers['x-legion-tool-name'],
114
+ tool_source_type: src[:type] || headers['x-legion-tool-source-type'],
115
+ tool_source_server: src[:server] || headers['x-legion-tool-source-server'],
116
+ status: status,
117
+ requested_at: ts[:tool_start] || tool[:started_at],
118
+ completed_at: ts[:tool_end] || tool[:finished_at],
119
+ **identity_attrs,
120
+ inserted_at: Time.now.utc
121
+ }, operation: 'write_tool_record.tool_call')
122
+ [db[:llm_tool_calls][id: id], true]
123
+ rescue Sequel::UniqueConstraintViolation => e
124
+ log.debug("[ledger] tool_call collision resolved uuid=#{tool_uuid} error=#{e.class}")
125
+ row = db[:llm_tool_calls].where(uuid: tool_uuid).first
126
+ raise(e) unless row
127
+
128
+ [row, false]
129
+ end
130
+
131
+ # Extract or fall back to find a response_id for linking the tool call.
132
+ def resolve_response_id(db, response, body, ctx, headers, tool_uuid)
133
+ return response[:id] if response # rubocop:disable Legion/Extension/RunnerReturnHash
134
+
135
+ fallback = fallback_response_for_conversation(db, body, ctx, headers)
136
+ return fallback[:id] if fallback # rubocop:disable Legion/Extension/RunnerReturnHash
137
+
138
+ raise ResponseNotReady, "no response row found for tool call uuid=#{tool_uuid}"
139
+ end
140
+
141
+ def fallback_response_for_conversation(db, body, ctx, headers)
142
+ conv_id = ctx[:conversation_id] || body[:conversation_id] ||
143
+ headers['x-legion-llm-conversation-id']
144
+ return nil unless conv_id # rubocop:disable Legion/Extension/RunnerReturnHash
145
+
146
+ conv = db[:llm_conversations].where(uuid: stable_uuid(conv_id)).first ||
147
+ db[:llm_conversations].where(uuid: conv_id).first
148
+ return nil unless conv # rubocop:disable Legion/Extension/RunnerReturnHash
149
+
150
+ db[:llm_message_inference_responses]
151
+ .join(:llm_message_inference_requests,
152
+ id: :message_inference_request_id)
153
+ .where(Sequel[:llm_message_inference_requests][:conversation_id] => conv[:id])
154
+ .order(Sequel.desc(Sequel[:llm_message_inference_responses][:id]))
155
+ .select_all(:llm_message_inference_responses)
156
+ .first
157
+ end
158
+
159
+ def find_or_create_tool_call_attempt(db, tool_call_row, tool, body, props, headers, identity_attrs) # rubocop:disable Metrics/CyclomaticComplexity
160
+ return nil unless tool_call_row # rubocop:disable Legion/Extension/RunnerReturnHash
161
+
162
+ tool_call_id = tool_call_row[:id]
163
+ attempt_no = db[:llm_tool_call_attempts]
164
+ .where(tool_call_id: tool_call_id).max(:attempt_no).to_i + 1
165
+ attempt_uuid = derive_attempt_uuid(tool_call_row[:uuid], attempt_no)
166
+
167
+ existing = db[:llm_tool_call_attempts].where(uuid: attempt_uuid).first
168
+ return existing if existing # rubocop:disable Legion/Extension/RunnerReturnHash
169
+
170
+ status = tool[:status] || headers['x-legion-tool-status'] || 'success'
171
+ error_info = tool[:error] || body[:error]
172
+ error_hash = error_info.is_a?(Hash) ? error_info : {}
173
+ ts = body[:timestamps] || {}
174
+ runner_ref = body[:worker_id] || body[:runner_ref] || props[:app_id]
175
+
176
+ id = insert_with_savepoint(db, :llm_tool_call_attempts, {
177
+ uuid: attempt_uuid,
178
+ tool_call_id: tool_call_id,
179
+ attempt_no: attempt_no,
180
+ runner_ref: runner_ref,
181
+ status: status,
182
+ error_category: error_hash[:category] || error_hash[:type],
183
+ error_code: error_hash[:code],
184
+ error_message: error_info.is_a?(String) ? error_info : error_hash[:message],
185
+ duration_ms: tool[:duration_ms].to_i,
186
+ arguments_ref: sha256_ref(tool[:arguments]),
187
+ result_ref: sha256_ref(tool[:result] || body[:result]),
188
+ started_at: ts[:tool_start] || tool[:started_at],
189
+ ended_at: ts[:tool_end] || tool[:finished_at],
190
+ **identity_attrs,
191
+ inserted_at: Time.now.utc
192
+ }, operation: 'write_tool_record.attempt')
193
+ db[:llm_tool_call_attempts][id: id]
194
+ rescue Sequel::UniqueConstraintViolation => e
195
+ log.debug("[ledger] tool_call_attempt collision resolved uuid=#{attempt_uuid} error=#{e.class}")
196
+ db[:llm_tool_call_attempts].where(uuid: attempt_uuid).first || raise(e)
197
+ end
198
+
199
+ def extract_identity_attrs(body, headers, db)
64
200
  caller_identity = Helpers::CallerIdentity.normalize(
65
- caller_raw: caller_raw, identity: identity, headers: headers
201
+ caller_raw: body[:caller],
202
+ identity: body[:identity],
203
+ headers: headers
66
204
  )
67
- agent = body[:agent] || {}
205
+ # raw_identity may carry a "type:value" prefix that OfficialRecordWriter
206
+ # knows how to parse; keep it intact for FK resolution.
207
+ raw_identity = caller_identity[:identity]
208
+ canonical_name = raw_identity
209
+ # Strip "type:" prefix added by CallerIdentity for generic identities
210
+ if canonical_name&.include?(':') && !canonical_name&.include?('@')
211
+ _prefix, remainder = canonical_name.split(':', 2)
212
+ canonical_name = remainder if remainder && !remainder.empty?
213
+ end
214
+
215
+ refs = resolve_tool_identity(db, body, raw_identity)
68
216
 
69
217
  {
70
- message_id: props[:message_id] || body[:message_id],
71
- correlation_id: props[:correlation_id] || body[:correlation_id] || tracing[:correlation_id],
72
- conversation_id: ctx[:conversation_id] || body[:conversation_id] || headers['x-legion-llm-conversation-id'],
73
- message_id_ctx: ctx[:message_id],
74
- parent_message_id: ctx[:parent_message_id],
75
- message_seq: ctx[:message_seq],
76
- request_id: ctx[:request_id] || body[:request_id] || headers['x-legion-llm-request-id'],
77
- exchange_id: ctx[:exchange_id] || body[:exchange_id],
78
- tool_call_id: tool[:id],
79
- tool_name: tool[:name] || headers['x-legion-tool-name'],
80
- tool_source_type: src[:type] || headers['x-legion-tool-source-type'],
81
- tool_source_server: src[:server] || headers['x-legion-tool-source-server'],
82
- tool_status: tool[:status] || headers['x-legion-tool-status'],
83
- tool_duration_ms: tool[:duration_ms].to_i,
84
- arguments_json: Helpers::Json.dump(tool[:arguments] || {}),
85
- result_json: Helpers::Json.dump(tool[:result] || body[:result]),
86
- error_json: Helpers::Json.dump(tool[:error] || body[:error]),
87
- caller_identity: caller_identity[:identity],
88
- agent_id: agent[:id],
89
- classification_level: cls[:level] || headers['x-legion-classification'],
90
- contains_phi: Helpers::Queries.phi_flag?(cls, headers),
91
- retention_policy: headers['x-legion-retention'] || 'default',
92
- expires_at: expires_at,
93
- tool_start_at: ts[:tool_start] || tool[:started_at],
94
- tool_end_at: ts[:tool_end] || tool[:finished_at],
95
- inserted_at: Time.now.utc
96
- }
218
+ identity_canonical_name: canonical_name,
219
+ identity_principal_id: refs[:principal_id],
220
+ identity_id: refs[:identity_id]
221
+ }.compact
222
+ end
223
+
224
+ def resolve_tool_identity(db, body, raw_identity)
225
+ return {} unless raw_identity
226
+ return {} unless Writers::OfficialRecordWriter.identity_tables_available?(db)
227
+
228
+ # Merge the header-resolved identity string into the body so that
229
+ # OfficialRecordWriter.resolve_identity can find it via
230
+ # parsed_identity_descriptor even when identity came solely from
231
+ # AMQP headers and is absent from the payload body.
232
+ body_with_identity = raw_identity ? body.merge(caller_identity: raw_identity) : body
233
+ Writers::OfficialRecordWriter.resolve_identity(db, body_with_identity)
234
+ rescue StandardError => e
235
+ handle_exception(e, level: :warn, handled: true, operation: 'write_tool_record.identity_resolution')
236
+ {}
237
+ end
238
+
239
+ def derive_tool_call_uuid(body, ctx, tool, headers)
240
+ ref = tool[:id] ||
241
+ ctx[:request_id] ||
242
+ body[:request_id] ||
243
+ headers['x-legion-llm-request-id'] ||
244
+ ctx[:message_id] ||
245
+ (body[:properties] || {})[:message_id]
246
+ stable_uuid("tool_call:#{ref || SecureRandom.uuid}")
247
+ end
248
+
249
+ def derive_attempt_uuid(tool_call_uuid, attempt_no)
250
+ stable_uuid("attempt:#{tool_call_uuid}:#{attempt_no}")
251
+ end
252
+
253
+ def sha256_ref(value)
254
+ return nil if value.nil? # rubocop:disable Legion/Extension/RunnerReturnHash
255
+
256
+ raw = value.is_a?(String) ? value : Helpers::Json.dump(value)
257
+ Digest::SHA256.hexdigest(raw)[0, 64]
258
+ end
259
+
260
+ def stable_uuid(value)
261
+ raw = value.to_s
262
+ return raw if raw.length <= 36 # rubocop:disable Legion/Extension/RunnerReturnHash
263
+
264
+ hex = Digest::SHA256.hexdigest(raw)[0, 32]
265
+ "#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
266
+ end
267
+
268
+ def insert_with_savepoint(db, table, attributes, operation:)
269
+ db.transaction(savepoint: true) do
270
+ Helpers::PersistenceLogging.insert_row(db, table, attributes,
271
+ operation: operation, warn_on_unique: false)
272
+ end
97
273
  end
98
274
 
99
275
  include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) &&
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Llm
6
6
  module Ledger
7
- VERSION = '0.3.3'
7
+ VERSION = '0.4.2'
8
8
  end
9
9
  end
10
10
  end
@@ -58,17 +58,18 @@ module Legion
58
58
  return existing if existing
59
59
 
60
60
  id = insert_with_savepoint(db, :llm_conversations, {
61
- uuid: uuid,
62
- title: body[:title] || body[:conversation_title],
63
- classification_level: classification_level(body),
64
- contains_phi: contains_phi?(body),
65
- contains_pii: contains_pii?(body),
66
- retention_policy: body[:retention_policy] || 'default',
67
- expires_at: body[:expires_at],
68
- recorded_at: recorded_at(body),
69
- inserted_at: Time.now.utc,
70
- created_at: Time.now.utc,
71
- updated_at: Time.now.utc
61
+ uuid: uuid,
62
+ title: body[:title] || body[:conversation_title],
63
+ classification_level: classification_level(body),
64
+ contains_phi: contains_phi?(body),
65
+ contains_pii: contains_pii?(body),
66
+ retention_policy: body[:retention_policy] || 'default',
67
+ expires_at: body[:expires_at],
68
+ identity_canonical_name: identity_canonical_name(body),
69
+ recorded_at: recorded_at(body),
70
+ inserted_at: Time.now.utc,
71
+ created_at: Time.now.utc,
72
+ updated_at: Time.now.utc
72
73
  }, operation: 'official_record_writer.conversation')
73
74
  db[:llm_conversations][id: id]
74
75
  rescue Sequel::UniqueConstraintViolation => e
@@ -87,16 +88,19 @@ module Legion
87
88
  seq = body[:message_seq] ? integer(body[:message_seq]) : next_message_seq(db, conversation)
88
89
  begin
89
90
  id = insert_with_savepoint(db, :llm_messages, {
90
- uuid: uuid,
91
- conversation_id: conversation[:id],
92
- seq: seq,
93
- role: 'user',
94
- content_type: 'text',
95
- content: request_content(body),
96
- input_tokens: tokens(body)[:input_tokens],
97
- output_tokens: 0,
98
- created_at: recorded_at(body),
99
- inserted_at: Time.now.utc
91
+ uuid: uuid,
92
+ conversation_id: conversation[:id],
93
+ seq: seq,
94
+ role: 'user',
95
+ content_type: 'text',
96
+ content: request_content(body),
97
+ input_tokens: tokens(body)[:input_tokens],
98
+ output_tokens: 0,
99
+ identity_principal_id: caller_identity_refs(db, body)[:principal_id],
100
+ identity_id: caller_identity_refs(db, body)[:identity_id],
101
+ identity_canonical_name: identity_canonical_name(body),
102
+ created_at: recorded_at(body),
103
+ inserted_at: Time.now.utc
100
104
  }, operation: 'official_record_writer.user_message')
101
105
  db[:llm_messages][id: id]
102
106
  rescue Sequel::UniqueConstraintViolation => e
@@ -114,28 +118,31 @@ module Legion
114
118
  operation = operation(body)
115
119
  caller_refs = caller_identity_refs(db, body)
116
120
  id = insert_with_savepoint(db, :llm_message_inference_requests, {
117
- uuid: stable_uuid(request_id),
118
- conversation_id: conversation[:id],
119
- latest_message_id: latest_message&.dig(:id),
120
- caller_principal_id: caller_refs[:principal_id],
121
- caller_identity_id: caller_refs[:identity_id],
122
- runtime_caller_type: caller_type(body),
123
- request_ref: request_id,
124
- correlation_ref: correlation_id(body),
125
- correlation_id: correlation_id(body),
126
- exchange_ref: body[:exchange_id],
127
- request_type: operation,
128
- operation: operation,
129
- idempotency_key: body[:idempotency_key] || request_id,
130
- status: 'responded',
131
- context_message_count: Array(body.dig(:request, :messages) || body[:messages]).size,
132
- request_capture_mode: 'full',
133
- request_json: json_dump(request_payload(body)),
134
- classification_level: classification_level(body),
135
- cost_center: billing(body)[:cost_center],
136
- budget_key: billing(body)[:budget_id] || billing(body)[:budget_key],
137
- requested_at: recorded_at(body),
138
- inserted_at: Time.now.utc
121
+ uuid: stable_uuid(request_id),
122
+ conversation_id: conversation[:id],
123
+ latest_message_id: latest_message&.dig(:id),
124
+ caller_principal_id: caller_refs[:principal_id],
125
+ caller_identity_id: caller_refs[:identity_id],
126
+ identity_canonical_name: identity_canonical_name(body),
127
+ runtime_caller_type: caller_type(body),
128
+ runtime_caller_class: runtime_caller_class(body),
129
+ runtime_caller_client: runtime_caller_client(body),
130
+ request_ref: request_id,
131
+ correlation_ref: correlation_id(body),
132
+ correlation_id: correlation_id(body),
133
+ exchange_ref: body[:exchange_id],
134
+ request_type: operation,
135
+ operation: operation,
136
+ idempotency_key: body[:idempotency_key] || request_id,
137
+ status: 'responded',
138
+ context_message_count: Array(body.dig(:request, :messages) || body[:messages]).size,
139
+ request_capture_mode: 'full',
140
+ request_json: json_dump(request_payload(body)),
141
+ classification_level: classification_level(body),
142
+ cost_center: billing(body)[:cost_center],
143
+ budget_key: billing(body)[:budget_id] || billing(body)[:budget_key],
144
+ requested_at: recorded_at(body),
145
+ inserted_at: Time.now.utc
139
146
  }, operation: 'official_record_writer.inference_request')
140
147
  db[:llm_message_inference_requests][id: id]
141
148
  rescue Sequel::UniqueConstraintViolation => e
@@ -165,6 +172,9 @@ module Legion
165
172
  content: response_content(body),
166
173
  input_tokens: 0,
167
174
  output_tokens: tokens(body)[:output_tokens],
175
+ identity_principal_id: caller_identity_refs(db, body)[:principal_id],
176
+ identity_id: caller_identity_refs(db, body)[:identity_id],
177
+ identity_canonical_name: identity_canonical_name(body),
168
178
  created_at: recorded_at(body),
169
179
  inserted_at: Time.now.utc
170
180
  }, operation: 'official_record_writer.response_message')
@@ -202,6 +212,9 @@ module Legion
202
212
  response_json: json_dump(visible_response(body)),
203
213
  response_thinking_json: json_dump(thinking_response(body)),
204
214
  dispatch_path: body[:dispatch_path] || body[:tier],
215
+ identity_principal_id: caller_identity_refs(db, body)[:principal_id],
216
+ identity_id: caller_identity_refs(db, body)[:identity_id],
217
+ identity_canonical_name: identity_canonical_name(body),
205
218
  responded_at: recorded_at(body),
206
219
  inserted_at: Time.now.utc
207
220
  }, operation: 'official_record_writer.inference_response')
@@ -224,6 +237,7 @@ module Legion
224
237
  update_if_missing(updates, existing, :provider_instance, provider_instance(body))
225
238
  update_if_missing(updates, existing, :finish_reason, finish_reason(body))
226
239
  update_if_missing(updates, existing, :dispatch_path, body[:dispatch_path] || body[:tier])
240
+ update_if_missing(updates, existing, :identity_canonical_name, identity_canonical_name(body))
227
241
 
228
242
  response_json = json_dump(visible_response(body))
229
243
  update_if_placeholder(updates, existing, :response_json, response_json)
@@ -268,6 +282,9 @@ module Legion
268
282
  currency: body[:currency] || 'USD',
269
283
  cost_center: billing(body)[:cost_center],
270
284
  budget_key: billing(body)[:budget_id] || billing(body)[:budget_key],
285
+ identity_principal_id: caller_identity_refs(db, body)[:principal_id],
286
+ identity_id: caller_identity_refs(db, body)[:identity_id],
287
+ identity_canonical_name: identity_canonical_name(body),
271
288
  recorded_at: recorded_at(body),
272
289
  inserted_at: Time.now.utc
273
290
  }, operation: 'official_record_writer.inference_metric')
@@ -310,6 +327,9 @@ module Legion
310
327
  updates[:caller_identity_id] = caller_refs[:identity_id] if existing[:caller_identity_id].nil? && caller_refs[:identity_id]
311
328
  updates[:caller_principal_id] = caller_refs[:principal_id] if existing[:caller_principal_id].nil? && caller_refs[:principal_id]
312
329
  updates[:runtime_caller_type] = caller_type(body) if existing[:runtime_caller_type].nil? && caller_type(body)
330
+ update_if_missing(updates, existing, :runtime_caller_class, runtime_caller_class(body))
331
+ update_if_missing(updates, existing, :runtime_caller_client, runtime_caller_client(body))
332
+ update_if_missing(updates, existing, :identity_canonical_name, identity_canonical_name(body))
313
333
 
314
334
  request_json = json_dump(request_payload(body))
315
335
  updates[:request_json] = request_json if existing[:request_json].to_s == '{}' && request_json != '{}'
@@ -342,6 +362,16 @@ module Legion
342
362
  parsed_identity_descriptor(body)[:kind]
343
363
  end
344
364
 
365
+ def runtime_caller_class(body)
366
+ body.dig(:caller, :class) || body.dig(:caller, :caller_class) ||
367
+ body.dig(:caller, :source_class) || body[:runtime_caller_class]
368
+ end
369
+
370
+ def runtime_caller_client(body)
371
+ body.dig(:caller, :client) || body.dig(:caller, :user_agent) ||
372
+ body[:runtime_caller_client]
373
+ end
374
+
345
375
  def caller_identity_refs(db, body)
346
376
  body[:__ledger_caller_identity_refs] ||= begin
347
377
  explicit_identity_id = integer_or_nil(body[:caller_identity_id] || body.dig(:caller, :requested_by, :id))
@@ -691,6 +721,10 @@ module Legion
691
721
  end
692
722
  end
693
723
 
724
+ def identity_canonical_name(body)
725
+ parsed_identity_descriptor(body)[:canonical_name]
726
+ end
727
+
694
728
  def present?(value)
695
729
  !value.nil? && !(value.respond_to?(:empty?) && value.empty?)
696
730
  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.3.3
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 1.8.0
18
+ version: 1.8.7
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: 1.8.0
25
+ version: 1.8.7
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: legion-json
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -207,6 +207,8 @@ files:
207
207
  - lib/legion/extensions/llm/ledger/data/migrations/008_relax_message_id_not_null.rb
208
208
  - lib/legion/extensions/llm/ledger/data/migrations/009_add_caller_to_metering.rb
209
209
  - lib/legion/extensions/llm/ledger/data/migrations/010_add_response_thinking_json_to_prompt_records.rb
210
+ - lib/legion/extensions/llm/ledger/data/migrations/011_add_identity_columns_to_registry_availability.rb
211
+ - lib/legion/extensions/llm/ledger/data/migrations/012_archive_legacy_tables.rb
210
212
  - lib/legion/extensions/llm/ledger/helpers/caller_identity.rb
211
213
  - lib/legion/extensions/llm/ledger/helpers/decryption.rb
212
214
  - lib/legion/extensions/llm/ledger/helpers/json.rb