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
@@ -80,7 +80,7 @@ module Legion
80
80
  /reduce the length of messages/i
81
81
  ].freeze
82
82
 
83
- def parse_error(provider:, response:) # rubocop:disable Metrics/PerceivedComplexity
83
+ def parse_error(provider:, response:)
84
84
  response = response_with_stream_error_body(response)
85
85
  message = provider&.parse_error(response)
86
86
 
@@ -88,9 +88,7 @@ module Legion
88
88
  when 200..399
89
89
  message
90
90
  when 400
91
- if context_length_exceeded?(message)
92
- raise ContextLengthExceededError.new(response, message || 'Context length exceeded')
93
- end
91
+ raise ContextLengthExceededError.new(response, message || 'Context length exceeded') if context_length_exceeded?(message)
94
92
 
95
93
  raise BadRequestError.new(response, message || 'Invalid request - please check your input')
96
94
  when 401
@@ -101,9 +99,7 @@ module Legion
101
99
  raise ForbiddenError.new(response,
102
100
  message || 'Forbidden - you do not have permission to access this resource')
103
101
  when 429
104
- if context_length_exceeded?(message)
105
- raise ContextLengthExceededError.new(response, message || 'Context length exceeded')
106
- end
102
+ raise ContextLengthExceededError.new(response, message || 'Context length exceeded') if context_length_exceeded?(message)
107
103
 
108
104
  raise RateLimitError.new(response, message || 'Rate limit exceeded - please wait a moment')
109
105
  when 500
@@ -14,9 +14,7 @@ module Legion
14
14
 
15
15
  def reject_legacy_options!
16
16
  LEGACY_OPTIONS.each do |key|
17
- if @options.key?(key) || @options.key?(key.to_s)
18
- raise ArgumentError, "#{key} is not supported by fleet protocol v2"
19
- end
17
+ raise ArgumentError, "#{key} is not supported by fleet protocol v2" if @options.key?(key) || @options.key?(key.to_s)
20
18
  end
21
19
  end
22
20
 
@@ -119,9 +119,7 @@ module Legion
119
119
  raise ConfigurationError,
120
120
  "fleet provider instance is not configured: #{instance_id}"
121
121
  end
122
- unless truthy?(dig(instance_settings, :fleet, :respond_to_requests))
123
- raise ConfigurationError, "fleet responses are disabled for provider instance: #{instance_id}"
124
- end
122
+ raise ConfigurationError, "fleet responses are disabled for provider instance: #{instance_id}" unless truthy?(dig(instance_settings, :fleet, :respond_to_requests))
125
123
 
126
124
  provider_class.new(deep_symbolize(instance_settings))
127
125
  end
@@ -186,9 +186,7 @@ module Legion
186
186
  end
187
187
 
188
188
  def signing_key
189
- if defined?(::Legion::Crypt) && ::Legion::Crypt.respond_to?(:cluster_secret)
190
- return ::Legion::Crypt.cluster_secret
191
- end
189
+ return ::Legion::Crypt.cluster_secret if defined?(::Legion::Crypt) && ::Legion::Crypt.respond_to?(:cluster_secret)
192
190
 
193
191
  raise TokenError, 'no signing key available - Legion::Crypt not initialized'
194
192
  rescue TokenError
@@ -19,7 +19,7 @@ module Legion
19
19
  :parameter_size, :quantization, :size_bytes,
20
20
  :modalities_input, :modalities_output, :metadata
21
21
  ) do
22
- # rubocop:disable Metrics/ParameterLists, Metrics/PerceivedComplexity
22
+ # rubocop:disable Metrics/ParameterLists
23
23
  def initialize(
24
24
  id:, name: nil, provider: nil, instance: :default,
25
25
  family: nil, capabilities: [], context_length: nil,
@@ -46,7 +46,7 @@ module Legion
46
46
  metadata: metadata.is_a?(Hash) ? metadata : {}
47
47
  )
48
48
  end
