lex-llm 0.4.18 → 0.5.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.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +13 -2
  3. data/B1b-conformance-kit.md +79 -0
  4. data/CHANGELOG.md +19 -0
  5. data/lex-llm.gemspec +2 -3
  6. data/lib/legion/extensions/llm/attachment.rb +1 -1
  7. data/lib/legion/extensions/llm/canonical/chunk.rb +184 -0
  8. data/lib/legion/extensions/llm/canonical/content_block.rb +126 -0
  9. data/lib/legion/extensions/llm/canonical/message.rb +125 -0
  10. data/lib/legion/extensions/llm/canonical/params.rb +61 -0
  11. data/lib/legion/extensions/llm/canonical/request.rb +117 -0
  12. data/lib/legion/extensions/llm/canonical/response.rb +124 -0
  13. data/lib/legion/extensions/llm/canonical/thinking.rb +81 -0
  14. data/lib/legion/extensions/llm/canonical/tool_call.rb +134 -0
  15. data/lib/legion/extensions/llm/canonical/tool_definition.rb +73 -0
  16. data/lib/legion/extensions/llm/canonical/usage.rb +61 -0
  17. data/lib/legion/extensions/llm/canonical.rb +49 -0
  18. data/lib/legion/extensions/llm/chat.rb +3 -5
  19. data/lib/legion/extensions/llm/connection.rb +5 -1
  20. data/lib/legion/extensions/llm/error.rb +3 -7
  21. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +1 -3
  22. data/lib/legion/extensions/llm/fleet/provider_responder.rb +1 -3
  23. data/lib/legion/extensions/llm/fleet/token_validator.rb +1 -3
  24. data/lib/legion/extensions/llm/model/info.rb +4 -6
  25. data/lib/legion/extensions/llm/models.rb +3 -3
  26. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +7 -3
  27. data/lib/legion/extensions/llm/routing/lane_key.rb +1 -3
  28. data/lib/legion/extensions/llm/stream_accumulator.rb +1 -1
  29. data/lib/legion/extensions/llm/streaming.rb +1 -3
  30. data/lib/legion/extensions/llm/tool.rb +1 -3
  31. data/lib/legion/extensions/llm/version.rb +1 -1
  32. data/lib/legion/extensions/llm.rb +118 -35
  33. data/spec/fixtures/ruby.mp3 +0 -0
  34. data/spec/fixtures/ruby.mp4 +0 -0
  35. data/spec/fixtures/ruby.png +0 -0
  36. data/spec/fixtures/ruby.txt +1 -0
  37. data/spec/fixtures/ruby.wav +0 -0
  38. data/spec/fixtures/ruby.xml +1 -0
  39. data/spec/fixtures/sample.pdf +0 -0
  40. data/spec/legion/extensions/llm/agent_spec.rb +179 -0
  41. data/spec/legion/extensions/llm/attachment_spec.rb +25 -0
  42. data/spec/legion/extensions/llm/auto_registration_spec.rb +38 -0
  43. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +285 -0
  44. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +179 -0
  45. data/spec/legion/extensions/llm/canonical/message_spec.rb +203 -0
  46. data/spec/legion/extensions/llm/canonical/params_spec.rb +159 -0
  47. data/spec/legion/extensions/llm/canonical/request_spec.rb +174 -0
  48. data/spec/legion/extensions/llm/canonical/response_spec.rb +234 -0
  49. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +151 -0
  50. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +191 -0
  51. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +174 -0
  52. data/spec/legion/extensions/llm/canonical/usage_spec.rb +138 -0
  53. data/spec/legion/extensions/llm/configuration_spec.rb +38 -0
  54. data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +269 -0
  55. data/spec/legion/extensions/llm/conformance/conformance.rb +51 -0
  56. data/spec/legion/extensions/llm/conformance/echo_translator.rb +56 -0
  57. data/spec/legion/extensions/llm/conformance/echo_translator_spec.rb +13 -0
  58. data/spec/legion/extensions/llm/conformance/fixtures/canonical_empty_response.json +13 -0
  59. data/spec/legion/extensions/llm/conformance/fixtures/canonical_error_response.json +19 -0
  60. data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +81 -0
  61. data/spec/legion/extensions/llm/conformance/fixtures/canonical_metering_audit_events.json +101 -0
  62. data/spec/legion/extensions/llm/conformance/fixtures/canonical_params_mapping_request.json +21 -0
  63. data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_request.json +13 -0
  64. data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_response.json +13 -0
  65. data/spec/legion/extensions/llm/conformance/fixtures/canonical_stop_reason_matrix.json +36 -0
  66. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_accumulated_response.json +20 -0
  67. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_error_chunks.json +26 -0
  68. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_text_chunks.json +33 -0
  69. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_thinking_chunks.json +42 -0
  70. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_tool_call_chunks.json +41 -0
  71. data/spec/legion/extensions/llm/conformance/fixtures/canonical_system_prompt_request.json +14 -0
  72. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +18 -0
  73. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_response.json +17 -0
  74. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_results_continuation_request.json +75 -0
  75. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_use_response.json +25 -0
  76. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tools_request.json +34 -0
  77. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +390 -0
  78. data/spec/legion/extensions/llm/connection_logging_spec.rb +53 -0
  79. data/spec/legion/extensions/llm/connection_retry_spec.rb +36 -0
  80. data/spec/legion/extensions/llm/context_spec.rb +127 -0
  81. data/spec/legion/extensions/llm/credential_sources_spec.rb +468 -0
  82. data/spec/legion/extensions/llm/error_middleware_spec.rb +102 -0
  83. data/spec/legion/extensions/llm/error_spec.rb +87 -0
  84. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +120 -0
  85. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +163 -0
  86. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +128 -0
  87. data/spec/legion/extensions/llm/fleet_messages_spec.rb +402 -0
  88. data/spec/legion/extensions/llm/gemspec_spec.rb +25 -0
  89. data/spec/legion/extensions/llm/message_spec.rb +64 -0
  90. data/spec/legion/extensions/llm/model/info_spec.rb +222 -0
  91. data/spec/legion/extensions/llm/models_spec.rb +104 -0
  92. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +203 -0
  93. data/spec/legion/extensions/llm/provider_contract_spec.rb +60 -0
  94. data/spec/legion/extensions/llm/provider_settings_spec.rb +76 -0
  95. data/spec/legion/extensions/llm/provider_spec.rb +592 -0
  96. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +68 -0
  97. data/spec/legion/extensions/llm/registry_publisher_spec.rb +22 -0
  98. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +75 -0
  99. data/spec/legion/extensions/llm/responses/thinking_extractor_spec.rb +75 -0
  100. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +222 -0
  101. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +50 -0
  102. data/spec/legion/extensions/llm/routing/registry_event_spec.rb +120 -0
  103. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +103 -0
  104. data/spec/legion/extensions/llm/streaming_spec.rb +108 -0
  105. data/spec/legion/extensions/llm/tool_spec.rb +94 -0
  106. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +60 -0
  107. data/spec/legion/extensions/llm/utils_spec.rb +113 -0
  108. data/spec/legion/extensions/llm_base_contract_spec.rb +110 -0
  109. data/spec/legion/extensions/llm_extension_spec.rb +78 -0
  110. data/spec/legion/extensions/llm_root_spec.rb +51 -0
  111. data/spec/spec_helper.rb +24 -0
  112. data/spec/support/fake_llm_provider.rb +148 -0
  113. data/spec/support/llm_configuration.rb +21 -0
  114. data/spec/support/rspec_configuration.rb +19 -0
  115. data/spec/support/simplecov_configuration.rb +20 -0
  116. metadata +96 -15
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ # rubocop:disable Metrics/ParameterLists -- factory methods have many params
6
+ module Legion
7
+ module Extensions
8
+ module Llm
9
+ module Canonical
10
+ # Canonical request shape — the single contract between client translators
11
+ # and the inference executor. Per R3 and G18.
12
+ Request = ::Data.define(
13
+ :id, :messages, :system, :tools, :tool_choice,
14
+ :params, :thinking, :stream,
15
+ :conversation_id, :caller, :routing, :metadata
16
+ ) do
17
+ # Build from keyword args (primary constructor).
18
+ def self.build(
19
+ id: nil, messages: nil, system: nil, tools: nil, tool_choice: nil,
20
+ params: nil, thinking: nil, stream: false,
21
+ conversation_id: nil, caller: nil, routing: nil, metadata: nil
22
+ )
23
+ # Normalize messages to Canonical::Message array
24
+ msg_array = Array(messages).filter_map do |msg|
25
+ msg.is_a?(Message) ? msg : Message.from_hash(msg)
26
+ end
27
+
28
+ # Normalize tools to Hash<name, ToolDefinition>
29
+ tool_hash = normalize_tools(tools)
30
+
31
+ # Normalize params
32
+ params_obj = case params
33
+ when Params then params
34
+ when Hash then Params.from_hash(params)
35
+ end
36
+
37
+ # Normalize thinking config
38
+ thinking_obj = case thinking
39
+ when Thinking::Config then thinking
40
+ when Hash then Thinking::Config.new(**thinking.transform_keys(&:to_sym))
41
+ end
42
+
43
+ new(
44
+ id: id || "req_#{SecureRandom.hex(12)}",
45
+ messages: msg_array,
46
+ system: system,
47
+ tools: tool_hash,
48
+ tool_choice: tool_choice.is_a?(String) ? tool_choice.to_sym : tool_choice,
49
+ params: params_obj,
50
+ thinking: thinking_obj,
51
+ stream: stream,
52
+ conversation_id: conversation_id,
53
+ caller: caller,
54
+ routing: routing || {},
55
+ metadata: metadata || {}
56
+ )
57
+ end
58
+
59
+ # Build from a Hash (raw client request or deserialized wire payload).
60
+ def self.from_hash(source)
61
+ return nil if source.nil?
62
+
63
+ h = source.transform_keys(&:to_sym)
64
+
65
+ # Extract metadata from unknown keys
66
+ metadata = h[:metadata] || {}
67
+ known_keys = %i[id messages system tools tool_choice params thinking
68
+ stream conversation_id caller routing metadata]
69
+ (h.keys - known_keys).each do |key|
70
+ metadata[key] = h.delete(key)
71
+ end
72
+ h[:metadata] = metadata
73
+
74
+ build(**h)
75
+ end
76
+
77
+ # Serialize to a Hash for AMQP/fleet/wire transport.
78
+ def to_h
79
+ {
80
+ id: id,
81
+ messages: messages&.map { |m| m.is_a?(Message) ? m.to_h : m },
82
+ system: system,
83
+ tools: tools&.transform_values { |t| t.is_a?(ToolDefinition) ? t.to_h : t },
84
+ tool_choice: tool_choice,
85
+ params: params&.to_h,
86
+ thinking: thinking&.to_h,
87
+ stream: stream,
88
+ conversation_id: conversation_id,
89
+ caller: caller,
90
+ routing: routing,
91
+ metadata: metadata
92
+ }.compact
93
+ end
94
+
95
+ def self.normalize_tools(tools)
96
+ return {} if tools.nil? || tools.empty?
97
+
98
+ case tools
99
+ when Hash
100
+ tools.transform_values do |tool|
101
+ tool.is_a?(ToolDefinition) ? tool : ToolDefinition.from_hash(tool)
102
+ end
103
+ when Array
104
+ tools.each_with_object({}) do |tool, hash|
105
+ td = tool.is_a?(ToolDefinition) ? tool : ToolDefinition.from_hash(tool)
106
+ hash[td.name] = td
107
+ end
108
+ else
109
+ {}
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ # rubocop:enable Metrics/ParameterLists
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/ParameterLists -- factory methods have many params
4
+ module Legion
5
+ module Extensions
6
+ module Llm
7
+ module Canonical
8
+ # rubocop:disable Lint/ConstantDefinitionInBlock -- required for Data.define block scope
9
+ # Canonical response shape — the provider-boundary contract.
10
+ # Per R2: does NOT replace Inference::Response (the pipeline envelope).
11
+ # Per Amendment A: immutable Data.define with strict factory.
12
+ Response = ::Data.define(
13
+ :text, :thinking, :tool_calls, :usage,
14
+ :stop_reason, :model, :routing, :metadata
15
+ ) do
16
+ STOP_REASONS = %i[end_turn tool_use max_tokens stop_sequence content_filter error].freeze
17
+
18
+ # Build from a Hash (raw provider response or deserialized wire payload).
19
+ # Unknown keys go to metadata, never silently dropped.
20
+ def self.from_hash(source)
21
+ return nil if source.nil?
22
+
23
+ h = source.transform_keys(&:to_sym)
24
+
25
+ # Extract known fields
26
+ text = h.delete(:text) || h.delete(:content) || ''
27
+ text = text.to_s if text
28
+
29
+ thinking_raw = h.delete(:thinking)
30
+ thinking = thinking_raw.is_a?(Thinking) ? thinking_raw : Thinking.from_hash(thinking_raw)
31
+
32
+ tool_calls_raw = h.delete(:tool_calls)
33
+ tool_calls = Array(tool_calls_raw).filter_map do |tc|
34
+ tc.is_a?(ToolCall) ? tc : ToolCall.from_hash(tc)
35
+ end
36
+
37
+ usage_raw = h.delete(:usage)
38
+ usage = usage_raw.is_a?(Usage) ? usage_raw : Usage.from_hash(usage_raw)
39
+
40
+ # Normalize stop_reason
41
+ stop_reason_raw = h.delete(:stop_reason) || h.delete(:finish_reason)
42
+ stop_reason = stop_reason_raw&.to_sym if stop_reason_raw
43
+ unless stop_reason.nil? || STOP_REASONS.include?(stop_reason)
44
+ raise ArgumentError,
45
+ "Invalid stop_reason: #{stop_reason.inspect}. Must be one of: #{STOP_REASONS.join(', ')}"
46
+ end
47
+
48
+ model = h.delete(:model)
49
+ routing = h.delete(:routing) || {}
50
+
51
+ # Remaining keys become metadata
52
+ existing_metadata = h.delete(:metadata) || {}
53
+ metadata = existing_metadata.merge(h).compact
54
+
55
+ new(
56
+ text: text,
57
+ thinking: thinking,
58
+ tool_calls: tool_calls,
59
+ usage: usage,
60
+ stop_reason: stop_reason,
61
+ model: model,
62
+ routing: routing,
63
+ metadata: metadata
64
+ )
65
+ end
66
+
67
+ # Build from keyword args (primary constructor).
68
+ def self.build(
69
+ text: '', thinking: nil, tool_calls: nil, usage: nil,
70
+ stop_reason: nil, model: nil, routing: nil, metadata: nil
71
+ )
72
+ stop_reason_sym = stop_reason&.to_sym
73
+ unless stop_reason_sym.nil? || STOP_REASONS.include?(stop_reason_sym)
74
+ raise ArgumentError,
75
+ "Invalid stop_reason: #{stop_reason_sym.inspect}. Must be one of: #{STOP_REASONS.join(', ')}"
76
+ end
77
+
78
+ new(
79
+ text: text.to_s,
80
+ thinking: thinking,
81
+ tool_calls: tool_calls || [],
82
+ usage: usage,
83
+ stop_reason: stop_reason_sym,
84
+ model: model,
85
+ routing: routing || {},
86
+ metadata: metadata || {}
87
+ )
88
+ end
89
+
90
+ # Serialize to a Hash for AMQP/fleet/wire transport.
91
+ def to_h
92
+ {
93
+ text: text,
94
+ thinking: thinking&.to_h,
95
+ tool_calls: tool_calls&.map { |tc| tc.is_a?(ToolCall) ? tc.to_h : tc },
96
+ usage: usage&.to_h,
97
+ stop_reason: stop_reason,
98
+ model: model,
99
+ routing: routing,
100
+ metadata: metadata
101
+ }.compact.reject do |k, v|
102
+ %i[tool_calls routing
103
+ metadata].include?(k) && v.is_a?(Enumerable) && v.empty?
104
+ end
105
+ end
106
+
107
+ # Whether the response includes tool calls.
108
+ def tool_call?
109
+ !tool_calls.nil? && !tool_calls.empty?
110
+ end
111
+
112
+ # Whether the response ended due to an error.
113
+ def error?
114
+ stop_reason == :error
115
+ end
116
+ end
117
+
118
+ Response::STOP_REASONS = %i[end_turn tool_use max_tokens stop_sequence content_filter error].freeze
119
+ # rubocop:enable Lint/ConstantDefinitionInBlock
120
+ end
121
+ end
122
+ end
123
+ end
124
+ # rubocop:enable Metrics/ParameterLists
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ # -- from_hash normalization is intentional
4
+ module Legion
5
+ module Extensions
6
+ module Llm
7
+ # rubocop:disable Style/Documentation -- module doc is in canonical.rb entry point
8
+ module Canonical
9
+ # Canonical thinking/reasoning block.
10
+ # Ports field vocabulary from Legion::LLM::Types and lex-llm Thinking.
11
+ Thinking = ::Data.define(:content, :signature) do
12
+ # Build from a Hash (raw provider response or deserialized wire payload).
13
+ def self.from_hash(source)
14
+ return nil if source.nil?
15
+
16
+ h = source.transform_keys(&:to_sym)
17
+
18
+ # Treat empty strings as nil
19
+ content = h[:content]
20
+ content = nil if content.is_a?(String) && content.empty?
21
+ signature = h[:signature]
22
+ signature = nil if signature.is_a?(String) && signature.empty?
23
+
24
+ return nil if content.nil? && signature.nil?
25
+
26
+ new(content: content, signature: signature)
27
+ end
28
+
29
+ # Serialize to a Hash for AMQP/fleet/wire transport.
30
+ def to_h
31
+ super.compact
32
+ end
33
+
34
+ # Whether this thinking block has any content.
35
+ def empty?
36
+ content.nil? && signature.nil?
37
+ end
38
+ end
39
+
40
+ # Normalized config for thinking across providers.
41
+ # Mirrors lex-llm Thinking::Config.
42
+ class ThinkingConfig
43
+ INCLUDES = Thinking
44
+ attr_reader :effort, :budget
45
+
46
+ def initialize(effort: nil, budget: nil)
47
+ @effort = effort.is_a?(Symbol) ? effort.to_s : effort
48
+ @budget = budget
49
+ end
50
+
51
+ # Build from keyword args.
52
+ def self.build(effort: nil, budget: nil)
53
+ new(effort: effort, budget: budget)
54
+ end
55
+
56
+ # Build from a Hash.
57
+ def self.from_hash(source)
58
+ return nil if source.nil? || source.empty?
59
+
60
+ h = source.transform_keys(&:to_sym)
61
+ build(effort: h[:effort], budget: h[:budget])
62
+ end
63
+
64
+ # Serialize to a Hash for AMQP/fleet/wire transport.
65
+ def to_h
66
+ { effort: effort, budget: budget }.compact
67
+ end
68
+
69
+ # Whether thinking is configured.
70
+ def enabled?
71
+ !effort.nil? || !budget.nil?
72
+ end
73
+ end
74
+
75
+ # Alias for convenience: Canonical::Thinking::Config
76
+ Thinking.const_set(:Config, ThinkingConfig)
77
+ end
78
+ # rubocop:enable Style/Documentation
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ # rubocop:disable Metrics/ParameterLists -- factory methods have many params
6
+ module Legion
7
+ module Extensions
8
+ module Llm
9
+ module Canonical
10
+ # rubocop:disable Lint/ConstantDefinitionInBlock -- required for Data.define block scope
11
+ # Canonical tool call with source enum and compliance fields.
12
+ # Ports field vocabulary from Legion::LLM::Types::ToolCall.
13
+ # Source enum per R7: :client | :registry | :special | :extension | :mcp
14
+ # Compliance fields per R8: data_handling_classification, policy_decision
15
+ ToolCall = ::Data.define(
16
+ :id, :exchange_id, :name, :arguments, :source,
17
+ :status, :duration_ms, :result, :error,
18
+ :started_at, :finished_at, :category,
19
+ :data_handling_classification, :policy_decision
20
+ ) do
21
+ SOURCE_VALUES = %i[client registry special extension mcp].freeze
22
+ STATUS_VALUES = %i[pending running success error].freeze
23
+
24
+ # Build from keyword args (primary constructor).
25
+ def self.build(
26
+ name:, id: nil, exchange_id: nil, arguments: nil, source: nil,
27
+ status: nil, duration_ms: nil, result: nil, error: nil,
28
+ started_at: nil, finished_at: nil, category: nil,
29
+ data_handling_classification: nil, policy_decision: nil
30
+ )
31
+ new(
32
+ id: id || "call_#{SecureRandom.hex(12)}",
33
+ exchange_id: exchange_id,
34
+ name: name,
35
+ arguments: arguments || {},
36
+ source: source,
37
+ status: status,
38
+ duration_ms: duration_ms,
39
+ result: result,
40
+ error: error,
41
+ started_at: started_at,
42
+ finished_at: finished_at,
43
+ category: category,
44
+ data_handling_classification: data_handling_classification,
45
+ policy_decision: policy_decision
46
+ )
47
+ end
48
+
49
+ # Build from a Hash (raw provider response or deserialized wire payload).
50
+ def self.from_hash(hash)
51
+ return nil if hash.nil?
52
+
53
+ h = hash.transform_keys(&:to_sym)
54
+
55
+ # Normalize source to symbol
56
+ source_raw = h[:source]
57
+ h[:source] = source_raw&.to_sym if source_raw.is_a?(String)
58
+
59
+ # Normalize status to symbol
60
+ status_raw = h[:status]
61
+ h[:status] = status_raw&.to_sym if status_raw.is_a?(String)
62
+
63
+ # Parse arguments if they're a JSON string
64
+ args = h[:arguments]
65
+ if args.is_a?(String) && !args.empty?
66
+ begin
67
+ h[:arguments] = Legion::JSON.load(args)
68
+ rescue Legion::JSON::ParseError => e
69
+ Legion::Logging.debug("[lex-llm][canonical][tool_call] arguments not parseable as JSON, leaving as string: #{e.message}")
70
+ end
71
+ end
72
+
73
+ build(**h)
74
+ end
75
+
76
+ # Return a new ToolCall with execution result attached.
77
+ def with_result(result:, status:, duration_ms: nil, finished_at: nil)
78
+ self.class.new(
79
+ id: id,
80
+ exchange_id: exchange_id,
81
+ name: name,
82
+ arguments: arguments,
83
+ source: source,
84
+ status: status,
85
+ duration_ms: duration_ms,
86
+ result: result,
87
+ error: status == :error ? result : error,
88
+ started_at: started_at,
89
+ finished_at: finished_at || ::Time.now,
90
+ category: category,
91
+ data_handling_classification: data_handling_classification,
92
+ policy_decision: policy_decision
93
+ )
94
+ end
95
+
96
+ def success?
97
+ status == :success
98
+ end
99
+
100
+ def error?
101
+ status == :error
102
+ end
103
+
104
+ # Serialize to a Hash for AMQP/fleet/wire transport.
105
+ def to_h
106
+ super.compact
107
+ end
108
+
109
+ # Subset for audit/ledger emission.
110
+ def to_audit_hash
111
+ {
112
+ id: id,
113
+ name: name,
114
+ arguments: arguments,
115
+ status: status,
116
+ duration_ms: duration_ms,
117
+ error: error,
118
+ exchange_id: exchange_id,
119
+ source: source,
120
+ category: category,
121
+ data_handling_classification: data_handling_classification,
122
+ policy_decision: policy_decision
123
+ }.compact
124
+ end
125
+ end
126
+
127
+ ToolCall::SOURCE_VALUES = %i[client registry special extension mcp].freeze
128
+ ToolCall::STATUS_VALUES = %i[pending running success error].freeze
129
+ # rubocop:enable Lint/ConstantDefinitionInBlock
130
+ end
131
+ end
132
+ end
133
+ end
134
+ # rubocop:enable Metrics/ParameterLists
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Llm
6
+ module Canonical
7
+ TOOL_NAME_MAX_LENGTH = 64
8
+
9
+ # Canonical tool definition.
10
+ # Ports field vocabulary from Legion::LLM::Types::ToolDefinition.
11
+ ToolDefinition = ::Data.define(:name, :description, :parameters, :source) do
12
+ # Build from keyword args (primary constructor).
13
+ def self.build(name:, description: '', parameters: nil, source: nil)
14
+ new(
15
+ sanitize_tool_name(name),
16
+ description.to_s,
17
+ parameters || {},
18
+ source || { type: :builtin }
19
+ )
20
+ end
21
+
22
+ # Build from a Hash (raw provider response or deserialized wire payload).
23
+ def self.from_hash(hash, source: nil)
24
+ return nil if hash.nil?
25
+
26
+ normalized = hash.respond_to?(:transform_keys) ? hash.transform_keys(&:to_sym) : {}
27
+ build(
28
+ name: normalized[:name],
29
+ description: normalized[:description],
30
+ parameters: normalized[:parameters] || normalized[:input_schema],
31
+ source: source || normalized[:source]
32
+ )
33
+ end
34
+
35
+ # Build from a registry entry (extension/registry tool metadata).
36
+ def self.from_registry_entry(entry)
37
+ source = {
38
+ type: entry[:tool_class] ? :registry : :extension,
39
+ tool_class: entry[:tool_class],
40
+ extension: entry[:extension],
41
+ runner: entry[:runner],
42
+ function: entry[:function]
43
+ }.compact
44
+
45
+ build(
46
+ name: entry[:name],
47
+ description: entry[:description],
48
+ parameters: entry[:input_schema] || entry[:parameters],
49
+ source: source.compact
50
+ )
51
+ end
52
+
53
+ # Sanitize a tool name to be safe for all wire formats.
54
+ def self.sanitize_tool_name(raw)
55
+ name = raw.to_s.tr('.', '_')
56
+ name = name.gsub(/[^a-zA-Z0-9_-]/, '')
57
+ name = name[0, TOOL_NAME_MAX_LENGTH] if name.length > TOOL_NAME_MAX_LENGTH
58
+ name.empty? ? 'tool' : name
59
+ end
60
+
61
+ # Serialize to a Hash for AMQP/fleet/wire transport.
62
+ def to_h
63
+ {
64
+ name: name,
65
+ description: description,
66
+ parameters: parameters
67
+ }.compact.reject { |k, v| k == :description && v == '' }
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ # -- from_hash normalization is intentional
4
+ module Legion
5
+ module Extensions
6
+ module Llm
7
+ module Canonical
8
+ # rubocop:disable Lint/ConstantDefinitionInBlock -- required for Data.define block scope
9
+ # Canonical usage/metering data for a response.
10
+ # Ports field vocabulary from lex-llm Tokens and legion-llm Types.
11
+ # Includes non-token units extension point per G20b.
12
+ Usage = ::Data.define(
13
+ :input_tokens, :output_tokens, :cache_read_tokens, :cache_write_tokens,
14
+ :thinking_tokens, :units
15
+ ) do
16
+ USAGE_KNOWN_KEYS = %i[input_tokens output_tokens cache_read_tokens cache_write_tokens
17
+ thinking_tokens units].freeze
18
+
19
+ # Build from a Hash (raw provider response or deserialized wire payload).
20
+ # Accepts both canonical key names and legacy provider spellings.
21
+ def self.from_hash(source)
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 units (non-token extension point — G20b)
34
+ units = h.delete(:units) || {}
35
+
36
+ new(
37
+ input_tokens: h[:input_tokens],
38
+ output_tokens: h[:output_tokens],
39
+ cache_read_tokens: h[:cache_read_tokens],
40
+ cache_write_tokens: h[:cache_write_tokens],
41
+ thinking_tokens: h[:thinking_tokens],
42
+ units: units
43
+ )
44
+ end
45
+
46
+ # Serialize to a Hash for AMQP/fleet/wire transport.
47
+ def to_h
48
+ super.compact
49
+ end
50
+
51
+ # Total tokens across all categories.
52
+ def total_tokens
53
+ [input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
54
+ thinking_tokens].compact.sum
55
+ end
56
+ end
57
+ # rubocop:enable Lint/ConstantDefinitionInBlock
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'canonical/thinking'
4
+ require_relative 'canonical/usage'
5
+ require_relative 'canonical/params'
6
+ require_relative 'canonical/content_block'
7
+ require_relative 'canonical/tool_definition'
8
+ require_relative 'canonical/tool_call'
9
+ require_relative 'canonical/message'
10
+ require_relative 'canonical/request'
11
+ require_relative 'canonical/response'
12
+ require_relative 'canonical/chunk'
13
+
14
+ module Legion
15
+ module Extensions
16
+ module Llm
17
+ # Canonical types for the N×N client→provider routing architecture.
18
+ #
19
+ # These Data.define structs form the single contract between client translators
20
+ # and provider translators. Per Amendment A: immutable, strict factories,
21
+ # enum validation, unknown keys → metadata.
22
+ #
23
+ # Contract version: incremented on any breaking change to the canonical shape.
24
+ # Provider registration refuses gems built against a mismatched version (G7).
25
+ module Canonical
26
+ CONTRACT_VERSION = '1.0.0'
27
+
28
+ # Available canonical types.
29
+ TYPES = %i[
30
+ Thinking Usage Params ContentBlock
31
+ ToolDefinition ToolCall Message
32
+ Request Response Chunk
33
+ ].freeze
34
+
35
+ class << self
36
+ # List all canonical type classes.
37
+ def types
38
+ TYPES.map { |name| const_get(name) }
39
+ end
40
+
41
+ # Check if a given constant name is a registered canonical type.
42
+ def type?(name)
43
+ TYPES.include?(name.to_sym)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -11,9 +11,7 @@ module Legion
11
11
  attr_reader :model, :messages, :tools, :tool_prefs, :params, :headers, :schema
