lex-llm-ledger 0.3.2 → 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: 84be271a1d8b2960d1eeef98a01c950ed2d44cab4e2e6d4c8dd6708980d027d4
4
- data.tar.gz: 6da1a77c998891e3b2e1a5fae24b1109c51715c762b52aa308ebb73abb886188
3
+ metadata.gz: b4fb5492195845786b98e664192f84716d049c4144e81e28c5627799cf169b99
4
+ data.tar.gz: fc6b2b70fbfda9e98c6e7a6e942c81f65193e5c65f46856a50b4fa64ec681972
5
5
  SHA512:
6
- metadata.gz: 51705c939e26737dfda07ac40df418887d15550e758b4db28aea1e377579b8c65108614cd5abd1ef6a309696c4b90149d1d3dacce6507011a219b90edb7d3a1e
7
- data.tar.gz: 163077fbadc935cdeb751645fc59d06a59bf3cd47db7d93d427b721cbdc067f6749c0d6adf6641b1ded3c160c03a252f2382b383d778068b7f59ececcb1b6bb0
6
+ metadata.gz: e85919d09a173cca54608528ceb4c50a0350912a52c81a5948fff7cf483f9a428e078cbf948b7f90237191335e0e1165ff103d22864df6ffc794c2b922394b9a
7
+ data.tar.gz: 6de04faae342441c24754f9b186fa92e49b6ab971ad8e4935175ad7206a58da5e288fef33791e97ed785c01f2ca0198606f82b064812a085e69530cf578dfe8d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
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
+
31
+ ## [0.3.3] - 2026-05-17
32
+
33
+ ### Fixed
34
+ - Extract inline `<think>` / `<thinking>` tags from string responses into `response_thinking_json` at write time instead of leaving them in `response_json`.
35
+ - Fall back to `ThinkingExtractor` when `response_thinking` is absent from the audit payload (covers Ollama, vLLM, and OpenAI-compatible gateways that pass thinking inline).
36
+ - Guard `finish_reason` and `thinking_response` against `String#dig` TypeError when `body[:response]` is a plain string.
37
+
3
38
  ## [0.3.2] - 2026-05-13
4
39
 
5
40
  ### 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 'legion/extensions/llm/responses/thinking_extractor'
3
4
  require_relative '../helpers/caller_identity'
4
5
  require_relative '../helpers/json'
5
6
 
@@ -96,7 +97,30 @@ module Legion
96
97
  end
97
98
 
98
99
  def response_thinking(body)
99
- body[:response_thinking] || body[:thinking] || body.dig(:response, :thinking) || {}
100
+ thinking = body[:response_thinking] || body[:thinking]
101
+ thinking ||= body.dig(:response, :thinking) if body[:response].is_a?(Hash)
102
+ if thinking
103
+ thinking.is_a?(Hash) ? thinking : { content: thinking }
104
+ else
105
+ extract_thinking_from_content(body)
106
+ end
107
+ end
108
+
109
+ def extract_thinking_from_content(body)
110
+ content_str = body[:response_content] || body[:response] || body[:content]
111
+ return {} unless content_str.is_a?(String)
112
+
113
+ _clean, extracted = extract_inline_thinking(content_str)
114
+ extracted ? { content: extracted } : {}
115
+ end
116
+
117
+ def extract_inline_thinking(text)
118
+ if defined?(::Legion::Extensions::Llm::Responses::ThinkingExtractor)
119
+ extraction = ::Legion::Extensions::Llm::Responses::ThinkingExtractor.extract(text)
120
+ [extraction.content, extraction.thinking]
121
+ else
122
+ [text, nil]
123
+ end
100
124
  end
101
125
 
102
126
  def official_prompt_payload(body, ctx, props, headers, expires_at)
@@ -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.2'
7
+ VERSION = '0.4.0'
8
8
  end
9
9
  end
10
10
  end
@@ -3,6 +3,7 @@
3
3
  require 'digest'
4
4
  require 'securerandom'
5
5
  require 'legion/logging'
6
+ require 'legion/extensions/llm/responses/thinking_extractor'
6
7
  require_relative '../helpers/json'