49
- # rubocop:enable Metrics/ParameterLists, Metrics/PerceivedComplexity
49
+ # rubocop:enable Metrics/ParameterLists
50
50
 
51
51
  # ── Capability predicates ─────────────────────────────────────
52
52
 
@@ -206,11 +206,9 @@ module Legion
206
206
  class << self
207
207
  private
208
208
 
209
- def extract_modalities(data) # rubocop:disable Metrics/PerceivedComplexity
209
+ def extract_modalities(data)
210
210
  # New-style keys take priority (round-trip from to_h)
211
- if data.key?(:modalities_input) || data.key?(:modalities_output)
212
- return [Array(data[:modalities_input]), Array(data[:modalities_output])]
213
- end
211
+ return [Array(data[:modalities_input]), Array(data[:modalities_output])] if data.key?(:modalities_input) || data.key?(:modalities_output)
214
212
 
215
213
  # Legacy: modalities is a hash or Modalities object
216
214
  modalities_data = data[:modalities]
@@ -123,7 +123,7 @@ module Legion
123
123
  fetch_provider_models(remote_only: remote_only)[:models]
124
124
  end
125
125
 
126
- def resolve(model_id, provider: nil, assume_exists: false, config: nil) # rubocop:disable Metrics/PerceivedComplexity
126
+ def resolve(model_id, provider: nil, assume_exists: false, config: nil)
127
127
  config ||= Legion::Extensions::Llm.config
128
128
  provider_class = provider ? resolve_provider_class(provider) : nil
129
129
 
@@ -168,7 +168,7 @@ module Legion
168
168
  instance.respond_to?(method, include_private) || super
169
169
  end
170
170
 
171
- def fetch_models_dev_models(existing_models) # rubocop:disable Metrics/PerceivedComplexity
171
+ def fetch_models_dev_models(existing_models)
172
172
  log.info 'Fetching models from models.dev API...'
173
173
 
174
174
  connection = Connection.basic do |f|
@@ -300,7 +300,7 @@ module Legion
300
300
  end
301
301
  end
302
302
 
303
- def add_provider_metadata(models_dev_model, provider_model) # rubocop:disable Metrics/PerceivedComplexity
303
+ def add_provider_metadata(models_dev_model, provider_model)
304
304
  data = models_dev_model.to_h
305
305
  data[:name] = provider_model.name if blank_value?(data[:name])
306
306
  data[:family] = provider_model.family if blank_value?(data[:family])
@@ -92,12 +92,16 @@ module Legion
92
92
  return nil if tools.empty?
93
93
 
94
94
  tools.values.map do |tool|
95
+ # Tools can be ToolDefinition objects or plain Hashes from native_dispatch.
96
+ tool_name = tool.respond_to?(:name) ? tool.name : (tool[:name] || tool['name'])
97
+ tool_desc = tool.respond_to?(:description) ? tool.description : (tool[:description] || tool['description'] || '')
98
+ tool_params = tool.respond_to?(:params_schema) ? tool.params_schema : (tool[:parameters] || tool['parameters'] || {})
95
99
  {
96
100
  type: 'function',
97
101
  function: {
98
- name: tool.name,
99
- description: tool.description,
100
- parameters: tool.params_schema || { type: 'object', properties: {} }
102
+ name: tool_name,
103
+ description: tool_desc,
104
+ parameters: tool_params || { type: 'object', properties: {} }
101
105
  }
102
106
  }
103
107
  end
@@ -10,9 +10,7 @@ module Legion
10
10
 
11
11
  def for(offering, prefix: 'llm.fleet', include_context: true, include_fingerprint: false)
12
12
  parts = [prefix, lane_kind(offering), model_slug(lane_model(offering))]
13
- if include_context && offering.inference? && offering.context_window
14
- parts << "ctx#{offering.context_window}"
15
- end
13
+ parts << "ctx#{offering.context_window}" if include_context && offering.inference? && offering.context_window
16
14
  parts.push('elig', eligibility_fingerprint(offering)) if include_fingerprint
