lex-llm 0.3.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -0
  3. data/README.md +18 -2
  4. data/lex-llm.gemspec +1 -0
  5. data/lib/legion/extensions/llm/auto_registration.rb +7 -36
  6. data/lib/legion/extensions/llm/embedding.rb +1 -1
  7. data/lib/legion/extensions/llm/error.rb +14 -0
  8. data/lib/legion/extensions/llm/errors/unsupported_capability.rb +21 -0
  9. data/lib/legion/extensions/llm/fleet/default_exchange_reply.rb +81 -0
  10. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +39 -0
  11. data/lib/legion/extensions/llm/fleet/protocol.rb +16 -0
  12. data/lib/legion/extensions/llm/fleet/publish_safety.rb +123 -0
  13. data/lib/legion/extensions/llm/message.rb +9 -3
  14. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +37 -36
  15. data/lib/legion/extensions/llm/provider.rb +198 -4
  16. data/lib/legion/extensions/llm/provider_contract.rb +21 -0
  17. data/lib/legion/extensions/llm/provider_settings.rb +18 -1
  18. data/lib/legion/extensions/llm/responses/chat_response.rb +43 -0
  19. data/lib/legion/extensions/llm/responses/embedding_response.rb +38 -0
  20. data/lib/legion/extensions/llm/responses/stream_chunk.rb +43 -0
  21. data/lib/legion/extensions/llm/responses/thinking_extractor.rb +155 -0
  22. data/lib/legion/extensions/llm/stream_accumulator.rb +12 -1
  23. data/lib/legion/extensions/llm/transport/exchanges/fleet.rb +24 -0
  24. data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +64 -0
  25. data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +155 -0
  26. data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +63 -0
  27. data/lib/legion/extensions/llm/version.rb +1 -1
  28. data/lib/legion/extensions/llm.rb +31 -11
  29. metadata +29 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 21bb44444f871870151b379672c39b043c36233ee0b7d634660a7fe021f355b6
4
- data.tar.gz: 2bc64a7a18d4304179e7465c99e21fdf584a9e4dd54860b207bf8d8c87e738cf
3
+ metadata.gz: 6f3fc1bac35781a8134a6d24d7467a790cdd506244cfd4f0e66955a4fa82ceb9
4
+ data.tar.gz: 0c1cdfe9dee8e21c5b9bba0a01b12f5ef41e30b46c73ff8d22ccc35d621818a9
5
5
  SHA512:
