lex-llm-ledger 0.3.3 → 0.4.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: bfcca825db5b0e313cbeafc6c9f396b3f964dd4debd911854c89affe3bcb79ef
4
- data.tar.gz: 1a4ea16c0c4ca34cc1ae4e3d0efa19cab3948c932162d9072c21127dae1ea375
3
+ metadata.gz: b4fb5492195845786b98e664192f84716d049c4144e81e28c5627799cf169b99
4
+ data.tar.gz: fc6b2b70fbfda9e98c6e7a6e942c81f65193e5c65f46856a50b4fa64ec681972
5
5
  SHA512:
6
- metadata.gz: 362b96b3b385cfcca6c2edbe3fa9c6e30a23fbaa80bd5136fa1f95cc35af8ea5aa93ea89cd7cf1415999ac82ce881311aeea084ee44ba19cffadcbef0858a71b
7
- data.tar.gz: 89c63b437c02355a9cc39182d42ac0a1971b7dcdf80473d7ef237d92bbb39cef96c13dd0ea3b34f13d6ed1f662cb6bb30aa473658dde119739b641bbf7b92dac
6
+ metadata.gz: e85919d09a173cca54608528ceb4c50a0350912a52c81a5948fff7cf483f9a428e078cbf948b7f90237191335e0e1165ff103d22864df6ffc794c2b922394b9a
7
+ data.tar.gz: 6de04faae342441c24754f9b186fa92e49b6ab971ad8e4935175ad7206a58da5e288fef33791e97ed785c01f2ca0198606f82b064812a085e69530cf578dfe8d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0] - 2026-05-17
4
+
5
+ ### Changed
6
+ - Rewrite `Runners::Tools` to write tool audit events to the official `llm_tool_calls` and
7
+ `llm_tool_call_attempts` tables instead of the legacy `llm_tool_records` table.
8
+ - Each tool audit event produces one `llm_tool_calls` row (linked to the parent
9
+ `llm_message_inference_responses` row) and one `llm_tool_call_attempts` row containing
10
+ the execution outcome.
11
+ - Argument and result payloads are stored as SHA-256 fingerprints in `arguments_ref` /
12
+ `result_ref` (the official schema columns are 255-char refs, not full JSON blobs).
13
+ - Idempotency is enforced via UUID derived from `tool_call.id` (or request/message context);
14
+ a second write for the same tool call returns `{ result: :duplicate }`.
15
+ - Tool call writes that cannot be linked to an existing inference response are logged at
16
+ `warn` and dropped gracefully (returns `{ result: :ok }`).
17
+ - Populate `identity_canonical_name` on every insert in `OfficialRecordWriter`:
18
+ `llm_conversations`, `llm_messages` (user and assistant), `llm_message_inference_requests`,
19
+ `llm_message_inference_responses`, and `llm_message_inference_metrics`.
20
+ - Populate `identity_principal_id` and `identity_id` on inserts into `llm_messages`,
21
+ `llm_message_inference_responses`, and `llm_message_inference_metrics`.
22
+ - Backfill `identity_canonical_name` in `enrich_request!` and `enrich_response!` when the
23
+ enrichment opportunity arrives after a metering-first write.
24
+ - `Runners::Tools` extracts identity via `CallerIdentity.normalize` and writes
25
+ `identity_canonical_name`, `identity_principal_id`, and `identity_id` to both
26
+ `llm_tool_calls` and `llm_tool_call_attempts`.
27
+ - `Runners::RegistryAvailability` writes `identity_canonical_name` from AMQP headers or
28
+ body identity fields when present; `identity_principal_id` and `identity_id` FK columns
29
+ are not resolved because node/service identities may not be registered in identity tables.
30
+
3
31
  ## [0.3.3] - 2026-05-17
4
32
 
5
33
  ### 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'
@@ -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'
@@ -21,19 +23,25 @@ module Legion
21
23
  ctx = body[:message_context] || {}
22
24
  tool = body[:tool_call] || {}
23
25
 