17
15
  parts.join('.')
18
16
  end
@@ -39,7 +39,7 @@ module Legion
39
39
  log.debug { inspect } if Legion::Extensions::Llm.config.log_stream_debug
40
40
  end
41
41
 
42
- def filtered_chunk(chunk) # rubocop:disable Metrics/PerceivedComplexity
42
+ def filtered_chunk(chunk)
43
43
  has_content = !@last_content_delta.empty?
44
44
  has_thinking = !@last_thinking_delta.empty?
45
45
  has_tokens = chunk.input_tokens&.positive? || chunk.output_tokens&.positive?
@@ -16,9 +16,7 @@ module Legion
16
16
  response = connection.post stream_url, payload do |req|
17
17
  req.headers = additional_headers.merge(req.headers) unless additional_headers.empty?
18
18
  on_chunk = build_stream_callback(accumulator, block)
19
- if Legion::Extensions::Llm.config.log_stream_debug
20
- log.debug { "Stream callback prepared: #{on_chunk.inspect}" }
21
- end
19
+ log.debug { "Stream callback prepared: #{on_chunk.inspect}" } if Legion::Extensions::Llm.config.log_stream_debug
22
20
  if faraday_1?
23
21
  req.options[:on_data] = handle_stream(&on_chunk)
24
22
  else
@@ -235,9 +235,7 @@ module Legion
235
235
  def resolve_direct_schema(schema)
236
236
  return extract_schema(schema.to_json_schema) if schema.respond_to?(:to_json_schema)
237
237
  return Legion::Extensions::Llm::Utils.deep_dup(schema) if schema.is_a?(Hash)
238
- if schema.is_a?(Class) && schema.method_defined?(:to_json_schema)
239
- return extract_schema(schema.new.to_json_schema)
240
- end
238
+ return extract_schema(schema.new.to_json_schema) if schema.is_a?(Class) && schema.method_defined?(:to_json_schema)
241
239
 
242
240
  nil
243
241
  end
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Llm
6
- VERSION = '0.4.18'
6
+ VERSION = '0.5.0'
7
7
  end
8
8
  end
9
9
  end
@@ -23,29 +23,125 @@ require 'marcel'
23
23
  require 'ruby_llm/schema'
24
24
  require 'securerandom'
25
25
  require 'time'
26
- require 'zeitwerk'
27
26
  require_relative 'llm/version'
28
27
 
29
28
  module Legion
30
29
  module Extensions
31
30
  # Legion-native namespace for the shared LLM provider framework.
32
31
  module Llm