7
8
  require_relative '../helpers/persistence_logging'
8
9
 
@@ -57,17 +58,18 @@ module Legion
57
58
  return existing if existing
58
59
 
59
60
  id = insert_with_savepoint(db, :llm_conversations, {
60
- uuid: uuid,
61
- title: body[:title] || body[:conversation_title],
62
- classification_level: classification_level(body),
63
- contains_phi: contains_phi?(body),
64
- contains_pii: contains_pii?(body),
65
- retention_policy: body[:retention_policy] || 'default',
66
- expires_at: body[:expires_at],
67
- recorded_at: recorded_at(body),
68
- inserted_at: Time.now.utc,
69
- created_at: Time.now.utc,
70
- 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
71
73
  }, operation: 'official_record_writer.conversation')
72
74
  db[:llm_conversations][id: id]
73
75
  rescue Sequel::UniqueConstraintViolation => e
@@ -86,16 +88,19 @@ module Legion
86
88
  seq = body[:message_seq] ? integer(body[:message_seq]) : next_message_seq(db, conversation)
87
89
  begin
88
90
  id = insert_with_savepoint(db, :llm_messages, {
89
- uuid: uuid,
90
- conversation_id: conversation[:id],
91
- seq: seq,
92
- role: 'user',
93
- content_type: 'text',
94
- content: request_content(body),
95
- input_tokens: tokens(body)[:input_tokens],
96
- output_tokens: 0,
97
- created_at: recorded_at(body),
98
- 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
99
104
  }, operation: 'official_record_writer.user_message')
100
105
  db[:llm_messages][id: id]
101
106
  rescue Sequel::UniqueConstraintViolation => e
@@ -113,28 +118,31 @@ module Legion
113
118
  operation = operation(body)
114
119
  caller_refs = caller_identity_refs(db, body)
115
120
  id = insert_with_savepoint(db, :llm_message_inference_requests, {
116
- uuid: stable_uuid(request_id),
117
- conversation_id: conversation[:id],
118
- latest_message_id: latest_message&.dig(:id),
119
- caller_principal_id: caller_refs[:principal_id],
120
- caller_identity_id: caller_refs[:identity_id],
121
- runtime_caller_type: caller_type(body),
122
- request_ref: request_id,
123
- correlation_ref: correlation_id(body),
124
- correlation_id: correlation_id(body),
125
- exchange_ref: body[:exchange_id],
126
- request_type: operation,
127
- operation: operation,
128
- idempotency_key: body[:idempotency_key] || request_id,
129
- status: 'responded',
130
- context_message_count: Array(body.dig(:request, :messages) || body[:messages]).size,
131
- request_capture_mode: 'full',
132
- request_json: json_dump(request_payload(body)),
133
- classification_level: classification_level(body),
134
- cost_center: billing(body)[:cost_center],
135
- budget_key: billing(body)[:budget_id] || billing(body)[:budget_key],
136
- requested_at: recorded_at(body),
137
- 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
138
146
  }, operation: 'official_record_writer.inference_request')
139
147
  db[:llm_message_inference_requests][id: id]
140
148
  rescue Sequel::UniqueConstraintViolation => e
@@ -164,6 +172,9 @@ module Legion
164
172
  content: response_content(body),
165
173
  input_tokens: 0,
166
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),
167
178
  created_at: recorded_at(body),
168
179
  inserted_at: Time.now.utc
169
180
  }, operation: 'official_record_writer.response_message')
@@ -201,6 +212,9 @@ module Legion
201
212
  response_json: json_dump(visible_response(body)),
202
213
  response_thinking_json: json_dump(thinking_response(body)),
203
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),
204
218
  responded_at: recorded_at(body),
205
219
  inserted_at: Time.now.utc
206
220
  }, operation: 'official_record_writer.inference_response')
@@ -223,6 +237,7 @@ module Legion
223
237
  update_if_missing(updates, existing, :provider_instance, provider_instance(body))
224
238
  update_if_missing(updates, existing, :finish_reason, finish_reason(body))
225
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))
226
241
 
227
242
  response_json = json_dump(visible_response(body))
