lex-llm 0.4.18 → 0.5.1

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 (125) 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 +27 -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 +138 -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 +98 -0
  16. data/lib/legion/extensions/llm/canonical/tool_schema.rb +46 -0
  17. data/lib/legion/extensions/llm/canonical/usage.rb +74 -0
  18. data/lib/legion/extensions/llm/canonical.rb +50 -0
  19. data/lib/legion/extensions/llm/chat.rb +3 -5
  20. data/lib/legion/extensions/llm/connection.rb +5 -1
  21. data/lib/legion/extensions/llm/error.rb +5 -7
  22. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +1 -3
  23. data/lib/legion/extensions/llm/fleet/provider_responder.rb +1 -3
  24. data/lib/legion/extensions/llm/fleet/token_validator.rb +1 -3
  25. data/lib/legion/extensions/llm/model/info.rb +4 -6
  26. data/lib/legion/extensions/llm/models.rb +3 -3
  27. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +9 -4
  28. data/lib/legion/extensions/llm/provider.rb +21 -4
  29. data/lib/legion/extensions/llm/provider_contract.rb +10 -1
  30. data/lib/legion/extensions/llm/routing/lane_key.rb +1 -3
  31. data/lib/legion/extensions/llm/stream_accumulator.rb +40 -1
  32. data/lib/legion/extensions/llm/streaming.rb +13 -5
  33. data/lib/legion/extensions/llm/tool.rb +1 -3
  34. data/lib/legion/extensions/llm/version.rb +1 -1
  35. data/lib/legion/extensions/llm.rb +118 -35
  36. data/spec/fixtures/ruby.mp3 +0 -0
  37. data/spec/fixtures/ruby.mp4 +0 -0
  38. data/spec/fixtures/ruby.png +0 -0
  39. data/spec/fixtures/ruby.txt +1 -0
  40. data/spec/fixtures/ruby.wav +0 -0
  41. data/spec/fixtures/ruby.xml +1 -0
  42. data/spec/fixtures/sample.pdf +0 -0
  43. data/spec/legion/extensions/llm/agent_spec.rb +179 -0
  44. data/spec/legion/extensions/llm/attachment_spec.rb +25 -0
  45. data/spec/legion/extensions/llm/auto_registration_spec.rb +38 -0
  46. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +285 -0
  47. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +179 -0
  48. data/spec/legion/extensions/llm/canonical/message_spec.rb +203 -0
  49. data/spec/legion/extensions/llm/canonical/params_spec.rb +159 -0
  50. data/spec/legion/extensions/llm/canonical/request_spec.rb +174 -0
  51. data/spec/legion/extensions/llm/canonical/response_spec.rb +234 -0
  52. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +151 -0
  53. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +191 -0
  54. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +221 -0
  55. data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +83 -0
  56. data/spec/legion/extensions/llm/canonical/usage_spec.rb +178 -0
  57. data/spec/legion/extensions/llm/configuration_spec.rb +38 -0
  58. data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +432 -0
  59. data/spec/legion/extensions/llm/conformance/conformance.rb +51 -0
  60. data/spec/legion/extensions/llm/conformance/echo_translator.rb +56 -0
  61. data/spec/legion/extensions/llm/conformance/echo_translator_spec.rb +13 -0
  62. data/spec/legion/extensions/llm/conformance/fixtures/canonical_empty_response.json +13 -0
  63. data/spec/legion/extensions/llm/conformance/fixtures/canonical_error_response.json +19 -0
  64. data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +81 -0
  65. data/spec/legion/extensions/llm/conformance/fixtures/canonical_metering_audit_events.json +101 -0
  66. data/spec/legion/extensions/llm/conformance/fixtures/canonical_params_mapping_request.json +21 -0
  67. data/spec/legion/extensions/llm/conformance/fixtures/canonical_server_tool_continuation_request.json +43 -0
  68. data/spec/legion/extensions/llm/conformance/fixtures/canonical_server_tool_use_response.json +29 -0
  69. data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_request.json +13 -0
  70. data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_response.json +13 -0
  71. data/spec/legion/extensions/llm/conformance/fixtures/canonical_stop_reason_matrix.json +36 -0
  72. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_accumulated_response.json +20 -0
  73. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_error_chunks.json +26 -0
  74. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_server_tool_chunks.json +52 -0
  75. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_text_chunks.json +33 -0
  76. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_thinking_chunks.json +42 -0
  77. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_tool_call_chunks.json +41 -0
  78. data/spec/legion/extensions/llm/conformance/fixtures/canonical_system_prompt_request.json +14 -0
  79. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +18 -0
  80. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_response.json +17 -0
  81. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_results_continuation_request.json +75 -0
  82. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_use_response.json +25 -0
  83. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tools_request.json +34 -0
  84. data/spec/legion/extensions/llm/conformance/provider_tool_rendering_examples.rb +77 -0
  85. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +390 -0
  86. data/spec/legion/extensions/llm/connection_logging_spec.rb +53 -0
  87. data/spec/legion/extensions/llm/connection_retry_spec.rb +36 -0
  88. data/spec/legion/extensions/llm/context_spec.rb +127 -0
  89. data/spec/legion/extensions/llm/credential_sources_spec.rb +468 -0
  90. data/spec/legion/extensions/llm/error_middleware_spec.rb +102 -0
  91. data/spec/legion/extensions/llm/error_spec.rb +87 -0
  92. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +120 -0
  93. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +163 -0
  94. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +128 -0
  95. data/spec/legion/extensions/llm/fleet_messages_spec.rb +402 -0
  96. data/spec/legion/extensions/llm/gemspec_spec.rb +25 -0
  97. data/spec/legion/extensions/llm/message_spec.rb +64 -0
  98. data/spec/legion/extensions/llm/model/info_spec.rb +222 -0
  99. data/spec/legion/extensions/llm/models_spec.rb +104 -0
  100. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +203 -0
  101. data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +68 -0
  102. data/spec/legion/extensions/llm/provider_contract_spec.rb +60 -0
  103. data/spec/legion/extensions/llm/provider_settings_spec.rb +76 -0
  104. data/spec/legion/extensions/llm/provider_spec.rb +613 -0
  105. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +68 -0
  106. data/spec/legion/extensions/llm/registry_publisher_spec.rb +22 -0
  107. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +75 -0
  108. data/spec/legion/extensions/llm/responses/thinking_extractor_spec.rb +75 -0
  109. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +222 -0
  110. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +50 -0
  111. data/spec/legion/extensions/llm/routing/registry_event_spec.rb +120 -0
  112. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +155 -0
  113. data/spec/legion/extensions/llm/streaming_spec.rb +108 -0
  114. data/spec/legion/extensions/llm/tool_spec.rb +94 -0
  115. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +60 -0
  116. data/spec/legion/extensions/llm/utils_spec.rb +113 -0
  117. data/spec/legion/extensions/llm_base_contract_spec.rb +110 -0
  118. data/spec/legion/extensions/llm_extension_spec.rb +78 -0
  119. data/spec/legion/extensions/llm_root_spec.rb +51 -0
  120. data/spec/spec_helper.rb +24 -0
  121. data/spec/support/fake_llm_provider.rb +148 -0
  122. data/spec/support/llm_configuration.rb +21 -0
  123. data/spec/support/rspec_configuration.rb +19 -0
  124. data/spec/support/simplecov_configuration.rb +20 -0
  125. metadata +103 -15