33
- loader = Zeitwerk::Loader.new
34
- loader.tag = 'lex-llm'
35
- loader.inflector.inflect(
36
- 'api' => 'API',
37
- 'llm' => 'Llm',
38
- 'open_ai_compatible' => 'OpenAICompatible',
39
- 'pdf' => 'PDF',
40
- 'ui' => 'UI'
41
- )
42
- loader.ignore("#{__dir__}/llm/version.rb")
43
- loader.ignore("#{__dir__}/llm/auto_registration.rb")
44
- loader.ignore("#{__dir__}/llm/credential_sources.rb")
45
- loader.ignore("#{__dir__}/llm/transport/exchanges")
46
- loader.ignore("#{__dir__}/llm/transport/messages")
47
- loader.push_dir("#{__dir__}/llm", namespace: self)
48
- loader.setup
32
+ # ------------------------------------------------------------------ #
33
+ # Explicit requires (replaces Zeitwerk autoloading). #
34
+ # Load order: base classes & canonical types first, then anything #
35
+ # that references them. All live under Legion::Extensions::Llm so #
36
+ # unqualified constant lookups resolve via Ruby scope. #
37
+ # ------------------------------------------------------------------ #
38
+
39
+ # --- Base value objects (no internal deps) ---
40
+ require_relative 'llm/mime_type'
41
+ require_relative 'llm/model/info'
42
+ require_relative 'llm/model/modalities'
43
+ require_relative 'llm/model/pricing_category'
44
+ require_relative 'llm/model/pricing_tier'
45
+ require_relative 'llm/model/pricing'
46
+ require_relative 'llm/configuration'
47
+ require_relative 'llm/thinking'
48
+ require_relative 'llm/tokens'
49
+ require_relative 'llm/message'
50
+ require_relative 'llm/tool_call'
51
+ require_relative 'llm/content'
52
+ require_relative 'llm/errors/unsupported_capability'
53
+ require_relative 'llm/error'
54
+
55
+ # --- Build on message/base types ---
56
+ require_relative 'llm/chunk'
57
+ require_relative 'llm/model'
58
+ require_relative 'llm/attachment'
59
+
60
+ # --- Streaming fundamentals (must load before streaming/provider) ---
61
+ require_relative 'llm/stream_accumulator'
62
+ require_relative 'llm/responses/stream_chunk'
63
+ require_relative 'llm/streaming'
64
+
65
+ # --- Context, Connection ---
66
+ require_relative 'llm/context'
67
+ require_relative 'llm/connection'
68
+
69
+ # --- Response normalizers ---
70
+ require_relative 'llm/responses/chat_response'
71
+ require_relative 'llm/responses/embedding_response'
72
+ require_relative 'llm/responses/thinking_extractor'
73
+
74
+ # --- Provider base & allied modules ---
75
+ require_relative 'llm/provider_contract'
76
+ require_relative 'llm/provider_settings'
77
+ require_relative 'llm/provider'
78
+
79
+ # --- Provider subtypes ---
80
+ require_relative 'llm/provider/open_ai_compatible'
81
+
82
+ # --- Routing ---
83
+ require_relative 'llm/routing'
84
+ require_relative 'llm/routing/lane_key'
85
+ require_relative 'llm/routing/offering_registry'
86
+ require_relative 'llm/routing/registry_event'
87
+ require_relative 'llm/routing/model_offering'
88
+
89
+ # --- Models (scans for Provider subclasses) ---
90
+ require_relative 'llm/models'
91
+
92
+ # --- Agent & Chat (reference Provider, Context, Chat at method-time) ---
93
+ require_relative 'llm/agent'
94
+ require_relative 'llm/chat'
95
+
96
+ # --- Domain services ---
97
+ require_relative 'llm/embedding'
98
+ require_relative 'llm/moderation'
99
+ require_relative 'llm/image'
100
+ require_relative 'llm/transcription'
101
+
102
+ # --- Registry & misc support ---
103
+ require_relative 'llm/registry_event_builder'
104
+ require_relative 'llm/registry_publisher'
105
+ require_relative 'llm/auto_registration'
106
+ require_relative 'llm/credential_sources'
107
+ require_relative 'llm/tool'
108
+ require_relative 'llm/utils'
109
+ require_relative 'llm/aliases'
110
+
111
+ # --- Fleet protocol (depends on Provider, Models) ---
112
+ require_relative 'llm/fleet/protocol'
113
+ require_relative 'llm/fleet/settings'
114
+ require_relative 'llm/fleet/token_error'
115
+ require_relative 'llm/fleet/envelope_validation'
116
+ require_relative 'llm/fleet/publish_safety'
117
+ require_relative 'llm/fleet/default_exchange_reply'
118
+ require_relative 'llm/fleet/token_validator'
119
+ require_relative 'llm/fleet/worker_execution'
120
+ require_relative 'llm/fleet/provider_responder'
121
+
122
+ # --- Transport lane (references Fleet exchange/message autoloads) ---
123
+ require_relative 'llm/transport/fleet_lane'
124
+
125
+ # --- Canonical types — explicit self-contained loader ---
126
+ require_relative 'llm/canonical'
127
+
128
+ # --- Transport modules (lazy — depend on optional legion-transport) ---
129
+ # These remain as autoload so boot-time does not force legion-transport.
130
+ module Transport
131
+ # Shared AMQP exchange definitions for fleet routing.
132
+ # Lazy-loaded; only instantiated when legion-transport is available.
133
+ module Exchanges
134
+ autoload :Fleet, File.expand_path('llm/transport/exchanges/fleet', __dir__)
135
+ end
136
+
137
+ # Shared AMQP message envelopes for fleet request/response cycles.
138
+ # Lazy-loaded; only instantiated when legion-transport is available.
139
+ module Messages
140
+ autoload :FleetRequest, File.expand_path('llm/transport/messages/fleet_request', __dir__)
141
+ autoload :FleetResponse, File.expand_path('llm/transport/messages/fleet_response', __dir__)
142
+ autoload :FleetError, File.expand_path('llm/transport/messages/fleet_error', __dir__)
143
+ end
144
+ end
49
145
 