228
243
  update_if_placeholder(updates, existing, :response_json, response_json)
@@ -267,6 +282,9 @@ module Legion
267
282
  currency: body[:currency] || 'USD',
268
283
  cost_center: billing(body)[:cost_center],
269
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),
270
288
  recorded_at: recorded_at(body),
271
289
  inserted_at: Time.now.utc
272
290
  }, operation: 'official_record_writer.inference_metric')
@@ -309,6 +327,9 @@ module Legion
309
327
  updates[:caller_identity_id] = caller_refs[:identity_id] if existing[:caller_identity_id].nil? && caller_refs[:identity_id]
310
328
  updates[:caller_principal_id] = caller_refs[:principal_id] if existing[:caller_principal_id].nil? && caller_refs[:principal_id]
311
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))
312
333
 
313
334
  request_json = json_dump(request_payload(body))
314
335
  updates[:request_json] = request_json if existing[:request_json].to_s == '{}' && request_json != '{}'
@@ -341,6 +362,16 @@ module Legion
341
362
  parsed_identity_descriptor(body)[:kind]
342
363
  end
343
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
+
344
375
  def caller_identity_refs(db, body)
345
376
  body[:__ledger_caller_identity_refs] ||= begin
346
377
  explicit_identity_id = integer_or_nil(body[:caller_identity_id] || body.dig(:caller, :requested_by, :id))
@@ -580,18 +611,38 @@ module Legion
580
611
 
581
612
  def visible_response(body)
582
613
  response = body[:response] || body[:response_content] || body[:content] || {}
583
- return { content: response } if response.is_a?(String)
614
+ if response.is_a?(String)
615
+ clean, _thinking = extract_inline_thinking(response)
616
+ return { content: clean }
617
+ end
584
618
  return { content: response[:content] } if response.is_a?(Hash) && response.key?(:content)
585
619
 
586
620
  response.is_a?(Hash) ? response.except(:thinking) : { content: response.to_s }
587
621
  end
588
622
 
589
623
  def thinking_response(body)
590
- thinking = body[:response_thinking] || body[:thinking] || body.dig(:response, :thinking)
591
- return {} if thinking.nil?
592
- return { content: thinking } if thinking.is_a?(String)
624
+ thinking = body[:response_thinking] || body[:thinking]
625
+ thinking ||= body.dig(:response, :thinking) if body[:response].is_a?(Hash)
626
+ if thinking
627
+ return { content: thinking } if thinking.is_a?(String)
628
+
629
+ return thinking
630
+ end
631
+
632
+ content_str = body[:response_content] || body[:response] || body[:content]
633
+ return {} unless content_str.is_a?(String)
593
634
 
594
- thinking
635
+ _clean, extracted = extract_inline_thinking(content_str)
636
+ extracted ? { content: extracted } : {}
637
+ end
638
+
639
+ def extract_inline_thinking(text)
640
+ if defined?(::Legion::Extensions::Llm::Responses::ThinkingExtractor)
641
+ extraction = ::Legion::Extensions::Llm::Responses::ThinkingExtractor.extract(text)
642
+ [extraction.content, extraction.thinking]
643
+ else
644
+ [text, nil]
645
+ end
595
646
  end
596
647
 
597
648
  def response_content(body)
@@ -599,7 +650,10 @@ module Legion
599
650
  end
600
651
 
601
652
  def finish_reason(body)
602
- body[:finish_reason] || body.dig(:response, :finish_reason) || body.dig(:response, :stop, :reason)
653
+ return body[:finish_reason] if body[:finish_reason]
654
+ return nil unless body[:response].is_a?(Hash)
655
+
656
+ body.dig(:response, :finish_reason) || body.dig(:response, :stop, :reason)
603
657
  end
604
658
 
605
659
  def classification_level(body)
@@ -667,6 +721,10 @@ module Legion
667
721
  end
668
722
  end
669
723
 
724
+ def identity_canonical_name(body)
725
+ parsed_identity_descriptor(body)[:canonical_name]
726
+ end
727
+
670
728
  def present?(value)
671
729
  !value.nil? && !(value.respond_to?(:empty?) && value.empty?)
672
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.2
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