6
- metadata.gz: 930d418014199a5f3b34bf505555e54462e2e590c11475859221d9a83c2def586f547c8341f813cfab03d6677ddbc8a66e06edc9f36e6bb6ffea05d36e40ce0b
7
- data.tar.gz: 66201e1d6405692d6da1fbb38d294b7632a0ef2ca42f1578c548746a5caeb3d3a25d1d37347e33f88d628408d962707d4ab254038cc97d0ad27b89bafa42b0e8
6
+ metadata.gz: 4592bdc8998415754bfce42444be4168fc05eacd3d20be7872c3f5ed2ef3384cd44a9027cb23bb7f3f0e8dda8b12451f51332b16d2a4611975e950da0a5da2af
7
+ data.tar.gz: a90e2831742bc0af3c0d540f8459434d8fb287cb5504dbaf2be6c22425aceff4d929ff5c230485951d6669b46be79dd439d0f80a688272b52b8f494adab83b4f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,48 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.2 - 2026-05-06
4
+
5
+ - Remove the temporary settings logger wrapper and lazy-load fleet transport envelopes so `lex-llm` boot does not force `legion-transport` loading.
6
+
7
+ ## 0.4.1 - 2026-05-06
8
+
9
+ - Make `AutoRegistration` a pure provider discovery mixin and remove upward `Legion::LLM::Call::Registry` mutation hooks.
10
+ - Add provider alias metadata so `legion-llm` can register compatibility provider families without provider require-time side effects.
11
+ - Pass live discovery flags and filters through from `Provider#discover_offerings` to `#list_models`.
12
+ - Merge provider-specific embedding params into canonical `Provider#embed` request payloads.
13
+
14
+ ## 0.4.0 - 2026-05-06
15
+
16
+ - Set the coordinated sweep dependency floor for provider-owned fleet responders.
17
+ - Make `Provider#discover_offerings(live: false)` serve only cached live discovery results so inventory reads do not probe provider endpoints.
18
+
19
+ ## 0.3.6 - 2026-05-06
20
+
21
+ - Replace shared fleet request, response, and error envelopes with strict fleet protocol v2 fields.
22
+ - Reject legacy fleet envelope fields and publish provider replies through the AMQP default exchange reply queue with optional mandatory routing and publisher confirms.
23
+
24
+ ## 0.3.5 - 2026-05-06
25
+
26
+ - Add shared response normalization value objects for chat, stream, embedding, and thinking extraction.
27
+ - Strip provider thinking from caller-visible OpenAI-compatible completion content, including malformed trailing close-tag output.
28
+ - Preserve provider reasoning metadata while tolerating streaming tool-call deltas without optional function names.
29
+
30
+ ## 0.3.4 - 2026-05-06
31
+
32
+ - Add shared provider contract and unsupported capability error namespace for lex-llm provider gems.
33
+ - Require keyword provider embed/count token calls and validate provider settings instance nesting.
34
+ - Move shared fleet defaults under nested consumer/auth settings.
35
+
36
+ ## 0.3.3 - 2026-05-03
37
+
38
+ - Fix OpenAI-compatible streaming to keep split `<think>` tag content out of streamed assistant content.
39
+ - Strip leaked assistant thinking from outbound OpenAI-compatible history, including dangling close-tag content from prior responses.
40
+ - Tolerate incomplete streaming tool-call deltas that omit `function.name`.
41
+
42
+ ## 0.3.2 - 2026-05-03
43
+
44
+ - Fix AutoRegistration to pass the discovered instance id into provider adapter config for instance-aware model offerings
45
+
3
46
  ## 0.3.1 - 2026-05-02
4
47
 
5
48
  - Fix AutoRegistration to pass tier and capabilities metadata to Call::Registry on registration
data/README.md CHANGED
@@ -37,7 +37,7 @@ Expected provider gems include:
37
37
  - `lex-llm-mlx`
38
38
  - `lex-llm-bedrock`
39
39
  - `lex-llm-vertex`
40
- - `lex-llm-azure`
40
+ - `lex-llm-azure-foundry`
41
41
 
42
42
  ## Install
43
43
 
@@ -48,7 +48,7 @@ gem 'lex-llm'
48
48
  Provider extensions should declare `lex-llm` as a gemspec dependency:
49
49
 
50
50
  ```ruby
51
- spec.add_dependency 'lex-llm', '>= 0.1.6'
51
+ spec.add_dependency 'lex-llm', '>= 0.4.0'
52
52
  ```
53
53
 
54
54
  For local development across LegionIO repos, prefer a local path override in the app or test `Gemfile`, not a permanent git dependency in the gemspec.
@@ -297,6 +297,22 @@ At minimum, a provider extension should define:
297
297
 
298
298
  Provider extensions should avoid duplicating shared classes, schema logic, fleet lane construction, JSON handling, or common request/response objects.
299
299
 
