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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +43 -0
- data/README.md +18 -2
- data/lex-llm.gemspec +1 -0
- data/lib/legion/extensions/llm/auto_registration.rb +7 -36
- data/lib/legion/extensions/llm/embedding.rb +1 -1
- data/lib/legion/extensions/llm/error.rb +14 -0
- data/lib/legion/extensions/llm/errors/unsupported_capability.rb +21 -0
- data/lib/legion/extensions/llm/fleet/default_exchange_reply.rb +81 -0
- data/lib/legion/extensions/llm/fleet/envelope_validation.rb +39 -0
- data/lib/legion/extensions/llm/fleet/protocol.rb +16 -0
- data/lib/legion/extensions/llm/fleet/publish_safety.rb +123 -0
- data/lib/legion/extensions/llm/message.rb +9 -3
- data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +37 -36
- data/lib/legion/extensions/llm/provider.rb +198 -4
- data/lib/legion/extensions/llm/provider_contract.rb +21 -0
- data/lib/legion/extensions/llm/provider_settings.rb +18 -1
- data/lib/legion/extensions/llm/responses/chat_response.rb +43 -0
- data/lib/legion/extensions/llm/responses/embedding_response.rb +38 -0
- data/lib/legion/extensions/llm/responses/stream_chunk.rb +43 -0
- data/lib/legion/extensions/llm/responses/thinking_extractor.rb +155 -0
- data/lib/legion/extensions/llm/stream_accumulator.rb +12 -1
- data/lib/legion/extensions/llm/transport/exchanges/fleet.rb +24 -0
- data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +64 -0
- data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +155 -0
- data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +63 -0
- data/lib/legion/extensions/llm/version.rb +1 -1
- data/lib/legion/extensions/llm.rb +31 -11
- metadata +29 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6f3fc1bac35781a8134a6d24d7467a790cdd506244cfd4f0e66955a4fa82ceb9
|
|
4
|
+
data.tar.gz: 0c1cdfe9dee8e21c5b9bba0a01b12f5ef41e30b46c73ff8d22ccc35d621818a9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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
|
-
#
|
|
20
|
-
#
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
@@ -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
|
-
|
|
139
|
-
|
|
144
|
+
extraction = Responses::ThinkingExtractor.extract(
|
|
145
|
+
message['content'],
|
|
146
|
+
metadata: thinking_metadata(message)
|
|
147
|
+
)
|
|
140
148
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
203
|
+
name = function['name']
|
|
204
|
+
id = call['id'] || name || call['index']
|
|
205
|
+
key = name || id
|
|
205
206
|
[
|
|
206
|
-
|
|
207
|
+
key.to_s.to_sym,
|
|
207
208
|
Legion::Extensions::Llm::ToolCall.new(
|
|
208
|
-
id:
|
|
209
|
+
id: id&.to_s,
|
|
209
210
|
name: name,
|
|
210
211
|
arguments: parse_tool_arguments(function['arguments'])
|
|
211
212
|
)
|