50
146
  Schema = ::RubyLLM::Schema unless const_defined?(:Schema, false)
51
147
 
@@ -148,6 +244,11 @@ module Legion
148
244
  require_policy: false,
149
245
  require_idempotency: true,
150
246
  idempotency_ttl_seconds: 600
247
+ },
248
+ request: {
249
+ logger: {
250
+ request_payload: false
251
+ }
151
252
  }
152
253
  }
153
254
  }
@@ -156,24 +257,6 @@ module Legion
156
257
  def self.provider_settings(...)
157
258
  ProviderSettings.build(...)
158
259
  end
159
-
160
- require_relative 'llm/auto_registration'
161
- require_relative 'llm/credential_sources'
162
- loader.eager_load
163
-
164
- module Transport
165
- # Local autoloads for fleet exchange classes that depend on legion-transport.
166
- module Exchanges
167
- autoload :Fleet, File.expand_path('llm/transport/exchanges/fleet', __dir__)
168
- end
169
-
170
- # Local autoloads for fleet message classes that depend on legion-transport.
171
- module Messages
172
- autoload :FleetRequest, File.expand_path('llm/transport/messages/fleet_request', __dir__)
173
- autoload :FleetResponse, File.expand_path('llm/transport/messages/fleet_response', __dir__)
174
- autoload :FleetError, File.expand_path('llm/transport/messages/fleet_error', __dir__)
175
- end
176
- end
177
260
  end
178
261
  end
179
262
  end