300
+ Canonical provider calls are keyword-based:
301
+
302
+ ```ruby
303
+ provider.chat(messages:, model:, tools: [], temperature: nil, params: {}, headers: {}, schema: nil, thinking: nil)
304
+ provider.stream_chat(messages:, model:, tools: [], temperature: nil, params: {}, headers: {}, schema: nil, thinking: nil) { |chunk| ... }
305
+ provider.embed(text:, model:, dimensions: nil, params: {}, headers: {})
306
+ provider.image(prompt:, model:, size:, with: nil, mask: nil, params: {})
307
+ provider.count_tokens(messages:, model:, params: {})
308
+ provider.health(live: false)
309
+ provider.discover_offerings(live: false, **filters)
310
+ ```
311
+
312
+ Provider responses should normalize through the shared response objects before they reach callers. Visible assistant text and provider reasoning are separate values: provider-specific thinking fields, OpenAI-compatible `reasoning_content`, and literal `<think>...</think>` text are removed from caller-visible content and preserved as thinking metadata when present.
313
+
314
+ Fleet envelopes also live here. `FleetRequest`, `FleetResponse`, and `FleetError` are protocol-v2 transport messages with `operation`, `request_id`, `correlation_id`, `idempotency_key`, `message_context`, and signed-token fields. Provider gems should consume and publish these shared envelopes instead of defining local fleet message shapes.
315
+
300
316
  All providers inherit `#readiness(live: false)`, which returns configured state, provider locality, API base, endpoint helpers, and non-live health metadata without probing remote services. Providers with a cheap health endpoint can pass `live: true` to include that endpoint response. OpenAI-compatible providers also inherit shared model-list parsing that maps discovered models into normalized capabilities and modalities for Legion routing.
301
317
 
302
318
  ## Schema Status
data/lex-llm.gemspec CHANGED
@@ -37,6 +37,7 @@ Gem::Specification.new do |spec|
37
37
  spec.add_dependency 'legion-json', '>= 1.2.1'
38
38
  spec.add_dependency 'legion-logging', '>= 1.3.2'
39
39
  spec.add_dependency 'legion-settings', '>= 1.3.14'
40
+ spec.add_dependency 'legion-transport', '>= 1.4.14'
40
41
  spec.add_dependency 'marcel', '~> 1'
41
42
  spec.add_dependency 'ruby_llm-schema', '~> 0'
42
43
  spec.add_dependency 'zeitwerk', '~> 2'
@@ -3,9 +3,9 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Llm
6
- # Mixin that lex-llm-* provider modules `extend` to get shared
7
- # registration boilerplate. The provider only needs to override
8
- # `discover_instances` everything else is handled here.
6
+ # Mixin that lex-llm-* provider modules `extend` to expose shared
7
+ # discovery metadata. Registration into Legion::LLM is owned by
8
+ # legion-llm so loaded providers can be rediscovered after reloads.
9
9
  #
10
10
  # Prerequisites on the extending module:
11
11
  # - `PROVIDER_FAMILY` constant (Symbol, e.g. :ollama)
@@ -16,39 +16,10 @@ module Legion
16
16
  {}
17
17
  end
18
18
 
19
- # Calls discover_instances, creates a LexLLMAdapter for each,
20
- # and registers into Call::Registry.
21
- #
22
- # Strips :tier and :capabilities from config before passing to
23
- # the adapter (these are metadata, not connection config).
24
- #
25
- # Guarded: no-op when Legion::LLM::Call::Registry is not loaded.
26
- def register_discovered_instances
27
- return unless defined?(Legion::LLM::Call::Registry)
28
-
29
- instances = discover_instances
30
- instances.each do |instance_id, config|
31
- registry_config = config.except(:tier, :capabilities)
32
- adapter = Legion::LLM::Call::LexLLMAdapter.new(
33
- self::PROVIDER_FAMILY, provider_class, instance_config: registry_config
34
- )
35
- meta = { tier: config[:tier], capabilities: config[:capabilities] || [] }
36
- Legion::LLM::Call::Registry.register(
37
- self::PROVIDER_FAMILY, adapter, instance: instance_id, metadata: meta
38
- )
39
- end
40
- rescue StandardError => e
41
- log.warn "[#{self::PROVIDER_FAMILY}] self-registration failed: #{e.message}" if respond_to?(:log)
42
- end
43
-
44
- # Deregisters all instances for this provider and re-runs discovery.
45
- #
46
- # Guarded: no-op when Legion::LLM::Call::Registry is not loaded.
47
- def rediscover!
48
- return unless defined?(Legion::LLM::Call::Registry)
49
-
50
- Legion::LLM::Call::Registry.deregister_provider(self::PROVIDER_FAMILY)
51
- register_discovered_instances
19
+ # Optional provider-family aliases that legion-llm should register
20
+ # against the same discovered provider instances.
21
+ def provider_aliases
22
+ []
52
23
  end