@@ -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 sampling and limit parameters for a request.
10
+ # Per G18: all standard/useful params are first-class, mapped per provider by translators.
11
+ Params = ::Data.define(
12
+ :max_tokens, :max_thinking_tokens, :temperature, :top_p, :top_k,
13
+ :stop_sequences, :seed, :frequency_penalty, :presence_penalty,
14
+ :response_format
15
+ ) do
16
+ PARAMS_KNOWN_KEYS = %i[max_tokens max_thinking_tokens temperature top_p top_k
17
+ stop_sequences seed frequency_penalty presence_penalty
18
+ response_format].freeze
19
+
20
+ # Build from a Hash (raw client request or deserialized wire payload).
21
+ # Accepts both canonical key names and common provider spellings.
22
+ def self.from_hash(source)
23
+ return nil if source.nil? || source.empty?
24
+
25
+ h = source.transform_keys(&:to_sym)
26
+
27
+ # Normalize common provider key variations
28
+ h[:max_tokens] ||= h.delete(:max_output_tokens) || h.delete(:num_predict)
29
+ h[:max_thinking_tokens] ||= h.delete(:budget_tokens) || h.delete(:thinking_budget)
30
+ h[:stop_sequences] ||= h.delete(:stop)
31
+
32
+ # Filter to known keys only
33
+ filtered = h.slice(*PARAMS_KNOWN_KEYS)
34
+
35
+ # Return nil if all known values are nil
36
+ return nil if filtered.all? { |_, v| v.nil? }
37
+
38
+ new(
39
+ max_tokens: filtered[:max_tokens],
40
+ max_thinking_tokens: filtered[:max_thinking_tokens],
41
+ temperature: filtered[:temperature],
42
+ top_p: filtered[:top_p],
43
+ top_k: filtered[:top_k],
44
+ stop_sequences: filtered[:stop_sequences],
45
+ seed: filtered[:seed],
46
+ frequency_penalty: filtered[:frequency_penalty],
47
+ presence_penalty: filtered[:presence_penalty],
48
+ response_format: filtered[:response_format]
49
+ )
50
+ end
51
+
52
+ # Serialize to a Hash for AMQP/fleet/wire transport.
53
+ def to_h
54
+ super.compact
55
+ end
56
+ end
57
+ # rubocop:enable Lint/ConstantDefinitionInBlock
58
+ end
59
+ end
60
+ end
61
+ end
@@ -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,98 @@
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
+ OBJECT_SCHEMA_KEYWORDS = %i[properties required additionalProperties].freeze
9
+ COMPOSITE_SCHEMA_KEYWORDS = %i[oneOf anyOf allOf enum $ref $defs definitions].freeze
10
+
11
+ # Canonical tool definition.
12
+ # Ports field vocabulary from Legion::LLM::Types::ToolDefinition.
13
+ ToolDefinition = ::Data.define(:name, :description, :parameters, :source) do
14
+ def self.normalize_parameters(parameters)
15
+ empty = { type: 'object', properties: {} }
16
+ return empty if parameters.nil?
17
+
18
+ schema = if parameters.respond_to?(:transform_keys)
19
+ parameters.transform_keys { |k| k.respond_to?(:to_sym) ? k.to_sym : k }
20
+ end
21
+ return empty if schema.nil? || schema.empty?
22
+ return schema if schema.key?(:type)
23
+ return schema.merge(type: 'object') if OBJECT_SCHEMA_KEYWORDS.any? { |k| schema.key?(k) }
24
+ return schema if COMPOSITE_SCHEMA_KEYWORDS.any? { |k| schema.key?(k) }
25
+
26
+ { type: 'object', properties: schema }
27
+ end
28
+
29
+ # Build from keyword args (primary constructor).
30
+ def self.build(name:, description: '', parameters: nil, source: nil)
31
+ new(
32
+ sanitize_tool_name(name),
33
+ description.to_s,
34
+ normalize_parameters(parameters),
35
+ source || { type: :builtin }
36
+ )
37
+ end
38
+
39
+ # Build from a Hash (raw provider response or deserialized wire payload).
40
+ def self.from_hash(hash, source: nil)
41
+ return nil if hash.nil?
42
+
43
+ normalized = hash.respond_to?(:transform_keys) ? hash.transform_keys(&:to_sym) : {}
44
+ build(
45
+ name: normalized[:name],
46
+ description: normalized[:description],
47
+ parameters: normalized[:parameters] || normalized[:input_schema],
48
+ source: source || normalized[:source]
49
+ )
50
+ end
51
+
52
+ # Build from a registry entry (extension/registry tool metadata).
53
+ def self.from_registry_entry(entry)
54
+ source = {
55
+ type: entry[:tool_class] ? :registry : :extension,
56
+ tool_class: entry[:tool_class],
57
+ extension: entry[:extension],
58
+ runner: entry[:runner],
59
+ function: entry[:function]
60
+ }.compact
61
+
62
+ build(
63
+ name: entry[:name],
64
+ description: entry[:description],
65
+ parameters: entry[:input_schema] || entry[:parameters],
66
+ source: source.compact
67
+ )
68
+ end
69
+
70
+ # Sanitize a tool name to be safe for all wire formats.
71
+ def self.sanitize_tool_name(raw)
72
+ name = raw.to_s.tr('.', '_')
73
+ name = name.gsub(/[^a-zA-Z0-9_-]/, '')
74
+ name = name[0, TOOL_NAME_MAX_LENGTH] if name.length > TOOL_NAME_MAX_LENGTH
75
+ name.empty? ? 'tool' : name
76
+ end
77
+
78
+ def params_schema
79
+ parameters
80
+ end
81
+
82
+ def input_schema
83
+ parameters
84
+ end
85
+
86
+ # Serialize to a Hash for AMQP/fleet/wire transport.
87
+ def to_h
88
+ {
89
+ name: name,
90
+ description: description,
91
+ parameters: parameters
92
+ }.compact.reject { |k, v| k == :description && v == '' }
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Llm
6
+ module Canonical
7
+ # Extracts and normalizes tool schemas from heterogeneous sources.
8
+ module ToolSchema
9
+ EMPTY_OBJECT = { type: 'object', properties: {} }.freeze
10
+
11
+ module_function
12
+
13
+ def extract(tool)
14
+ raw = raw_schema(tool)
15
+ ToolDefinition.normalize_parameters(raw)
16
+ end
17
+
18
+ def raw_schema(tool)
19
+ return nil if tool.nil?
20
+ return tool.params_schema if tool.respond_to?(:params_schema) && tool.params_schema
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']
27
+ end
28
+
29
+ def tool_name(tool)
30
+ return tool.name if tool.respond_to?(:name) && !tool.is_a?(Hash)
31
+ return tool[:name] || tool['name'] if tool.respond_to?(:[])
32
+
33
+ 'unknown'
34
+ end
35
+
36
+ def tool_description(tool)
37
+ return tool.description if tool.respond_to?(:description) && !tool.is_a?(Hash)
38
+ return (tool[:description] || tool['description'] || '').to_s if tool.respond_to?(:[])
39
+
40
+ ''
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end