24
- expires_at = Helpers::Retention.resolve(
26
+ Helpers::Retention.resolve(
25
27
  retention: headers['x-legion-retention'],
26
28
  contains_phi: headers['x-legion-contains-phi'] == 'true'
27
29
  )
28
30
 
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 }
31
+ db = ::Legion::Data.connection
32
+ write_result = [:ok]
33
+ db.transaction do
34
+ response = find_or_resolve_response(db, body, ctx, props, headers)
35
+ identity_attrs = extract_identity_attrs(body, headers, db)
36
+ tool_call_row, new_tool_call = find_or_create_tool_call(db, response, body, ctx, tool, headers, identity_attrs)
37
+ if tool_call_row && !new_tool_call
38
+ write_result[0] = :duplicate
39
+ elsif new_tool_call
40
+ find_or_create_tool_call_attempt(db, tool_call_row, tool, body, props, headers, identity_attrs)
41
+ end
42
+ end
43
+
44
+ { result: write_result[0] }
37
45
  rescue Sequel::UniqueConstraintViolation => e
38
46
  log.warn("write_tool_record duplicate insert ignored: #{e.message}")
39
47
  { result: :duplicate }
@@ -54,46 +62,202 @@ module Legion
54
62
  Helpers::SubscriptionMessage.runner_args(payload, metadata, message)
55
63
  end