53
24
  end
54
25
  end
@@ -25,7 +25,7 @@ module Legion
25
25
  config: config)
26
26
  model_id = model.id
27
27
 
28
- provider_instance.embed(text, model: model_id, dimensions:)
28
+ provider_instance.embed(text:, model: model_id, dimensions:)
29
29
  end
30
30
  end
31
31
  end
@@ -27,6 +27,20 @@ module Legion
27
27
  class ModelNotFoundError < StandardError; end
28
28
  class UnsupportedAttachmentError < StandardError; end
29
29
 
30
+ # Backward-compatible unsupported-capability error alias.
31
+ class UnsupportedCapabilityError < Errors::UnsupportedCapability
32
+ def initialize(message = nil, provider: nil, capability: nil, model: nil)
33
+ if provider && capability
34
+ super(provider:, capability:, model:)
35
+ else
36
+ @provider = provider
37
+ @capability = capability
38
+ @model = model
39
+ StandardError.instance_method(:initialize).bind_call(self, message)
40
+ end
41
+ end
42
+ end
43
+
30
44
  # Error classes for different HTTP status codes
31
45
  class BadRequestError < Error; end
32
46
  class ForbiddenError < Error; end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Llm
6
+ module Errors
7
+ # Raised when a provider receives a canonical call for an unsupported capability.
8
+ class UnsupportedCapability < StandardError
9
+ attr_reader :provider, :capability, :model
10
+
11
+ def initialize(provider:, capability:, model: nil)
12
+ @provider = provider
13
+ @capability = capability
14
+ @model = model
15
+ super("Provider #{provider} does not support #{capability}#{" for #{model}" if model}")
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'publish_safety'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Llm
8
+ module Fleet
9
+ # Publishes correlated fleet replies directly to the caller's reply queue.
10
+ module DefaultExchangeReply
11
+ include PublishSafety
12
+
13
+ DEFAULT_REPLY_PUBLISH_OPTIONS = {
14
+ mandatory: false,
15
+ publisher_confirm: false,
16
+ spool: false,
17
+ return_result: true
18
+ }.freeze
19
+
20
+ def publish(options = nil)
21
+ raise unless @valid
22
+
23
+ requested_options = DEFAULT_REPLY_PUBLISH_OPTIONS.merge(@options).merge(options || {})
24
+ return_result = return_publish_result?(requested_options)
25
+ publish_options = reply_publish_options(requested_options)
26
+ validate_payload_size
27
+ default_exchange = channel.default_exchange
28
+ return_state = {}
29
+ install_return_listener(default_exchange, requested_options, return_state)
30
+ prepare_publisher_confirms(default_exchange, requested_options)
31
+ default_exchange.publish(encode_message, **publish_options)
32
+ return nil unless return_result
33
+
34
+ publish_result(default_exchange, requested_options.merge(publish_options), return_state)
35
+ rescue Bunny::ConnectionClosedError, Bunny::ChannelAlreadyClosed, Bunny::ChannelError,
36
+ Bunny::NetworkErrorWrapper, IOError, Timeout::Error => e
37
+ handle_exception(e, level: :warn, handled: true, operation: 'llm.fleet.reply.publish')
38
+ reply_publish_failure_result(e, publish_options || @options)
39
+ end
40
+
41
+ private
42
+
43
+ def reply_publish_failure_result(error, options)
44
+ {
45
+ status: :failed,
46
+ accepted: false,
47
+ error_class: error.class.name,
48
+ error: error.message,
49
+ routing_key: options[:routing_key] || routing_key,
50
+ message_id: message_id,
51
+ correlation_id: correlation_id
52
+ }.compact
53
+ end
54
+
55
+ def reply_publish_options(options)
56
+ {
57
+ routing_key: routing_key,
58
+ content_type: options[:content_type] || content_type,
59
+ content_encoding: options[:content_encoding] || content_encoding,
60
+ type: options[:type] || type,
61
+ priority: options[:priority] || priority,
62
+ expiration: options[:expiration] || expiration,
63
+ headers: reply_headers(options),
64
+ persistent: options.key?(:persistent) ? options[:persistent] : persistent,
65
+ message_id: message_id,
66
+ correlation_id: correlation_id,
67
+ reply_to: reply_to,
68
+ app_id: options[:app_id] || app_id,
69
+ timestamp: timestamp,
70
+ mandatory: options[:mandatory] == true
71
+ }.compact
72
+ end
73
+
74
+ def reply_headers(options)
75
+ options[:headers] ? headers.merge(options[:headers]) : headers
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'protocol'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Llm
8
+ module Fleet
9
+ # Shared validation helpers for strict fleet protocol v2 envelopes.
10
+ module EnvelopeValidation
11
+ LEGACY_OPTIONS = %i[schema_version request_type fleet_correlation_id].freeze
12
+
13
+ private
14
+
15
+ def reject_legacy_options!
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
20
+ end
21
+ end
22
+
23
+ def require_option!(key)
24
+ return if @options.key?(key) && !@options[key].nil?
25
+
26
+ raise ArgumentError, "#{key} is required"
27
+ end
28
+
29
+ def require_protocol_version!
30
+ version = @options.fetch(:protocol_version, Fleet::Protocol::VERSION)
31
+ return if version == Fleet::Protocol::VERSION
32
+
33
+ raise ArgumentError, "protocol_version must be #{Fleet::Protocol::VERSION}"
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Llm
6
+ module Fleet
7
+ module Protocol
8
+ VERSION = 2
9
+ REQUEST_TYPE = 'llm.fleet.request'
10
+ RESPONSE_TYPE = 'llm.fleet.response'
11
+ ERROR_TYPE = 'llm.fleet.error'
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Llm
6
+ module Fleet
7
+ # Publish-result helpers kept local to fleet messages so they work with older legion-transport releases.
8
+ module PublishSafety
9
+ private
10
+
11
+ def return_publish_result?(options)
12
+ options[:return_result] == true || options[:mandatory] == true || options[:publisher_confirm] == true ||
13
+ options[:spool] == false
14
+ end
15
+
16
+ def install_return_listener(exchange_dest, options, return_state)
17
+ return unless options[:mandatory] == true
18
+
19
+ return_channel = publish_channel(exchange_dest)
20
+ return unless return_channel.respond_to?(:on_return)
21
+
22
+ expected_correlation_id = correlation_id
23
+ expected_message_id = message_id
24
+ return_channel.on_return do |return_info, properties, _content|
25
+ next unless returned_message_matches?(
26
+ properties,
27
+ correlation_id: expected_correlation_id,
28
+ message_id: expected_message_id
29
+ )
30
+
31
+ record_return!(return_state, return_info)
32
+ end
33
+ end
34
+
35
+ def returned_message_matches?(properties, correlation_id:, message_id:)
36
+ return false if property_mismatch?(properties, :correlation_id, correlation_id)
37
+ return false if property_mismatch?(properties, :message_id, message_id)
38
+
39
+ true
40
+ end
41
+
42
+ def property_mismatch?(properties, key, expected)
43
+ return false unless expected
44
+ return false unless properties.respond_to?(key)
45
+
46
+ value = properties.public_send(key)
47
+ value && value != expected
48
+ end
49
+
50
+ def record_return!(return_state, return_info)
51
+ return_state[:returned] = true
52
+ return_state[:reply_code] = return_info.reply_code if return_info.respond_to?(:reply_code)
53
+ return_state[:reply_text] = return_info.reply_text if return_info.respond_to?(:reply_text)
54
+ end
55
+
56
+ def prepare_publisher_confirms(exchange_dest, options)
57
+ return unless options[:publisher_confirm] == true
58
+
59
+ confirm_channel = publish_channel(exchange_dest)
60
+ confirm_channel.confirm_select if confirm_channel.respond_to?(:confirm_select)
61
+ end
62
+
63
+ def publish_result(exchange_dest, options, return_state)
64
+ status = confirm_publish(exchange_dest, options)
65
+ status = :unroutable if return_state[:returned]
66
+ {
67
+ status: status,
68
+ accepted: status == :accepted,
69
+ exchange: exchange_name(exchange_dest),
70
+ routing_key: options[:routing_key] || routing_key || '',
71
+ message_id: message_id,
72
+ return_reply_code: return_state[:reply_code],
73
+ return_reply_text: return_state[:reply_text],
74
+ correlation_id: correlation_id
75
+ }.compact
76
+ end
77
+
78
+ def publish_failure_result(status, error, options)
79
+ {
80
+ status: status,
81
+ accepted: false,
82
+ error_class: error.class.name,
83
+ error: error.message,
84
+ routing_key: options[:routing_key] || routing_key || '',
85
+ message_id: message_id,
86
+ correlation_id: correlation_id
87
+ }.compact
88
+ end
89
+
90
+ def confirm_publish(exchange_dest, options)
91
+ return :accepted unless options[:publisher_confirm] == true
92
+
93
+ confirm_channel = publish_channel(exchange_dest)
94
+ return :accepted unless confirm_channel.respond_to?(:wait_for_confirms)
95
+
96
+ timeout = options[:publish_confirm_timeout_ms]
97
+ confirmed = if timeout
98
+ confirm_channel.wait_for_confirms(timeout.to_f / 1000.0)
99
+ else
100
+ confirm_channel.wait_for_confirms
101
+ end
102
+ confirmed == false ? :nacked : :accepted
103
+ rescue Timeout::Error => e
104
+ handle_exception(e, level: :warn, handled: true, operation: 'llm.fleet.publish.confirm')
105
+ :confirm_timeout
106
+ end
107
+
108
+ def publish_channel(exchange_dest)
109
+ return exchange_dest.channel if exchange_dest.respond_to?(:channel)
110
+
111
+ channel
112
+ end
113
+
114
+ def exchange_name(exchange_dest)
115
+ return exchange_dest.name if exchange_dest.respond_to?(:name)
116
+
117
+ exchange_dest.to_s
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -80,12 +80,18 @@ module Legion
80
80
  content: content,
81
81
  model_id: model_id,
82
82
  tool_calls: tool_calls,
83
- tool_call_id: tool_call_id,
84
- thinking: thinking&.text,
85
- thinking_signature: thinking&.signature
83
+ tool_call_id: tool_call_id
86
84
  }.merge(tokens ? tokens.to_h : {}).compact
