lex-llm 0.7.3 → 0.8.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 +4 -4
- data/CHANGELOG.md +106 -0
- data/RULES.md +97 -0
- data/lib/legion/extensions/llm/auto_registration.rb +4 -13
- data/lib/legion/extensions/llm/canonical/chunk.rb +106 -92
- data/lib/legion/extensions/llm/canonical/content_block.rb +66 -75
- data/lib/legion/extensions/llm/canonical/message.rb +52 -67
- data/lib/legion/extensions/llm/canonical/params.rb +59 -33
- data/lib/legion/extensions/llm/canonical/request.rb +67 -54
- data/lib/legion/extensions/llm/canonical/response.rb +61 -76
- data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
- data/lib/legion/extensions/llm/canonical/thinking.rb +99 -51
- data/lib/legion/extensions/llm/canonical/tool_call.rb +42 -51
- data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
- data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
- data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
- data/lib/legion/extensions/llm/canonical.rb +6 -5
- data/lib/legion/extensions/llm/configuration.rb +40 -10
- data/lib/legion/extensions/llm/connection.rb +8 -30
- data/lib/legion/extensions/llm/credential_sources.rb +32 -49
- data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
- data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
- data/lib/legion/extensions/llm/error.rb +0 -14
- data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
- data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
- data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
- data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
- data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -153
- data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
- data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -150
- data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
- data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
- data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
- data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
- data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
- data/lib/legion/extensions/llm/inventory/records.rb +82 -99
- data/lib/legion/extensions/llm/inventory/registry.rb +54 -46
- data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
- data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +249 -0
- data/lib/legion/extensions/llm/inventory/weight_schema.rb +146 -0
- data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +172 -182
- data/lib/legion/extensions/llm/provider.rb +192 -315
- data/lib/legion/extensions/llm/provider_contract.rb +25 -8
- data/lib/legion/extensions/llm/provider_settings.rb +5 -26
- data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
- data/lib/legion/extensions/llm/responses/tool_arguments.rb +34 -0
- data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
- data/lib/legion/extensions/llm/routing/records.rb +34 -17
- data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
- data/lib/legion/extensions/llm/streaming.rb +50 -35
- data/lib/legion/extensions/llm/taxonomies.rb +29 -17
- data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
- data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
- data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
- data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
- data/lib/legion/extensions/llm/utils.rb +23 -5
- data/lib/legion/extensions/llm/version.rb +1 -1
- data/lib/legion/extensions/llm.rb +10 -98
- data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
- data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
- data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
- data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
- data/spec/legion/extensions/llm/canonical/params_spec.rb +57 -136
- data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
- data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
- data/spec/legion/extensions/llm/canonical/thinking_spec.rb +68 -148
- data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
- data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
- data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
- data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
- data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
- data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
- data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
- data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
- data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +130 -0
- data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +507 -0
- data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
- data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
- data/spec/legion/extensions/llm/error_spec.rb +2 -12
- data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
- data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +178 -67
- data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
- data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +101 -62
- data/spec/legion/extensions/llm/fleet_messages_spec.rb +118 -123
- data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
- data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
- data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
- data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
- data/spec/legion/extensions/llm/inventory/records_spec.rb +129 -41
- data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +55 -4
- data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
- data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
- data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +312 -0
- data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +141 -0
- data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +105 -68
- data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
- data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
- data/spec/legion/extensions/llm/provider_spec.rb +98 -78
- data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
- data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
- data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
- data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -16
- data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
- data/spec/legion/extensions/llm/utils_spec.rb +26 -7
- data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
- data/spec/legion/extensions/llm_extension_spec.rb +5 -5
- data/spec/support/fake_llm_provider.rb +45 -39
- data/spec/support/fake_ssot_harness.rb +7 -2
- data/spec/support/ssot_registry_helpers.rb +3 -2
- metadata +15 -54
- data/lib/legion/extensions/llm/agent.rb +0 -366
- data/lib/legion/extensions/llm/aliases.json +0 -436
- data/lib/legion/extensions/llm/aliases.rb +0 -67
- data/lib/legion/extensions/llm/attachment.rb +0 -229
- data/lib/legion/extensions/llm/chat.rb +0 -354
- data/lib/legion/extensions/llm/chunk.rb +0 -10
- data/lib/legion/extensions/llm/content.rb +0 -81
- data/lib/legion/extensions/llm/context.rb +0 -33
- data/lib/legion/extensions/llm/embedding.rb +0 -33
- data/lib/legion/extensions/llm/image.rb +0 -109
- data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
- data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -311
- data/lib/legion/extensions/llm/message.rb +0 -118
- data/lib/legion/extensions/llm/mime_type.rb +0 -75
- data/lib/legion/extensions/llm/model/info.rb +0 -286
- data/lib/legion/extensions/llm/model/modalities.rb +0 -26
- data/lib/legion/extensions/llm/model/pricing.rb +0 -52
- data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
- data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
- data/lib/legion/extensions/llm/model.rb +0 -11
- data/lib/legion/extensions/llm/models.json +0 -57313
- data/lib/legion/extensions/llm/models.rb +0 -530
- data/lib/legion/extensions/llm/models_schema.json +0 -168
- data/lib/legion/extensions/llm/moderation.rb +0 -60
- data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
- data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
- data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
- data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
- data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
- data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
- data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
- data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
- data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
- data/lib/legion/extensions/llm/thinking.rb +0 -53
- data/lib/legion/extensions/llm/tokens.rb +0 -51
- data/lib/legion/extensions/llm/tool_call.rb +0 -34
- data/lib/legion/extensions/llm/transcription.rb +0 -39
- data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
- data/spec/legion/extensions/llm/agent_spec.rb +0 -179
- data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
- data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
- data/spec/legion/extensions/llm/context_spec.rb +0 -127
- data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
- data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -337
- data/spec/legion/extensions/llm/message_spec.rb +0 -64
- data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
- data/spec/legion/extensions/llm/models_spec.rb +0 -104
- data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
- data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
- data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
- data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
- data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
- data/spec/legion/extensions/llm/routing/registry_event_spec.rb +0 -120
|
@@ -4,21 +4,20 @@ module Legion
|
|
|
4
4
|
module Extensions
|
|
5
5
|
module Llm
|
|
6
6
|
module Canonical
|
|
7
|
-
TOOL_NAME_MAX_LENGTH = 64
|
|
8
7
|
OBJECT_SCHEMA_KEYWORDS = %i[properties required additionalProperties].freeze
|
|
9
8
|
COMPOSITE_SCHEMA_KEYWORDS = %i[oneOf anyOf allOf enum $ref $defs definitions].freeze
|
|
10
9
|
|
|
11
10
|
# Canonical tool definition.
|
|
12
|
-
# Ports field vocabulary from Legion::LLM::Types::ToolDefinition.
|
|
13
|
-
ToolDefinition = ::Data.define(:name, :description, :parameters, :source) do
|
|
11
|
+
# Ports field vocabulary from Legion::LLM::Types::ToolDefinition. # -- required for Data.define block scope
|
|
12
|
+
ToolDefinition = ::Data.define(:name, :description, :parameters, :source, :metadata) do
|
|
14
13
|
def self.normalize_parameters(parameters)
|
|
15
14
|
empty = { type: 'object', properties: {} }
|
|
16
15
|
return empty if parameters.nil?
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return empty if schema.
|
|
17
|
+
Strict.expect_type!(parameters, [::Hash], self::NORMALIZE_PARAMETERS_SITE, :parameters) unless parameters.is_a?(::Hash)
|
|
18
|
+
|
|
19
|
+
schema = parameters.transform_keys { |k| k.respond_to?(:to_sym) ? k.to_sym : k }
|
|
20
|
+
return empty if schema.empty?
|
|
22
21
|
return schema if schema.key?(:type)
|
|
23
22
|
return schema.merge(type: 'object') if OBJECT_SCHEMA_KEYWORDS.any? { |k| schema.key?(k) }
|
|
24
23
|
return schema if COMPOSITE_SCHEMA_KEYWORDS.any? { |k| schema.key?(k) }
|
|
@@ -26,53 +25,50 @@ module Legion
|
|
|
26
25
|
{ type: 'object', properties: schema }
|
|
27
26
|
end
|
|
28
27
|
|
|
29
|
-
# Build from keyword args (primary constructor).
|
|
30
|
-
|
|
28
|
+
# Build from keyword args (primary constructor). M5: the name is the
|
|
29
|
+
# authoritative client/registry fact — never rewritten, stripped,
|
|
30
|
+
# truncated, or fabricated here; per-dialect name constraints belong
|
|
31
|
+
# to the provider translator edge. source is explicit or absent —
|
|
32
|
+
# never fabricated ({ type: :builtin } is deleted).
|
|
33
|
+
def self.build(name:, description: '', parameters: nil, source: nil, metadata: {})
|
|
31
34
|
new(
|
|
32
|
-
|
|
33
|
-
description
|
|
35
|
+
name,
|
|
36
|
+
description,
|
|
34
37
|
normalize_parameters(parameters),
|
|
35
|
-
source
|
|
38
|
+
source,
|
|
39
|
+
Strict.metadata!(metadata, self::BUILD_SITE)
|
|
36
40
|
)
|
|
37
41
|
end
|
|
38
42
|
|
|
39
43
|
# Build from a Hash (raw provider response or deserialized wire payload).
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
# Canonical keys only (O03a): edges pass `parameters`, not `input_schema`.
|
|
45
|
+
def self.from_hash(source)
|
|
46
|
+
Strict.require_hash!(source, self::FROM_HASH_SITE)
|
|
47
|
+
hash = Strict.symbolize_keys(source)
|
|
48
|
+
metadata = Strict.fold_unknowns!(self, self::FROM_HASH_SITE, hash)
|
|
44
49
|
build(
|
|
45
|
-
name:
|
|
46
|
-
description:
|
|
47
|
-
parameters:
|
|
48
|
-
source:
|
|
50
|
+
name: hash[:name],
|
|
51
|
+
description: hash[:description],
|
|
52
|
+
parameters: hash[:parameters],
|
|
53
|
+
source: hash[:source],
|
|
54
|
+
metadata:
|
|
49
55
|
)
|
|
50
56
|
end
|
|
51
57
|
|
|
52
|
-
#
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
tool_class: entry[:tool_class],
|
|
57
|
-
extension: entry[:extension],
|
|
58
|
-
runner: entry[:runner],
|
|
59
|
-
function: entry[:function]
|
|
60
|
-
}.compact
|
|
58
|
+
# M5: a tool name is authoritative — missing or empty is a contract
|
|
59
|
+
# error, never a fabricated label.
|
|
60
|
+
def self.require_name!(name, site)
|
|
61
|
+
raise ArgumentError, "#{site}: name must be a non-empty String, got #{name.class}: #{name.inspect}" unless name.is_a?(::String) && !name.empty?
|
|
61
62
|
|
|
62
|
-
|
|
63
|
-
name: entry[:name],
|
|
64
|
-
description: entry[:description],
|
|
65
|
-
parameters: entry[:input_schema] || entry[:parameters],
|
|
66
|
-
source: source.compact
|
|
67
|
-
)
|
|
63
|
+
name
|
|
68
64
|
end
|
|
69
65
|
|
|
70
|
-
#
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
66
|
+
# M5: description is a String; absence is the empty string (the
|
|
67
|
+
# documented no-description value), wrong class raises.
|
|
68
|
+
def self.description_value!(description, site)
|
|
69
|
+
return '' if description.nil?
|
|
70
|
+
|
|
71
|
+
Strict.expect_type!(description, [::String], site, :description)
|
|
76
72
|
end
|
|
77
73
|
|
|
78
74
|
def params_schema
|
|
@@ -85,11 +81,7 @@ module Legion
|
|
|
85
81
|
|
|
86
82
|
# Serialize to a Hash for AMQP/fleet/wire transport.
|
|
87
83
|
def to_h
|
|
88
|
-
|
|
89
|
-
name: name,
|
|
90
|
-
description: description,
|
|
91
|
-
parameters: parameters
|
|
92
|
-
}.compact.reject { |k, v| k == :description && v == '' }
|
|
84
|
+
super.compact
|
|
93
85
|
end
|
|
94
86
|
|
|
95
87
|
# MultiJson/Oj/::JSON callback — prevents Data.define #inspect leak into JSON.
|
|
@@ -100,7 +92,25 @@ module Legion
|
|
|
100
92
|
def to_json(*)
|
|
101
93
|
to_h.to_json(*)
|
|
102
94
|
end
|
|
95
|
+
|
|
96
|
+
# H1/M5: the single strict constructor — .new runs the same member
|
|
97
|
+
# contract as the factories (authoritative name, String
|
|
98
|
+
# description, normalized parameters, explicit-or-absent source);
|
|
99
|
+
# the factories fill their defaults and delegate here.
|
|
100
|
+
Strict.install_strict_new!(self) do |values, site|
|
|
101
|
+
values[:name] = require_name!(values[:name], site)
|
|
102
|
+
values[:description] = description_value!(values[:description], site)
|
|
103
|
+
values[:parameters] = normalize_parameters(values[:parameters])
|
|
104
|
+
values[:source] = Strict.expect_type!(values[:source], [::Hash], site, :source)
|
|
105
|
+
values[:metadata] = Strict.metadata!(values[:metadata], site)
|
|
106
|
+
values
|
|
107
|
+
end
|
|
103
108
|
end
|
|
109
|
+
|
|
110
|
+
ToolDefinition::BUILD_SITE = 'Canonical::ToolDefinition.build'
|
|
111
|
+
ToolDefinition::FROM_HASH_SITE = 'Canonical::ToolDefinition.from_hash'
|
|
112
|
+
ToolDefinition::NEW_SITE = 'Canonical::ToolDefinition.new'
|
|
113
|
+
ToolDefinition::NORMALIZE_PARAMETERS_SITE = 'Canonical::ToolDefinition.normalize_parameters'
|
|
104
114
|
end
|
|
105
115
|
end
|
|
106
116
|
end
|
|
@@ -4,40 +4,33 @@ module Legion
|
|
|
4
4
|
module Extensions
|
|
5
5
|
module Llm
|
|
6
6
|
module Canonical
|
|
7
|
-
# Extracts and normalizes tool schemas from
|
|
7
|
+
# Extracts and normalizes tool schemas from ToolDefinition ONLY (04 §6).
|
|
8
|
+
# A schema extractor that tolerates raw hashes is a hidden path over the
|
|
9
|
+
# canonical type — wrong-class input raises (L3).
|
|
8
10
|
module ToolSchema
|
|
9
11
|
EMPTY_OBJECT = { type: 'object', properties: {} }.freeze
|
|
10
12
|
|
|
11
13
|
module_function
|
|
12
14
|
|
|
13
|
-
def extract(
|
|
14
|
-
|
|
15
|
-
ToolDefinition.normalize_parameters(
|
|
15
|
+
def extract(tool_definition)
|
|
16
|
+
require_tool_definition!(tool_definition, 'ToolSchema.extract')
|
|
17
|
+
ToolDefinition.normalize_parameters(tool_definition.parameters)
|
|
16
18
|
end
|
|
17
19
|
|
|
18
|
-
def
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return tool.parameters if tool.respond_to?(:parameters) && tool.parameters
|
|
22
|
-
|
|
23
|
-
return unless tool.respond_to?(:[])
|
|
24
|
-
|
|
25
|
-
tool[:parameters] || tool['parameters'] || tool[:input_schema] || tool['input_schema'] ||
|
|
26
|
-
tool[:params_schema] || tool['params_schema']
|
|
20
|
+
def tool_name(tool_definition)
|
|
21
|
+
require_tool_definition!(tool_definition, 'ToolSchema.tool_name')
|
|
22
|
+
tool_definition.name
|
|
27
23
|
end
|
|
28
24
|
|
|
29
|
-
def
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
'unknown'
|
|
25
|
+
def tool_description(tool_definition)
|
|
26
|
+
require_tool_definition!(tool_definition, 'ToolSchema.tool_description')
|
|
27
|
+
tool_definition.description
|
|
34
28
|
end
|
|
35
29
|
|
|
36
|
-
def
|
|
37
|
-
return
|
|
38
|
-
return (tool[:description] || tool['description'] || '').to_s if tool.respond_to?(:[])
|
|
30
|
+
def require_tool_definition!(tool_definition, site)
|
|
31
|
+
return tool_definition if tool_definition.is_a?(ToolDefinition)
|
|
39
32
|
|
|
40
|
-
|
|
33
|
+
raise ArgumentError, "#{site}: expected Canonical::ToolDefinition, got #{tool_definition.class}"
|
|
41
34
|
end
|
|
42
35
|
end
|
|
43
36
|
end
|
|
@@ -4,56 +4,44 @@
|
|
|
4
4
|
module Legion
|
|
5
5
|
module Extensions
|
|
6
6
|
module Llm
|
|
7
|
+
# -- required for Data.define block scope
|
|
7
8
|
module Canonical
|
|
8
|
-
# rubocop:disable Lint/ConstantDefinitionInBlock -- required for Data.define block scope
|
|
9
9
|
# Canonical usage/metering data for a response.
|
|
10
10
|
# Ports field vocabulary from lex-llm Tokens and legion-llm Types.
|
|
11
11
|
# Includes non-token units extension point per G20b.
|
|
12
|
+
# Canonical keys only (O03a): provider spellings are translated at the edges.
|
|
12
13
|
Usage = ::Data.define(
|
|
13
14
|
:input_tokens, :output_tokens, :cache_read_tokens, :cache_write_tokens,
|
|
14
|
-
:thinking_tokens, :units
|
|
15
|
+
:thinking_tokens, :units, :metadata
|
|
15
16
|
) do
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return nil if source.nil? || source.empty?
|
|
23
|
-
|
|
24
|
-
h = source.transform_keys(&:to_sym)
|
|
25
|
-
|
|
26
|
-
# Normalize legacy key names
|
|
27
|
-
h[:input_tokens] ||= h.delete(:input) || h.delete(:prompt_tokens)
|
|
28
|
-
h[:output_tokens] ||= h.delete(:output) || h.delete(:completion_tokens)
|
|
29
|
-
h[:cache_read_tokens] ||= h.delete(:cached) || h.delete(:cache_read)
|
|
30
|
-
h[:cache_write_tokens] ||= h.delete(:cache_creation) || h.delete(:cache_write)
|
|
31
|
-
h[:thinking_tokens] ||= h.delete(:thinking) || h.delete(:reasoning)
|
|
32
|
-
|
|
33
|
-
# Extract nested details (OpenAI prompt_tokens_details / input_tokens_details)
|
|
34
|
-
h[:cache_read_tokens] ||= dig_nested(h, :prompt_tokens_details, :cached_tokens) ||
|
|
35
|
-
dig_nested(h, :input_tokens_details, :cached_tokens)
|
|
36
|
-
h[:thinking_tokens] ||= dig_nested(h, :completion_tokens_details, :reasoning_tokens) ||
|
|
37
|
-
dig_nested(h, :output_tokens_details, :reasoning_tokens)
|
|
38
|
-
|
|
39
|
-
# Extract units (non-token extension point — G20b)
|
|
40
|
-
units = h.delete(:units) || {}
|
|
41
|
-
|
|
17
|
+
# rubocop:disable Metrics/ParameterLists -- factory methods have many params
|
|
18
|
+
# Build from keyword args (primary constructor).
|
|
19
|
+
def self.build(
|
|
20
|
+
input_tokens: nil, output_tokens: nil, cache_read_tokens: nil,
|
|
21
|
+
cache_write_tokens: nil, thinking_tokens: nil, units: nil, metadata: {}
|
|
22
|
+
)
|
|
42
23
|
new(
|
|
43
|
-
input_tokens
|
|
44
|
-
|
|
45
|
-
cache_read_tokens: h[:cache_read_tokens],
|
|
46
|
-
cache_write_tokens: h[:cache_write_tokens],
|
|
47
|
-
thinking_tokens: h[:thinking_tokens],
|
|
48
|
-
units: units
|
|
24
|
+
input_tokens:, output_tokens:, cache_read_tokens:, cache_write_tokens:,
|
|
25
|
+
thinking_tokens:, units: units || {}, metadata: Strict.metadata!(metadata, self::BUILD_SITE)
|
|
49
26
|
)
|
|
50
27
|
end
|
|
28
|
+
# rubocop:enable Metrics/ParameterLists
|
|
51
29
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
30
|
+
# Build from a Hash (raw provider response or deserialized wire payload).
|
|
31
|
+
# from_hash({}) is a valid all-nil Usage (the no-usage object), never nil.
|
|
32
|
+
def self.from_hash(source)
|
|
33
|
+
Strict.require_hash!(source, self::FROM_HASH_SITE)
|
|
34
|
+
hash = Strict.symbolize_keys(source)
|
|
35
|
+
metadata = Strict.fold_unknowns!(self, self::FROM_HASH_SITE, hash)
|
|
36
|
+
build(
|
|
37
|
+
input_tokens: hash[:input_tokens],
|
|
38
|
+
output_tokens: hash[:output_tokens],
|
|
39
|
+
cache_read_tokens: hash[:cache_read_tokens],
|
|
40
|
+
cache_write_tokens: hash[:cache_write_tokens],
|
|
41
|
+
thinking_tokens: hash[:thinking_tokens],
|
|
42
|
+
units: hash[:units] || {},
|
|
43
|
+
metadata:
|
|
44
|
+
)
|
|
57
45
|
end
|
|
58
46
|
|
|
59
47
|
# Serialize to a Hash for AMQP/fleet/wire transport.
|
|
@@ -75,8 +63,27 @@ module Legion
|
|
|
75
63
|
[input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
|
76
64
|
thinking_tokens].compact.sum
|
|
77
65
|
end
|
|
66
|
+
|
|
67
|
+
# H1/M3: the single strict constructor. .new validates the token
|
|
68
|
+
# members as Integers (a string count would raise deep in
|
|
69
|
+
# StreamAccumulator#total_tokens instead of at construction) and
|
|
70
|
+
# units as a Hash. The factories fill their defaults and delegate
|
|
71
|
+
# here.
|
|
72
|
+
Strict.install_strict_new!(self) do |values, site|
|
|
73
|
+
values[:input_tokens] = Strict.expect_type!(values[:input_tokens], [::Integer], site, :input_tokens)
|
|
74
|
+
values[:output_tokens] = Strict.expect_type!(values[:output_tokens], [::Integer], site, :output_tokens)
|
|
75
|
+
values[:cache_read_tokens] = Strict.expect_type!(values[:cache_read_tokens], [::Integer], site, :cache_read_tokens)
|
|
76
|
+
values[:cache_write_tokens] = Strict.expect_type!(values[:cache_write_tokens], [::Integer], site, :cache_write_tokens)
|
|
77
|
+
values[:thinking_tokens] = Strict.expect_type!(values[:thinking_tokens], [::Integer], site, :thinking_tokens)
|
|
78
|
+
values[:units] = values[:units].nil? ? {} : Strict.expect_type!(values[:units], [::Hash], site, :units)
|
|
79
|
+
values[:metadata] = Strict.metadata!(values[:metadata], site)
|
|
80
|
+
values
|
|
81
|
+
end
|
|
78
82
|
end
|
|
79
|
-
|
|
83
|
+
|
|
84
|
+
Usage::BUILD_SITE = 'Canonical::Usage.build'
|
|
85
|
+
Usage::FROM_HASH_SITE = 'Canonical::Usage.from_hash'
|
|
86
|
+
Usage::NEW_SITE = 'Canonical::Usage.new'
|
|
80
87
|
end
|
|
81
88
|
end
|
|
82
89
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'canonical/strict'
|
|
3
4
|
require_relative 'canonical/thinking'
|
|
4
5
|
require_relative 'canonical/usage'
|
|
5
6
|
require_relative 'canonical/params'
|
|
@@ -26,17 +27,17 @@ module Legion
|
|
|
26
27
|
module Canonical
|
|
27
28
|
CONTRACT_VERSION = '1.0.0'
|
|
28
29
|
|
|
29
|
-
# Available canonical types.
|
|
30
|
+
# Available canonical types (the frozen 04 §1 inventory).
|
|
30
31
|
TYPES = %i[
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
Request Response Chunk
|
|
32
|
+
Message ContentBlock ToolCall ToolDefinition ToolSchema
|
|
33
|
+
Params Thinking Thinking::Config
|
|
34
|
+
Request Response Chunk Usage
|
|
34
35
|
].freeze
|
|
35
36
|
|
|
36
37
|
class << self
|
|
37
38
|
# List all canonical type classes.
|
|
38
39
|
def types
|
|
39
|
-
TYPES.map { |name| const_get(
|
|
40
|
+
TYPES.map { |name| name.to_s.split('::').reduce(self) { |mod, part| mod.const_get(part) } }
|
|
40
41
|
end
|
|
41
42
|
|
|
42
43
|
# Check if a given constant name is a registered canonical type.
|
|
@@ -26,23 +26,49 @@ module Legion
|
|
|
26
26
|
Array(keys).each { |key| option(key.to_sym) }
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
include Legion::Logging::Helper
|
|
30
|
+
|
|
31
|
+
# L5: the log defaults, read from the settings system (the ENV
|
|
32
|
+
# reads are deleted). A configured log_level Symbol/String names a
|
|
33
|
+
# Logger constant — an unknown name is a configuration error (it
|
|
34
|
+
# raises, it does not fall back).
|
|
35
|
+
def log_settings_defaults
|
|
36
|
+
{
|
|
37
|
+
level: log_level_value(llm_setting(:log_level)),
|
|
38
|
+
stream_debug: llm_setting(:log_stream_debug) == true
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
29
42
|
private
|
|
30
43
|
|
|
31
44
|
def option_keys = @option_keys ||= []
|
|
32
45
|
def defaults = @defaults ||= {}
|
|
33
|
-
|
|
46
|
+
|
|
47
|
+
def log_level_value(value)
|
|
48
|
+
return Logger::INFO if value.nil?
|
|
49
|
+
|
|
50
|
+
value.is_a?(::Integer) ? value : Logger.const_get(value.to_s.upcase)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def llm_setting(key)
|
|
54
|
+
return nil unless defined?(::Legion::Settings) && ::Legion::Settings.respond_to?(:dig)
|
|
55
|
+
|
|
56
|
+
::Legion::Settings.dig(:extensions, :llm, key)
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
handle_exception(e, level: :warn, handled: true, operation: 'llm.configuration.setting', key:)
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
private :option, :log_level_value, :llm_setting
|
|
34
62
|
end
|
|
35
63
|
|
|
36
64
|
# System-level options are declared here.
|
|
37
65
|
# Provider-specific options are declared in each provider extension via
|
|
38
66
|
# `self.configuration_options`.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
option
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
option :model_registry_file, -> { File.expand_path('models.json', __dir__) }
|
|
67
|
+
# H4: the dormant default_model / default_*_model options are deleted —
|
|
68
|
+
# a model-defaulting authority with no consumer (verified in-repo and
|
|
69
|
+
# in consumer gems). Model selection belongs to the router.
|
|
70
|
+
# The model_registry_file option is deleted with the Models catalog
|
|
71
|
+
# (the second inventory) — the SSOT registry is the only inventory.
|
|
46
72
|
|
|
47
73
|
option :request_timeout, 300
|
|
48
74
|
option :max_retries, 3
|
|
@@ -53,8 +79,12 @@ module Legion
|
|
|
53
79
|
|
|
54
80
|
option :logger, nil
|
|
55
81
|
option :log_file, -> { $stdout }
|
|
56
|
-
|
|
57
|
-
|
|
82
|
+
# L5: the ENV reads (LEGION_LLM_DEBUG / LEGION_LLM_STREAM_DEBUG) are
|
|
83
|
+
# deleted — every tunable lives in the settings system:
|
|
84
|
+
# extensions.llm.log_level (Symbol/Integer; Logger::INFO when unset)
|
|
85
|
+
# and extensions.llm.log_stream_debug (boolean; false when unset).
|
|
86
|
+
option :log_level, -> { self.class.log_settings_defaults[:level] }
|
|
87
|
+
option :log_stream_debug, -> { self.class.log_settings_defaults[:stream_debug] }
|
|
58
88
|
option :log_regexp_timeout, -> { Regexp.respond_to?(:timeout) ? (Regexp.timeout || 1.0) : nil }
|
|
59
89
|
|
|
60
90
|
# Prompt caching
|
|
@@ -13,7 +13,7 @@ module Legion
|
|
|
13
13
|
include Legion::Logging::Helper
|
|
14
14
|
|
|
15
15
|
def basic(&)
|
|
16
|
-
logger =
|
|
16
|
+
logger = faraday_logger_for(Legion::Extensions::Llm.config, log)
|
|
17
17
|
Faraday.new do |f|
|
|
18
18
|
f.response :logger,
|
|
19
19
|
logger,
|
|
@@ -25,22 +25,20 @@ module Legion
|
|
|
25
25
|
yield f if block_given?
|
|
26
26
|
end
|
|
27
27
|
end
|
|
28
|
+
end
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
return config.logger if config.respond_to?(:logger) && config.logger
|
|
30
|
+
# One Faraday logger resolution (10 U9): the configured logger when
|
|
31
|
+
# present, else the caller's fallback log.
|
|
32
|
+
def self.faraday_logger_for(config_source, fallback_log)
|
|
33
|
+
return config_source.logger if config_source.respond_to?(:logger) && config_source.logger
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
end
|
|
35
|
+
fallback_log
|
|
37
36
|
end
|
|
38
37
|
|
|
39
38
|
def initialize(provider, config)
|
|
40
39
|
@provider = provider
|
|
41
40
|
@config = config
|
|
42
41
|
|
|
43
|
-
ensure_configured!
|
|
44
42
|
@connection ||= Faraday.new(provider.api_base) do |faraday|
|
|
45
43
|
faraday.ssl.verify = false
|
|
46
44
|
setup_timeout(faraday)
|
|
@@ -83,7 +81,7 @@ module Legion
|
|
|
83
81
|
end
|
|
84
82
|
|
|
85
83
|
def setup_logging(faraday)
|
|
86
|
-
logger =
|
|
84
|
+
logger = self.class.faraday_logger_for(config, log)
|
|
87
85
|
# Enable request body logging when the logger is at DEBUG level,
|
|
88
86
|
# or when explicitly enabled via fleet request_payload setting.
|
|
89
87
|
request_payload = Legion::Extensions::Llm.default_settings.dig(:fleet, :request, :logger, :request_payload)
|
|
@@ -100,12 +98,6 @@ module Legion
|
|
|
100
98
|
end
|
|
101
99
|
end
|
|
102
100
|
|
|
103
|
-
def faraday_logger
|
|
104
|
-
return config.logger if config.respond_to?(:logger) && config.logger
|
|
105
|
-
|
|
106
|
-
log
|
|
107
|
-
end
|
|
108
|
-
|
|
109
101
|
def debug_logger?(logger)
|
|
110
102
|
return logger.debug? if logger.respond_to?(:debug?)
|
|
111
103
|
return logger.level.to_i <= Logger::DEBUG if logger.respond_to?(:level)
|
|
@@ -164,20 +156,6 @@ module Legion
|
|
|
164
156
|
Legion::Extensions::Llm::OverloadedError
|
|
165
157
|
]
|
|
166
158
|
end
|
|
167
|
-
|
|
168
|
-
def ensure_configured!
|
|
169
|
-
return if @provider.configured?
|
|
170
|
-
|
|
171
|
-
missing = @provider.configuration_requirements.reject { |req| @config.send(req) }
|
|
172
|
-
config_block = <<~RUBY
|
|
173
|
-
Legion::Extensions::Llm.configure do |config|
|
|
174
|
-
#{missing.map { |key| "config.#{key} = ENV['#{key.to_s.upcase}']" }.join("\n ")}
|
|
175
|
-
end
|
|
176
|
-
RUBY
|
|
177
|
-
|
|
178
|
-
raise ConfigurationError,
|
|
179
|
-
"#{@provider.name} provider is not configured. Add this to your initialization:\n\n#{config_block}"
|
|
180
|
-
end
|
|
181
159
|
end
|
|
182
160
|
end
|
|
183
161
|
end
|
|
@@ -58,8 +58,12 @@ module Legion
|
|
|
58
58
|
env_hash[key.to_sym] || env_hash[key.to_s]
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
+
# The codex auth file is an optional source: absent means "no codex
|
|
62
|
+
# credential" (probe semantics); present-but-unreadable raises from
|
|
63
|
+
# read_json (O11 fail-closed).
|
|
61
64
|
def codex_token
|
|
62
65
|
return nil unless credential_source_probing_enabled?
|
|
66
|
+
return nil unless File.exist?(CODEX_AUTH)
|
|
63
67
|
|
|
64
68
|
data = read_json(CODEX_AUTH)
|
|
65
69
|
mode = data[:auth_mode] || data['auth_mode']
|
|
@@ -74,6 +78,7 @@ module Legion
|
|
|
74
78
|
|
|
75
79
|
def codex_openai_key
|
|
76
80
|
return nil unless credential_source_probing_enabled?
|
|
81
|
+
return nil unless File.exist?(CODEX_AUTH)
|
|
77
82
|
|
|
78
83
|
data = read_json(CODEX_AUTH)
|
|
79
84
|
val = data[:OPENAI_API_KEY] || data['OPENAI_API_KEY']
|
|
@@ -198,19 +203,12 @@ module Legion
|
|
|
198
203
|
credential_fingerprint(val)
|
|
199
204
|
end
|
|
200
205
|
|
|
201
|
-
# Returns true when the URL points to localhost / 127.0.0.1 / ::1
|
|
206
|
+
# Returns true when the URL points to localhost / 127.0.0.1 / ::1
|
|
207
|
+
# (the one shared host-locality detector — Utils.localhost_url?).
|
|
202
208
|
def localhost?(url)
|
|
203
209
|
return false if url.nil?
|
|
204
210
|
|
|
205
|
-
|
|
206
|
-
host = uri.host
|
|
207
|
-
return false if host.nil?
|
|
208
|
-
|
|
209
|
-
normalized = host.delete_prefix('[').delete_suffix(']')
|
|
210
|
-
%w[localhost 127.0.0.1 ::1].include?(normalized)
|
|
211
|
-
rescue URI::InvalidURIError => e
|
|
212
|
-
handle_exception(e, level: :warn, handled: true, operation: 'llm.credential_sources.localhost')
|
|
213
|
-
false
|
|
211
|
+
Utils.localhost_url?(url)
|
|
214
212
|
end
|
|
215
213
|
|
|
216
214
|
module_function :env, :credential_source_probing_enabled?,
|
|
@@ -224,69 +222,54 @@ module Legion
|
|
|
224
222
|
# --- private helpers -----------------------------------------------
|
|
225
223
|
|
|
226
224
|
# Merge user-level (~/.claude/settings.json) and project-level
|
|
227
|
-
# (.claude/settings.json) Claude configs.
|
|
225
|
+
# (.claude/settings.json) Claude configs. Both files are optional
|
|
226
|
+
# sources: absent means "no config from that level". Project overrides
|
|
227
|
+
# user.
|
|
228
228
|
def merge_claude_configs
|
|
229
|
-
user = read_json(CLAUDE_SETTINGS)
|
|
230
|
-
project = read_json(CLAUDE_PROJECT)
|
|
231
|
-
deep_merge(user, project)
|
|
229
|
+
user = File.exist?(CLAUDE_SETTINGS) ? read_json(CLAUDE_SETTINGS) : {}
|
|
230
|
+
project = File.exist?(CLAUDE_PROJECT) ? read_json(CLAUDE_PROJECT) : {}
|
|
231
|
+
Utils.deep_merge(user, project)
|
|
232
232
|
end
|
|
233
233
|
|
|
234
|
-
# Read and parse a JSON file.
|
|
234
|
+
# Read and parse a JSON file. O11 fail-closed: a missing, empty, or
|
|
235
|
+
# unreadable/unparseable credential file raises — it is a
|
|
236
|
+
# configuration error, never a fabricated empty credential.
|
|
235
237
|
def read_json(path)
|
|
236
|
-
|
|
238
|
+
raise ConfigurationError, "credential file is missing: #{path}" unless File.exist?(path)
|
|
237
239
|
|
|
238
240
|
raw = File.read(path)
|
|
239
|
-
|
|
241
|
+
raise ConfigurationError, "credential file is empty: #{path}" if raw.strip.empty?
|
|
240
242
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
::JSON.parse(raw, symbolize_names: true)
|
|
245
|
-
end
|
|
246
|
-
rescue StandardError => e
|
|
247
|
-
handle_exception(e, level: :warn, handled: true, operation: 'llm.credential_sources.read_json',
|
|
248
|
-
path:)
|
|
249
|
-
{}
|
|
243
|
+
# L1: Legion::JSON only (house rule) — the bare ::JSON fallback is
|
|
244
|
+
# deleted; Legion::JSON is a hard dependency of this gem.
|
|
245
|
+
::Legion::JSON.parse(raw, symbolize_names: true)
|
|
250
246
|
end
|
|
251
247
|
|
|
252
248
|
# JWT expiry check. Decodes the base64 payload segment and checks
|
|
253
|
-
# that exp > now.
|
|
254
|
-
# doubt).
|
|
249
|
+
# that exp > now. O11 fail-closed: unreadable or invalid token data
|
|
250
|
+
# is INVALID (the old benefit-of-the-doubt true is deleted). A
|
|
251
|
+
# parseable token with no exp claim has nothing to violate.
|
|
255
252
|
def token_valid?(token)
|
|
256
|
-
return true if token.nil?
|
|
257
|
-
|
|
258
253
|
require 'base64'
|
|
259
|
-
require 'json'
|
|
260
254
|
|
|
261
255
|
parts = token.to_s.split('.')
|
|
262
|
-
return
|
|
256
|
+
return false if parts.length < 2
|
|
263
257
|
|
|
264
|
-
|
|
258
|
+
# L1: Legion::JSON only (string keys — the JWT payload is read by
|
|
259
|
+
# string key).
|
|
260
|
+
payload = ::Legion::JSON.parse(Base64.urlsafe_decode64(parts[1]), symbolize_names: false)
|
|
265
261
|
exp = payload['exp']
|
|
266
262
|
return true if exp.nil?
|
|
267
263
|
|
|
268
264
|
exp.to_i > Time.now.to_i
|
|
269
265
|
rescue StandardError => e
|
|
270
266
|
handle_exception(e, level: :warn, handled: true, operation: 'llm.credential_sources.token_valid')
|
|
271
|
-
|
|
272
|
-
end
|
|
273
|
-
|
|
274
|
-
# Simple recursive hash merge (project values override user values).
|
|
275
|
-
def deep_merge(base, override)
|
|
276
|
-
base.merge(override) do |_key, old_val, new_val|
|
|
277
|
-
if old_val.is_a?(Hash) && new_val.is_a?(Hash)
|
|
278
|
-
deep_merge(old_val, new_val)
|
|
279
|
-
else
|
|
280
|
-
new_val
|
|
281
|
-
end
|
|
282
|
-
end
|
|
267
|
+
false
|
|
283
268
|
end
|
|
284
269
|
|
|
285
|
-
module_function :merge_claude_configs, :read_json,
|
|
286
|
-
:token_valid?, :deep_merge
|
|
270
|
+
module_function :merge_claude_configs, :read_json, :token_valid?
|
|
287
271
|
|
|
288
|
-
private_class_method :merge_claude_configs, :read_json,
|
|
289
|
-
:token_valid?, :deep_merge
|
|
272
|
+
private_class_method :merge_claude_configs, :read_json, :token_valid?
|
|
290
273
|
end
|
|
291
274
|
end
|
|
292
275
|
end
|