56
64
 
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] || {}
65
+ # Resolve the llm_message_inference_responses row this tool call belongs to.
66
+ # Returns nil if we cannot link at all.
67
+ def find_or_resolve_response(db, body, ctx, props, headers)
68
+ request_ref = ctx[:request_id] || body[:request_id] ||
69
+ props[:correlation_id] || headers['x-legion-llm-request-id']
70
+ return nil unless request_ref # rubocop:disable Legion/Extension/RunnerReturnHash
71
+
72
+ request = db[:llm_message_inference_requests].where(request_ref: request_ref).first
73
+ return nil unless request # rubocop:disable Legion/Extension/RunnerReturnHash
74
+
75
+ db[:llm_message_inference_responses]
76
+ .where(message_inference_request_id: request[:id]).first
77
+ end
78
+
79
+ def find_or_create_tool_call(db, response, body, ctx, tool, headers, identity_attrs)
80
+ tool_uuid = derive_tool_call_uuid(body, ctx, tool, headers)
81
+ existing = db[:llm_tool_calls].where(uuid: tool_uuid).first
82
+ return [existing, false] if existing # rubocop:disable Legion/Extension/RunnerReturnHash
83
+
84
+ response_id = resolve_response_id(db, response, body, ctx, headers, tool_uuid)
85
+ return [nil, false] unless response_id # rubocop:disable Legion/Extension/RunnerReturnHash
86
+
87
+ next_index = db[:llm_tool_calls]
88
+ .where(message_inference_response_id: response_id)
89
+ .max(:tool_call_index).to_i + 1
90
+
91
+ src = tool[:source] || {}
92
+ status = tool[:status] || headers['x-legion-tool-status'] || 'success'
93
+ ts = body[:timestamps] || {}
94
+
95
+ id = insert_with_savepoint(db, :llm_tool_calls, {
96
+ uuid: tool_uuid,
97
+ message_inference_response_id: response_id,
98
+ tool_call_index: next_index,
99
+ provider_tool_call_ref: tool[:id],
100
+ tool_name: tool[:name] || headers['x-legion-tool-name'],
101
+ tool_source_type: src[:type] || headers['x-legion-tool-source-type'],
102
+ tool_source_server: src[:server] || headers['x-legion-tool-source-server'],
103
+ status: status,
104
+ requested_at: ts[:tool_start] || tool[:started_at],
105
+ completed_at: ts[:tool_end] || tool[:finished_at],
106
+ **identity_attrs,
107
+ inserted_at: Time.now.utc
108
+ }, operation: 'write_tool_record.tool_call')
109
+ [db[:llm_tool_calls][id: id], true]
110
+ rescue Sequel::UniqueConstraintViolation => e
111
+ log.debug("[ledger] tool_call collision resolved uuid=#{tool_uuid} error=#{e.class}")
112
+ row = db[:llm_tool_calls].where(uuid: tool_uuid).first
113
+ raise(e) unless row
114
+
115
+ [row, false]
116
+ end
117
+
118
+ # Extract or fall back to find a response_id for linking the tool call.
119
+ def resolve_response_id(db, response, body, ctx, headers, tool_uuid)
120
+ return response[:id] if response # rubocop:disable Legion/Extension/RunnerReturnHash
121
+
122
+ fallback = fallback_response_for_conversation(db, body, ctx, headers)
123
+ return fallback[:id] if fallback # rubocop:disable Legion/Extension/RunnerReturnHash
124
+
125
+ log.warn("[ledger] write_tool_record: no response row found for tool call uuid=#{tool_uuid}, skipping")
126
+ nil
127
+ end
128
+
129
+ def fallback_response_for_conversation(db, body, ctx, headers)
130
+ conv_id = ctx[:conversation_id] || body[:conversation_id] ||
131
+ headers['x-legion-llm-conversation-id']
132
+ return nil unless conv_id # rubocop:disable Legion/Extension/RunnerReturnHash
133
+
134
+ conv = db[:llm_conversations].where(uuid: stable_uuid(conv_id)).first ||
135
+ db[:llm_conversations].where(uuid: conv_id).first
136
+ return nil unless conv # rubocop:disable Legion/Extension/RunnerReturnHash
137
+
138
+ db[:llm_message_inference_responses]
139
+ .join(:llm_message_inference_requests,
140
+ id: :message_inference_request_id)
141
+ .where(Sequel[:llm_message_inference_requests][:conversation_id] => conv[:id])
142
+ .order(Sequel.desc(Sequel[:llm_message_inference_responses][:id]))
143
+ .select_all(:llm_message_inference_responses)
144
+ .first
145
+ end
146
+
147
+ def find_or_create_tool_call_attempt(db, tool_call_row, tool, body, props, headers, identity_attrs) # rubocop:disable Metrics/CyclomaticComplexity
148
+ return nil unless tool_call_row # rubocop:disable Legion/Extension/RunnerReturnHash
149
+
150
+ tool_call_id = tool_call_row[:id]
151
+ attempt_no = db[:llm_tool_call_attempts]
152
+ .where(tool_call_id: tool_call_id).max(:attempt_no).to_i + 1
153
+ attempt_uuid = derive_attempt_uuid(tool_call_row[:uuid], attempt_no)
154
+
155
+ existing = db[:llm_tool_call_attempts].where(uuid: attempt_uuid).first
156
+ return existing if existing # rubocop:disable Legion/Extension/RunnerReturnHash
157
+
158
+ status = tool[:status] || headers['x-legion-tool-status'] || 'success'
159
+ error_info = tool[:error] || body[:error]
160
+ error_hash = error_info.is_a?(Hash) ? error_info : {}
161
+ ts = body[:timestamps] || {}
162
+ runner_ref = body[:worker_id] || body[:runner_ref] || props[:app_id]
163
+
164
+ id = insert_with_savepoint(db, :llm_tool_call_attempts, {
165
+ uuid: attempt_uuid,
166
+ tool_call_id: tool_call_id,
167
+ attempt_no: attempt_no,
168
+ runner_ref: runner_ref,
169
+ status: status,
170
+ error_category: error_hash[:category] || error_hash[:type],
171
+ error_code: error_hash[:code],
172
+ error_message: error_info.is_a?(String) ? error_info : error_hash[:message],
173
+ duration_ms: tool[:duration_ms].to_i,
174
+ arguments_ref: sha256_ref(tool[:arguments]),
175
+ result_ref: sha256_ref(tool[:result] || body[:result]),
176
+ started_at: ts[:tool_start] || tool[:started_at],
177
+ ended_at: ts[:tool_end] || tool[:finished_at],
178
+ **identity_attrs,
179
+ inserted_at: Time.now.utc
180
+ }, operation: 'write_tool_record.attempt')
181
+ db[:llm_tool_call_attempts][id: id]
182
+ rescue Sequel::UniqueConstraintViolation => e
183
+ log.debug("[ledger] tool_call_attempt collision resolved uuid=#{attempt_uuid} error=#{e.class}")
184
+ db[:llm_tool_call_attempts].where(uuid: attempt_uuid).first || raise(e)
185
+ end
186
+
187
+ def extract_identity_attrs(body, headers, db)
64
188
  caller_identity = Helpers::CallerIdentity.normalize(
65
- caller_raw: caller_raw, identity: identity, headers: headers
189
+ caller_raw: body[:caller],
190
+ identity: body[:identity],
191
+ headers: headers
66
192
  )