87
85
  end
88
86
 
87
+ def to_internal_h
88
+ to_h.merge(
89
+ thinking: thinking&.text,
90
+ thinking_signature: thinking&.signature,
91
+ raw: raw
92
+ ).compact
93
+ end
94
+
89
95
  def instance_variables
90
96
  super - [:@raw]
91
97
  end
@@ -39,17 +39,17 @@ module Legion
39
39
  messages.map do |message|
40
40
  {
41
41
  role: message.role.to_s,
42
- content: openai_content(message.content),
42
+ content: openai_content(message.content, role: message.role),
43
43
  tool_call_id: message.tool_call_id,
44
44
  tool_calls: format_openai_tool_calls(message.tool_calls)
45
45
  }.compact
46
46
  end
47
47
  end
48
48
 
49
- def openai_content(content)
49
+ def openai_content(content, role:)
50
50
  return content.format if content.is_a?(Legion::Extensions::Llm::Content::Raw)
51
- return content unless content.respond_to?(:attachments)
52
- return content.text.to_s if content.attachments.empty?
51
+ return sanitize_openai_text(content, role:) unless content.respond_to?(:attachments)
52
+ return sanitize_openai_text(content.text.to_s, role:) if content.attachments.empty?
53
53
 
54
54
  openai_content_parts(content)
