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 +4 -4
- data/CHANGELOG.md +41 -0
- data/lex-llm-ledger.gemspec +1 -1
- data/lib/legion/extensions/llm/ledger/actors/registry_availability.rb +2 -0
- data/lib/legion/extensions/llm/ledger/backfill/legacy_llm_records.rb +6 -6
- data/lib/legion/extensions/llm/ledger/data/migrations/011_add_identity_columns_to_registry_availability.rb +30 -0
- data/lib/legion/extensions/llm/ledger/data/migrations/012_archive_legacy_tables.rb +15 -0
- data/lib/legion/extensions/llm/ledger/runners/registry_availability.rb +45 -29
- data/lib/legion/extensions/llm/ledger/runners/tools.rb +221 -45
- data/lib/legion/extensions/llm/ledger/version.rb +1 -1
- data/lib/legion/extensions/llm/ledger/writers/official_record_writer.rb +77 -43
- metadata +5 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9d96639d6f6bf123c338c0df58c4e25d1375d0102873318197f6297eeda70a80
|
|
4
|
+
data.tar.gz: 1ebcbc88558e3783595e3c07c13eb0f9d0f3fbb82a2c096cb52752592c469d2a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lex-llm-ledger.gemspec
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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 :
|
|
46
|
+
when :z_archive_llm_prompt_records
|
|
47
47
|
backfill_prompt(row)
|
|
48
|
-
when :
|
|
48
|
+
when :z_archive_llm_metering_records
|
|
49
49
|
backfill_metering(row)
|
|
50
|
-
when :
|
|
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
|
-
|
|
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:
|
|
48
|
-
message_id:
|
|
49
|
-
correlation_id:
|
|
50
|
-
routing_key:
|
|
51
|
-
event_type:
|
|
52
|
-
occurred_at:
|
|
53
|
-
offering_id:
|
|
54
|
-
provider_family:
|
|
55
|
-
provider_instance:
|
|
56
|
-
instance_id:
|
|
57
|
-
model_family:
|
|
58
|
-
model_id:
|
|
59
|
-
canonical_model:
|
|
60
|
-
provider_model:
|
|
61
|
-
usage_type:
|
|
62
|
-
transport:
|
|
63
|
-
lane_key:
|
|
64
|
-
worker_id:
|
|
65
|
-
node_id:
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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:
|
|
201
|
+
caller_raw: body[:caller],
|
|
202
|
+
identity: body[:identity],
|
|
203
|
+
headers: headers
|
|
66
204
|
)
|
|
67
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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) &&
|
|
@@ -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:
|
|
62
|
-
title:
|
|
63
|
-
classification_level:
|
|
64
|
-
contains_phi:
|
|
65
|
-
contains_pii:
|
|
66
|
-
retention_policy:
|
|
67
|
-
expires_at:
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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:
|
|
91
|
-
conversation_id:
|
|
92
|
-
seq:
|
|
93
|
-
role:
|
|
94
|
-
content_type:
|
|
95
|
-
content:
|
|
96
|
-
input_tokens:
|
|
97
|
-
output_tokens:
|
|
98
|
-
|
|
99
|
-
|
|
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:
|
|
118
|
-
conversation_id:
|
|
119
|
-
latest_message_id:
|
|
120
|
-
caller_principal_id:
|
|
121
|
-
caller_identity_id:
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|