67
- agent = body[:agent] || {}
193
+ # raw_identity may carry a "type:value" prefix that OfficialRecordWriter
194
+ # knows how to parse; keep it intact for FK resolution.
195
+ raw_identity = caller_identity[:identity]
196
+ canonical_name = raw_identity
197
+ # Strip "type:" prefix added by CallerIdentity for generic identities
198
+ if canonical_name&.include?(':') && !canonical_name&.include?('@')
199
+ _prefix, remainder = canonical_name.split(':', 2)
200
+ canonical_name = remainder if remainder && !remainder.empty?
201
+ end
202
+
203
+ refs = resolve_tool_identity(db, body, raw_identity)
68
204
 
69
205
  {
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
- }
206
+ identity_canonical_name: canonical_name,
207
+ identity_principal_id: refs[:principal_id],
208
+ identity_id: refs[:identity_id]
209
+ }.compact
210
+ end
211
+
212
+ def resolve_tool_identity(db, body, raw_identity)
213
+ return {} unless raw_identity
214
+ return {} unless Writers::OfficialRecordWriter.identity_tables_available?(db)
215
+
216
+ # Merge the header-resolved identity string into the body so that
217
+ # OfficialRecordWriter.resolve_identity can find it via
218
+ # parsed_identity_descriptor even when identity came solely from
219
+ # AMQP headers and is absent from the payload body.
220
+ body_with_identity = raw_identity ? body.merge(caller_identity: raw_identity) : body
221
+ Writers::OfficialRecordWriter.resolve_identity(db, body_with_identity)
222
+ rescue StandardError => e
223
+ handle_exception(e, level: :warn, handled: true, operation: 'write_tool_record.identity_resolution')
224
+ {}
225
+ end
226
+
227
+ def derive_tool_call_uuid(body, ctx, tool, headers)
228
+ ref = tool[:id] ||
229
+ ctx[:request_id] ||
230
+ body[:request_id] ||
231
+ headers['x-legion-llm-request-id'] ||
232
+ ctx[:message_id] ||
233
+ (body[:properties] || {})[:message_id]
234
+ stable_uuid("tool_call:#{ref || SecureRandom.uuid}")
235
+ end
236
+
237
+ def derive_attempt_uuid(tool_call_uuid, attempt_no)
238
+ stable_uuid("attempt:#{tool_call_uuid}:#{attempt_no}")
239
+ end
240
+
241
+ def sha256_ref(value)
242
+ return nil if value.nil? # rubocop:disable Legion/Extension/RunnerReturnHash
243
+
244
+ raw = value.is_a?(String) ? value : Helpers::Json.dump(value)
245
+ Digest::SHA256.hexdigest(raw)[0, 64]
246
+ end
247
+
248
+ def stable_uuid(value)
249
+ raw = value.to_s
250
+ return raw if raw.length <= 36 # rubocop:disable Legion/Extension/RunnerReturnHash
251
+
252
+ hex = Digest::SHA256.hexdigest(raw)[0, 32]
253
+ "#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
254
+ end
255
+
256
+ def insert_with_savepoint(db, table, attributes, operation:)
257
+ db.transaction(savepoint: true) do
258
+ Helpers::PersistenceLogging.insert_row(db, table, attributes,
259
+ operation: operation, warn_on_unique: false)
260
+ end
97
261
  end
98
262
 
99
263
  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.0'
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.0
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