55
55
  end
@@ -63,6 +63,12 @@ module Legion
63
63
  parts
64
64
  end
65
65
 
66
+ def sanitize_openai_text(text, role:)
67
+ return text unless role.to_sym == :assistant && text.is_a?(String)
68
+
69
+ Responses::ThinkingExtractor.extract(text).content
70
+ end
71
+
66
72
  def format_openai_tool_calls(tool_calls)
67
73
  return nil unless tool_calls&.any?
68
74
 
@@ -135,18 +141,29 @@ module Legion
135
141
  end
136
142
 
137
143
  def extract_thinking_from_completion(message)
138
- reasoning = message['reasoning_content'] || message['reasoning']
139
- content = message['content']
144
+ extraction = Responses::ThinkingExtractor.extract(
145
+ message['content'],
146
+ metadata: thinking_metadata(message)
147
+ )
140
148
 
141
- if reasoning
142
- [content, Thinking.build(text: reasoning)]
143
- elsif content.is_a?(String) && content.include?('<think>')
144
- think_text = content[%r{<think>(.*?)</think>}m, 1]
145
- clean = content.gsub(%r{<think>.*?</think>}m, '').strip
146
- [clean, Thinking.build(text: think_text)]
147
- else
148
- [content, nil]
149
- end
149
+ [
150
+ extraction.content,
151
+ Thinking.build(
152
+ text: extraction.thinking,
153
+ signature: extraction.signature
154
+ )
155
+ ]
156
+ end
157
+
158
+ def thinking_metadata(message)
159
+ {
160
+ reasoning_content: message['reasoning_content'],
161
+ reasoning: message['reasoning'],
162
+ thinking: message['thinking'],
163
+ thinking_text: message['thinking_text'],
164
+ thinking_signature: message['thinking_signature'],
165
+ reasoning_signature: message['reasoning_signature']
166
+ }.compact
150
167
  end