12
12
 
13
13
  def initialize(model: nil, provider: nil, assume_model_exists: false, context: nil)
14
- if assume_model_exists && !provider
15
- raise ArgumentError, 'Provider must be specified if assume_model_exists is true'
16
- end
14
+ raise ArgumentError, 'Provider must be specified if assume_model_exists is true' if assume_model_exists && !provider
17
15
 
18
16
  @context = context
19
17
  @config = context&.config || Legion::Extensions::Llm.config
@@ -139,7 +137,7 @@ module Legion
139
137
  messages.each(&)
140
138
  end
141
139
 
142
- def complete(&) # rubocop:disable Metrics/PerceivedComplexity
140
+ def complete(&)
143
141
  response = @provider.complete(
144
142
  messages,
145
143
  tools: @tools,
@@ -234,7 +232,7 @@ module Legion
234
232
  end
235
233
  end
236
234
 
237
- def handle_tool_calls(response, &) # rubocop:disable Metrics/PerceivedComplexity
235
+ def handle_tool_calls(response, &)
238
236
  halt_result = nil
239
237
 
240
238
  response.tool_calls.each_value do |tool_call|
@@ -77,9 +77,13 @@ module Legion
77
77
 
78
78
  def setup_logging(faraday)
79
79
  logger = faraday_logger
80
+ # Enable request body logging when the logger is at DEBUG level,
81
+ # or when explicitly enabled via fleet request_payload setting.
82
+ request_payload = Legion::Extensions::Llm.default_settings.dig(:fleet, :request, :logger, :request_payload)
83
+ bodies_enabled = request_payload == true || debug_logger?(logger)
80
84
  faraday.response :logger,
81
85
  logger,
82
- bodies: debug_logger?(logger),
86
+ bodies: bodies_enabled,
83
87
  errors: false,
84
88
  headers: false,
85
89
  log_level: :debug do |logger|