Binary file
Binary file
Binary file
@@ -0,0 +1 @@
1
+ Ruby is the best.
Binary file
@@ -0,0 +1 @@
1
+ <truism>Ruby is the best</truism>
Binary file
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Llm::Agent do
6
+ include_context 'with configured Legion::Extensions::Llm'
7
+ include_context 'with fake llm provider'
8
+
9
+ it 'builds a configured plain chat via .chat with runtime inputs' do
10
+ tool_class = Class.new(Legion::Extensions::Llm::Tool) do
11
+ def name = 'echo_tool'
12
+ end
13
+
14
+ agent_class = Class.new(Legion::Extensions::Llm::Agent) do
15
+ model 'fake-chat-model', provider: :fake_llm, assume_model_exists: true
16
+ inputs :display_name
17
+ instructions { "Hello #{display_name}" }
18
+ tools { [tool_class.new] }
19
+ params { { max_tokens: 12 } }
20
+ end
21
+
22
+ chat = agent_class.chat(display_name: 'Ava')
23
+
24
+ expect(chat.messages.first.role).to eq(:system)
25
+ expect(chat.messages.first.content).to eq('Hello Ava')
26
+ expect(chat.tools.keys).to include(:echo_tool)
27
+ expect(chat.params).to eq(max_tokens: 12)
28
+ end
29
+
30
+ it 'exposes Legion::Extensions::Llm::Chat as chat in execution context for .chat' do
31
+ agent_class = Class.new(Legion::Extensions::Llm::Agent) do
32
+ model 'fake-chat-model', provider: :fake_llm, assume_model_exists: true
33
+ instructions { chat.class.name }
34
+ end
35
+
36
+ chat = agent_class.chat
37
+ expect(chat.messages.first.content).to eq('Legion::Extensions::Llm::Chat')
38
+ end
39
+
40
+ it 'raises when instructions default prompt is missing' do
41
+ agent_class = Class.new(Legion::Extensions::Llm::Agent) do
42
+ model 'fake-chat-model', provider: :fake_llm, assume_model_exists: true
43
+ instructions
44
+ end
45
+
46
+ expect { agent_class.chat }.to raise_error(Legion::Extensions::Llm::PromptNotFoundError, /Prompt file not found/)
47
+ end
48
+
49
+ it 'supports inline schema DSL via schema do ... end' do
50
+ agent_class = Class.new(Legion::Extensions::Llm::Agent) do
51
+ model 'fake-chat-model', provider: :fake_llm, assume_model_exists: true
52
+ schema do
53
+ string :verdict, enum: %w[pass revise]
54
+ string :feedback
55
+ end
56
+ end
57
+
58
+ chat = agent_class.chat
59
+
60
+ expect(chat.schema).to include(name: 'Schema', strict: true, schema: include(type: 'object'))
61
+ expect(chat.schema.dig(:schema, :properties)).to include(
62
+ verdict: include(type: 'string'),
63
+ feedback: include(type: 'string')
64
+ )
65
+ end
66
+
67
+ it 'supports runtime-evaluated schema blocks that return a schema value' do
68
+ agent_class = Class.new(Legion::Extensions::Llm::Agent) do
69
+ model 'fake-chat-model', provider: :fake_llm, assume_model_exists: true
70
+ inputs :strict
71
+
72
+ schema do
73
+ if strict
74
+ {
75
+ type: 'object',
76
+ properties: { answer: { type: 'string' } },
77
+ required: ['answer'],
78
+ additionalProperties: false
79
+ }
80
+ end
81
+ end
82
+ end
83
+
84
+ strict_chat = agent_class.chat(strict: true)
85
+ loose_chat = agent_class.chat(strict: false)
86
+
87
+ expect(strict_chat.schema).to include(name: 'response', strict: true, schema: include(type: 'object'))
88
+ expect(loose_chat.schema).to be_nil
89
+ end
90
+
91
+ it 'can ask using a registered provider' do
92
+ agent_class = Class.new(Legion::Extensions::Llm::Agent) do
93
+ model 'fake-chat-model', provider: :fake_llm, assume_model_exists: true
94
+ instructions 'Answer questions clearly.'
95
+ end
96
+
97
+ stub_const('SpecChatAgent', agent_class)
98
+
99
+ response = SpecChatAgent.new.ask('hello')
100
+ expect(response.content).to include('fake response to hello')
101
+ expect(response.role).to eq(:assistant)
102
+ end
103
+
104
+ it 'delegates add_message to the underlying chat interface' do
105
+ agent_class = Class.new(Legion::Extensions::Llm::Agent) do
106
+ model 'fake-chat-model', provider: :fake_llm, assume_model_exists: true
107
+ end
108
+
109
+ agent = agent_class.new
110
+ message = agent.add_message(role: :user, content: 'Hello')
111
+
112
+ expect(message.role).to eq(:user)
113
+ expect(message.content).to eq('Hello')
114
+ expect(agent.chat.messages.last).to eq(message)
115
+ end
116
+
117
+ it 'exposes messages like Legion::Extensions::Llm::Chat' do
118
+ agent_class = Class.new(Legion::Extensions::Llm::Agent) do
119
+ model 'fake-chat-model', provider: :fake_llm, assume_model_exists: true
120
+ end
121
+
122
+ agent = agent_class.new
123
+ agent.add_message(role: :user, content: 'First')
124
+
125
+ expect(agent.messages).to eq(agent.chat.messages)
126
+ expect(agent.messages.last.content).to eq('First')
127
+ end
128
+
129
+ it 'delegates callback hooks to the underlying chat' do
130
+ fake_chat = Class.new do
131
+ attr_reader :events
132
+
133
+ def initialize
134
+ @events = []
135
+ end
136
+
137
+ def on_new_message(&)
138
+ @events << :new_message
139
+ self
140
+ end
141
+
142
+ def on_end_message(&)
143
+ @events << :end_message
144
+ self
145
+ end
146
+
147
+ def on_tool_call(&)
148
+ @events << :tool_call
149
+ self
150
+ end
151
+
152
+ def on_tool_result(&)
153
+ @events << :tool_result
154
+ self
155
+ end
156
+ end.new
157
+
158
+ agent = Class.new(described_class).new(chat: fake_chat)
159
+
160
+ expect(agent.on_new_message { :ok }).to eq(fake_chat)
161
+ expect(agent.on_end_message { :ok }).to eq(fake_chat)
162
+ expect(agent.on_tool_call { :ok }).to eq(fake_chat)
163
+ expect(agent.on_tool_result { :ok }).to eq(fake_chat)
164
+ expect(fake_chat.events).to eq(%i[new_message end_message tool_call tool_result])
165
+ end
166
+
167
+ it 'supports Enumerable by delegating each to chat' do
168
+ fake_chat = Class.new do
169
+ def each(&)
170
+ return enum_for(:each) unless block_given?
171
+
172
+ %w[first second].each(&)
173
+ end
174
+ end.new
175
+
176
+ agent = Class.new(described_class).new(chat: fake_chat)
177
+ expect(agent.map(&:upcase)).to eq(%w[FIRST SECOND])
178
+ end
179
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'open3'
5
+ require 'rbconfig'
6
+
7
+ RSpec.describe Legion::Extensions::Llm::Attachment do
8
+ it 'supports path attachments from the public API' do
9
+ script = <<~'RUBY'
10
+ require 'legion/extensions/llm'
11
+
12
+ content = Legion::Extensions::Llm::Content.new('What is in this file?', 'spec/fixtures/ruby.txt')
13
+ attachment = content.attachments.first
14
+ puts "#{attachment.filename},#{attachment.mime_type}"
15
+ RUBY
16
+
17
+ stdout, stderr, status = Open3.capture3(
18
+ RbConfig.ruby, '-Ilib', '-e', script,
19
+ chdir: File.expand_path('../../../..', __dir__)
20
+ )
21
+
22
+ expect(status.success?).to be(true), stderr
23
+ expect(stdout.strip).to eq('ruby.txt,text/plain')
24
+ end
25
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Llm::AutoRegistration do
6
+ # Build a fake provider module that extends AutoRegistration,
7
+ # mimicking what a real lex-llm-* provider would look like.
8
+ let(:fake_provider_class) { Class.new }
9
+
10
+ let(:provider_module) do
11
+ klass = fake_provider_class
12
+ mod = Module.new do
13
+ extend Legion::Extensions::Llm::AutoRegistration
14
+
15
+ const_set(:PROVIDER_FAMILY, :fake_provider)
16
+
17
+ define_singleton_method(:provider_class) { klass }
18
+ end
19
+ mod
20
+ end
21
+
22
+ describe '#discover_instances' do
23
+ it 'returns an empty hash by default' do
24
+ expect(provider_module.discover_instances).to eq({})
25
+ end
26
+ end
27
+
28
+ describe '#provider_aliases' do
29
+ it 'returns an empty alias list by default' do
30
+ expect(provider_module.provider_aliases).to eq([])
31
+ end
32
+ end
33
+
34
+ it 'does not expose legion-llm registry mutation hooks' do
35
+ expect(provider_module).not_to respond_to(:register_discovered_instances)
36
+ expect(provider_module).not_to respond_to(:rediscover!)
37
+ end
38
+ end