151
168
 
152
169
  def build_chunk(data)
@@ -173,39 +190,23 @@ module Legion
173
190
 
174
191
  if reasoning
175
192
  [content, Thinking.build(text: reasoning)]
176
- elsif content.is_a?(String) && content.include?('<think>')
177
- clean, think_text = split_think_tags(content)
178
- [clean, Thinking.build(text: think_text)]
179
193
  else
180
194
  [content, nil]
181
195
  end
182
196
  end
183
197
 
184
- def split_think_tags(text) # rubocop:disable Metrics/PerceivedComplexity
185
- if text.match?(%r{<think>.*</think>}m)
186
- thinking = text[%r{<think>(.*?)</think>}m, 1]
187
- clean = text.gsub(%r{<think>.*?</think>}m, '').strip
188
- [clean.empty? ? nil : clean, thinking]
189
- elsif text.start_with?('<think>')
190
- [nil, text.delete_prefix('<think>')]
191
- elsif text.include?('</think>')
192
- parts = text.split('</think>', 2)
193
- [parts[1]&.strip.then { |s| s&.empty? ? nil : s }, parts[0]]
194
- else
195
- [text, nil]
196
- end
197
- end
198
-
199
198
  def parse_tool_calls(tool_calls)
200
199
  return nil unless tool_calls&.any?
201
200
 
202
201
  tool_calls.to_h do |call|
203
202
  function = call.fetch('function', {})
204
- name = function.fetch('name')
203
+ name = function['name']
204
+ id = call['id'] || name || call['index']
205
+ key = name || id
205
206
  [
206
- name.to_sym,
207
+ key.to_s.to_sym,
207
208
  Legion::Extensions::Llm::ToolCall.new(
208
- id: call['id'] || name,
209
+ id: id&.to_s,
209
210
  name: name,
210
211
  arguments: parse_tool_arguments(function['arguments'])
211
212
  )