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.
- checksums.yaml +4 -4
- data/.rubocop.yml +13 -2
- data/B1b-conformance-kit.md +79 -0
- data/CHANGELOG.md +19 -0
- data/lex-llm.gemspec +2 -3
- data/lib/legion/extensions/llm/attachment.rb +1 -1
- data/lib/legion/extensions/llm/canonical/chunk.rb +184 -0
- data/lib/legion/extensions/llm/canonical/content_block.rb +126 -0
- data/lib/legion/extensions/llm/canonical/message.rb +125 -0
- data/lib/legion/extensions/llm/canonical/params.rb +61 -0
- data/lib/legion/extensions/llm/canonical/request.rb +117 -0
- data/lib/legion/extensions/llm/canonical/response.rb +124 -0
- data/lib/legion/extensions/llm/canonical/thinking.rb +81 -0
- data/lib/legion/extensions/llm/canonical/tool_call.rb +134 -0
- data/lib/legion/extensions/llm/canonical/tool_definition.rb +73 -0
- data/lib/legion/extensions/llm/canonical/usage.rb +61 -0
- data/lib/legion/extensions/llm/canonical.rb +49 -0
- data/lib/legion/extensions/llm/chat.rb +3 -5
- data/lib/legion/extensions/llm/connection.rb +5 -1
- data/lib/legion/extensions/llm/error.rb +3 -7
- data/lib/legion/extensions/llm/fleet/envelope_validation.rb +1 -3
- data/lib/legion/extensions/llm/fleet/provider_responder.rb +1 -3
- data/lib/legion/extensions/llm/fleet/token_validator.rb +1 -3
- data/lib/legion/extensions/llm/model/info.rb +4 -6
- data/lib/legion/extensions/llm/models.rb +3 -3
- data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +7 -3
- data/lib/legion/extensions/llm/routing/lane_key.rb +1 -3
- data/lib/legion/extensions/llm/stream_accumulator.rb +1 -1
- data/lib/legion/extensions/llm/streaming.rb +1 -3
- data/lib/legion/extensions/llm/tool.rb +1 -3
- data/lib/legion/extensions/llm/version.rb +1 -1
- data/lib/legion/extensions/llm.rb +118 -35
- data/spec/fixtures/ruby.mp3 +0 -0
- data/spec/fixtures/ruby.mp4 +0 -0
- data/spec/fixtures/ruby.png +0 -0
- data/spec/fixtures/ruby.txt +1 -0
- data/spec/fixtures/ruby.wav +0 -0
- data/spec/fixtures/ruby.xml +1 -0
- data/spec/fixtures/sample.pdf +0 -0
- data/spec/legion/extensions/llm/agent_spec.rb +179 -0
- data/spec/legion/extensions/llm/attachment_spec.rb +25 -0
- data/spec/legion/extensions/llm/auto_registration_spec.rb +38 -0
- data/spec/legion/extensions/llm/canonical/chunk_spec.rb +285 -0
- data/spec/legion/extensions/llm/canonical/content_block_spec.rb +179 -0
- data/spec/legion/extensions/llm/canonical/message_spec.rb +203 -0
- data/spec/legion/extensions/llm/canonical/params_spec.rb +159 -0
- data/spec/legion/extensions/llm/canonical/request_spec.rb +174 -0
- data/spec/legion/extensions/llm/canonical/response_spec.rb +234 -0
- data/spec/legion/extensions/llm/canonical/thinking_spec.rb +151 -0
- data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +191 -0
- data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +174 -0
- data/spec/legion/extensions/llm/canonical/usage_spec.rb +138 -0
- data/spec/legion/extensions/llm/configuration_spec.rb +38 -0
- data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +269 -0
- data/spec/legion/extensions/llm/conformance/conformance.rb +51 -0
- data/spec/legion/extensions/llm/conformance/echo_translator.rb +56 -0
- data/spec/legion/extensions/llm/conformance/echo_translator_spec.rb +13 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_empty_response.json +13 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_error_response.json +19 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +81 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_metering_audit_events.json +101 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_params_mapping_request.json +21 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_request.json +13 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_response.json +13 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_stop_reason_matrix.json +36 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_accumulated_response.json +20 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_error_chunks.json +26 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_text_chunks.json +33 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_thinking_chunks.json +42 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_tool_call_chunks.json +41 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_system_prompt_request.json +14 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +18 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_response.json +17 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_results_continuation_request.json +75 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_use_response.json +25 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_tools_request.json +34 -0
- data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +390 -0
- data/spec/legion/extensions/llm/connection_logging_spec.rb +53 -0
- data/spec/legion/extensions/llm/connection_retry_spec.rb +36 -0
- data/spec/legion/extensions/llm/context_spec.rb +127 -0
- data/spec/legion/extensions/llm/credential_sources_spec.rb +468 -0
- data/spec/legion/extensions/llm/error_middleware_spec.rb +102 -0
- data/spec/legion/extensions/llm/error_spec.rb +87 -0
- data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +120 -0
- data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +163 -0
- data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +128 -0
- data/spec/legion/extensions/llm/fleet_messages_spec.rb +402 -0
- data/spec/legion/extensions/llm/gemspec_spec.rb +25 -0
- data/spec/legion/extensions/llm/message_spec.rb +64 -0
- data/spec/legion/extensions/llm/model/info_spec.rb +222 -0
- data/spec/legion/extensions/llm/models_spec.rb +104 -0
- data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +203 -0
- data/spec/legion/extensions/llm/provider_contract_spec.rb +60 -0
- data/spec/legion/extensions/llm/provider_settings_spec.rb +76 -0
- data/spec/legion/extensions/llm/provider_spec.rb +592 -0
- data/spec/legion/extensions/llm/registry_event_builder_spec.rb +68 -0
- data/spec/legion/extensions/llm/registry_publisher_spec.rb +22 -0
- data/spec/legion/extensions/llm/responses/response_objects_spec.rb +75 -0
- data/spec/legion/extensions/llm/responses/thinking_extractor_spec.rb +75 -0
- data/spec/legion/extensions/llm/routing/model_offering_spec.rb +222 -0
- data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +50 -0
- data/spec/legion/extensions/llm/routing/registry_event_spec.rb +120 -0
- data/spec/legion/extensions/llm/stream_accumulator_spec.rb +103 -0
- data/spec/legion/extensions/llm/streaming_spec.rb +108 -0
- data/spec/legion/extensions/llm/tool_spec.rb +94 -0
- data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +60 -0
- data/spec/legion/extensions/llm/utils_spec.rb +113 -0
- data/spec/legion/extensions/llm_base_contract_spec.rb +110 -0
- data/spec/legion/extensions/llm_extension_spec.rb +78 -0
- data/spec/legion/extensions/llm_root_spec.rb +51 -0
- data/spec/spec_helper.rb +24 -0
- data/spec/support/fake_llm_provider.rb +148 -0
- data/spec/support/llm_configuration.rb +21 -0
- data/spec/support/rspec_configuration.rb +19 -0
- data/spec/support/simplecov_configuration.rb +20 -0
- metadata +96 -15
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9ab0a51687719efbd7f048be70ff4ba57b6d81fafddfcaefb2f0d78f5f3721ae
|
|
4
|
+
data.tar.gz: 49e57ce1c99330d956cc92b6c6a75a283f078f5e0b8fb036ae7aad9579caccd0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8d8fcea6f732dd4c5dfcd713024796a640941d18c8ef90f3c5b5ca0b3fcbc0094942f2cadabbdd78669ecabce8cd76f893488a92f15ec70c21ebec865fb0a531
|
|
7
|
+
data.tar.gz: 4a062635e491cb84b60117b1c28520a8fc5bb4d0775609ed1104639e549114c2a373e68461833008a234e33d7ac14a5fe1292cc2f37fe37b0f00818387cc4851
|
data/.rubocop.yml
CHANGED
|
@@ -13,12 +13,18 @@ AllCops:
|
|
|
13
13
|
- lib/generators/**/templates/**/*
|
|
14
14
|
SuggestExtensions: false
|
|
15
15
|
|
|
16
|
+
Layout/LineLength:
|
|
17
|
+
Max: 190
|
|
16
18
|
Metrics/ClassLength:
|
|
17
19
|
Enabled: false
|
|
18
20
|
Metrics/AbcSize:
|
|
19
|
-
Enabled:
|
|
21
|
+
Enabled: true
|
|
22
|
+
Max: 50
|
|
23
|
+
Metrics/PerceivedComplexity:
|
|
24
|
+
Max: 50
|
|
20
25
|
Metrics/CyclomaticComplexity:
|
|
21
|
-
Enabled:
|
|
26
|
+
Enabled: true
|
|
27
|
+
Max: 50
|
|
22
28
|
Metrics/MethodLength:
|
|
23
29
|
Enabled: false
|
|
24
30
|
Metrics/ModuleLength:
|
|
@@ -40,3 +46,8 @@ RSpec/MultipleExpectations:
|
|
|
40
46
|
Enabled: false
|
|
41
47
|
RSpec/LeakyLocalVariable:
|
|
42
48
|
Enabled: false
|
|
49
|
+
# Conformance kit specs live under spec/legion/extensions/llm/conformance/
|
|
50
|
+
# but the test classes use Canonical::Conformance:: namespace — path can't match.
|
|
51
|
+
RSpec/SpecFilePathFormat:
|
|
52
|
+
Exclude:
|
|
53
|
+
- 'spec/legion/extensions/llm/conformance/**/*'
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# B1b — Conformance Kit (lex-llm spec support)
|
|
2
|
+
|
|
3
|
+
> **Status:** Complete (self-test green: 54 examples, 0 failures)
|
|
4
|
+
> **Date:** 2026-06-10
|
|
5
|
+
> **Repo:** lex-llm (conformance kit only)
|
|
6
|
+
> **Branch:** feat/canonical-types
|
|
7
|
+
> **Design doc:** 2026-06-09-nxn-canonical-routing-design.md Amendment B
|
|
8
|
+
> **Implementation plan:** Phase 2 (conformance kit in lex-llm)
|
|
9
|
+
> **Dependency:** B1a canonical types (coordinator commit e1cbf820)
|
|
10
|
+
> **Self-test green:** `bundle exec rspec spec/legion/extensions/llm/conformance` → 54 examples, 0 failures
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## What was delivered
|
|
15
|
+
|
|
16
|
+
### Shared example groups
|
|
17
|
+
|
|
18
|
+
**provider_translator_examples.rb** (~390 lines)
|
|
19
|
+
`it_behaves_like 'a canonical provider translator'` — 54 scenarios across render_request,
|
|
20
|
+
parse_response, parse_chunk, stop_reason, round-trip.
|
|
21
|
+
|
|
22
|
+
**client_translator_examples.rb** (~270 lines)
|
|
23
|
+
Mirror group for client translators: `it_behaves_like 'a canonical client translator'` —
|
|
24
|
+
parse_request/format_request round-trip, format_response, format_chunk, format_error.
|
|
25
|
+
|
|
26
|
+
### Fixture corpus (19 JSON files)
|
|
27
|
+
|
|
28
|
+
All under `spec/legion/extensions/llm/conformance/fixtures/`:
|
|
29
|
+
- simple_text request/response, system_prompt, params_mapping (all 10 G18 fields),
|
|
30
|
+
tools_request, tool_use_response, tool_results_continuation_request (enhanced: mixed client + registry tool calls per G4)
|
|
31
|
+
- canonical_thinking_request.json
|
|
32
|
+
- canonical_thinking_response.json (thinking content + signature, thinking_tokens)
|
|
33
|
+
- canonical_empty_response.json
|
|
34
|
+
- canonical_error_response.json
|
|
35
|
+
- canonical_stop_reason_matrix.json (6 canonical enums + 5 provider mappings)
|
|
36
|
+
- canonical_streaming_text_chunks.json
|
|
37
|
+
- canonical_streaming_thinking_chunks.json (thinking + text + signature + done)
|
|
38
|
+
- canonical_streaming_tool_call_chunks.json (multi-chunk tool-call identity per A7)
|
|
39
|
+
- canonical_streaming_error_chunks.json (**new**: mid-stream error per G5/G6)
|
|
40
|
+
- canonical_streaming_accumulated_response.json (**new**: expected assembled response from tool-call chunks)
|
|
41
|
+
- canonical_fleet_round_trip.json (per R6)
|
|
42
|
+
- canonical_metering_audit_events.json (per G15e: schemas + example events)
|
|
43
|
+
|
|
44
|
+
### Self-test echo translator
|
|
45
|
+
|
|
46
|
+
Echo translator + spec that passes both provider and client translator groups, proving the shared examples work correctly.
|
|
47
|
+
|
|
48
|
+
### Infrastructure fixes
|
|
49
|
+
|
|
50
|
+
1. **lex-llm.gemspec** — spec.files now includes spec/ (was excluded); spec.require_paths adds 'spec' — enables cross-gem conformance kit loading per the amended Phase 2 spec.
|
|
51
|
+
2. .rubocop.yml — excluded conformance directory from RSpec/SpecFilePathFormat.
|
|
52
|
+
3. **provider_translator_examples.rb** — parse_response now tests translator.parse_response(wire) instead of canonical::Response.from_hash(fixture).
|
|
53
|
+
|
|
54
|
+
### Issues found & fixed during this session
|
|
55
|
+
|
|
56
|
+
1. **parse_response tests didn't exercise the translator** — Fixed by using `fixture_symbolized` + calling `translator.parse_response(wire).
|
|
57
|
+
2. **Malformed rubocop directives in canonical lib files** — 5 files had `# rubocop:disable Metrics/ParameterLists, Metrics/PerceivedComplexity -- factory` where the trailing `, --` triggered `Lint/CopDirectiveSyntax`. Fixed & committed.
|
|
58
|
+
3. **RSpec/SpecFilePathFormat rubocop violation** — `echo_translator_spec.rb` in `spec/legion/extensions/llm/conformance/` triggered the cop. Excluded conformance directory.
|
|
59
|
+
4. **Symbolized vs string-keyed fixtures** — Fixed by using `fixture_symbolized` for self-test.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Test results
|
|
64
|
+
|
|
65
|
+
Conformance kit: 54 examples, 0 failures
|
|
66
|
+
Full test suite: 630 examples, 0 failures
|
|
67
|
+
Line coverage: 79.01% (3569 / 4517)
|
|
68
|
+
Branch coverage: 52.76% (908 / 1721)
|
|
69
|
+
RuboCop: clean (136 files, 0 offenses)
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Next steps
|
|
74
|
+
|
|
75
|
+
1. **Phase 3** — Provider gems adopt the kit (anthropic first, then openai, vllm, ollama, bedrock). Each runs `it_behaves_like 'a canonical provider translator'`.
|
|
76
|
+
2. **Phase 5** — Client translators in legion-llm adopt the client shared examples.
|
|
77
|
+
3. **Bug fix** — Resolve ToolCall.from_hash JSON::ParserError constant resolution in lib/canonical (feat/canonical-types branch). The coordinator commit e1cbf820 fixed this with `Legion::JSON`.
|
|
78
|
+
4. **Provider-specific fixtures** — Real provider gems will supply their own wire-format fixtures; the canonical fixtures serve as the round-trip anchor. A future step would add anthropic/openai wire-format reference fixtures (sanitized from `legionio-e2e/results/`) for direct cross-verification.
|
|
79
|
+
5. **Streaming tool-call incremental fragments** — The `canonical_streaming_tool_call_chunks.json` fixture carries complete (canonical-form) tool call args per chunk due to the ParserError bug. The fix in #3 allows incremental fragments if desired.
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0 - 2026-06-10
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Canonical types module** — `Legion::Extensions::Llm::Canonical` provides immutable `Data.define` value objects (Thinking, Usage, Params, ContentBlock, ToolDefinition, ToolCall, Message, Request, Response, Chunk) forming the single N×N client↔provider routing contract. Includes `from_hash`/`to_h` for serialization, `CONTRACT_VERSION` for provider gem compatibility checks, and explicit factory validation per Amendment A.
|
|
7
|
+
- **Conformance kit** — Shared RSpec example groups shipped under `spec/legion/extensions/llm/conformance/` (provider_translator_examples, client_translator_examples) with JSON fixtures for canonical↔provider translation contract testing. Packaged via gemspec `spec.files`; `gemspec.require_paths` remains `['lib']` only — conformance specs are consumed by provider gems at test time via `Gem.loaded_specs['lex-llm'].full_gem_path`.
|
|
8
|
+
- **Conformance kit coordinator** — Fixtures read with explicit UTF-8 encoding so locale-less CI shells do not fail on JSON.parse.
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- **Zeitwerk autoloading removed** — Replaced lazy Zeitwerk::Loader with deterministic explicit `require_relative` for every file in `lib/`. Contract constants now exist at `require` time so provider gems can subclass against them during phased extension loading (core → lex-identity → lex-llm → lex-llm-*). Removed undeclared `zeitwerk ~> 2` runtime dependency from gemspec. Load order: canonical types and base classes first, then components referencing them. Transport exchange/message modules remain as Ruby `autoload` to avoid forcing `legion-transport` at boot time.
|
|
12
|
+
|
|
13
|
+
## 0.4.19 - 2026-06-10
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- **Connection logging bodies** — `setup_logging` now enables request body logging when the logger is at DEBUG level OR when `fleet.request.logger.request_payload` is explicitly true. Previously relied solely on log-level check; the new `request_payload` setting provides explicit control for fleet worker scenarios.
|
|
17
|
+
- **OpenAI-compatible tool formatting** — `format_openai_tools` now handles both `ToolDefinition` objects and plain Hashes (from `native_dispatch`) by checking `respond_to?` for method access and falling back to symbol/string key access. Prevents `NoMethodError` when tools arrive as hash-backed definitions.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- **Fleet request_payload setting** — Added `fleet.request.logger.request_payload` (default: `false`) to `default_settings` for explicit control over request body logging in Faraday middleware.
|
|
21
|
+
|
|
3
22
|
## 0.4.18 - 2026-06-05
|
|
4
23
|
|
|
5
24
|
### Fixed
|
data/lex-llm.gemspec
CHANGED
|
@@ -24,8 +24,8 @@ Gem::Specification.new do |spec|
|
|
|
24
24
|
|
|
25
25
|
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
26
26
|
|
|
27
|
-
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(
|
|
28
|
-
spec.require_paths = [
|
|
27
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|features|tmp|coverage)/}) }
|
|
28
|
+
spec.require_paths = %w[lib]
|
|
29
29
|
|
|
30
30
|
# Runtime dependencies
|
|
31
31
|
spec.add_dependency 'base64'
|
|
@@ -44,5 +44,4 @@ Gem::Specification.new do |spec|
|
|
|
44
44
|
spec.add_dependency 'legion-transport', '>= 1.4.14'
|
|
45
45
|
spec.add_dependency 'marcel', '~> 1'
|
|
46
46
|
spec.add_dependency 'ruby_llm-schema', '~> 0'
|
|
47
|
-
spec.add_dependency 'zeitwerk', '~> 2'
|
|
48
47
|
end
|
|
@@ -0,0 +1,184 @@
|
|
|
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
|
+
# Canonical streaming chunk with full lifecycle support.
|
|
9
|
+
# Per R4: block_index/item_id/signature lifecycle, multi-tool-call deltas.
|
|
10
|
+
# Per G20d: strict on produce, ignore-unknown on consume.
|
|
11
|
+
Chunk = ::Data.define(
|
|
12
|
+
:request_id, :conversation_id, :exchange_id,
|
|
13
|
+
:index, :type, :block_index,
|
|
14
|
+
:item_id, :delta, :tool_call, :signature,
|
|
15
|
+
:usage, :stop_reason, :metadata, :timestamp
|
|
16
|
+
) do
|
|
17
|
+
# Build a text delta chunk.
|
|
18
|
+
def self.text_delta(delta:, request_id:, conversation_id: nil, exchange_id: nil,
|
|
19
|
+
index: 0, block_index: nil, item_id: nil)
|
|
20
|
+
new(
|
|
21
|
+
type: :text_delta, delta: delta, index: index,
|
|
22
|
+
request_id: request_id, conversation_id: conversation_id,
|
|
23
|
+
exchange_id: exchange_id, block_index: block_index,
|
|
24
|
+
item_id: item_id, tool_call: nil, signature: nil,
|
|
25
|
+
usage: nil, stop_reason: nil, metadata: {},
|
|
26
|
+
timestamp: ::Time.now
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Build a thinking delta chunk.
|
|
31
|
+
def self.thinking_delta(delta:, request_id:, conversation_id: nil, exchange_id: nil,
|
|
32
|
+
index: 0, block_index: nil, item_id: nil, signature: nil)
|
|
33
|
+
new(
|
|
34
|
+
type: :thinking_delta, delta: delta, index: index,
|
|
35
|
+
request_id: request_id, conversation_id: conversation_id,
|
|
36
|
+
exchange_id: exchange_id, block_index: block_index,
|
|
37
|
+
item_id: item_id, tool_call: nil, signature: signature,
|
|
38
|
+
usage: nil, stop_reason: nil, metadata: {},
|
|
39
|
+
timestamp: ::Time.now
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Build a tool_call_delta chunk (supports multiple in-flight tool calls via tool_call.id).
|
|
44
|
+
def self.tool_call_delta(tool_call:, request_id:, conversation_id: nil, exchange_id: nil,
|
|
45
|
+
index: 0, block_index: nil, item_id: nil)
|
|
46
|
+
new(
|
|
47
|
+
type: :tool_call_delta, index: index,
|
|
48
|
+
request_id: request_id, conversation_id: conversation_id,
|
|
49
|
+
exchange_id: exchange_id, block_index: block_index,
|
|
50
|
+
item_id: item_id, delta: nil, tool_call: tool_call, signature: nil,
|
|
51
|
+
usage: nil, stop_reason: nil, metadata: {},
|
|
52
|
+
timestamp: ::Time.now
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Build a usage chunk.
|
|
57
|
+
def self.usage_chunk(usage:, request_id:, conversation_id: nil, exchange_id: nil)
|
|
58
|
+
new(
|
|
59
|
+
type: :usage, request_id: request_id,
|
|
60
|
+
conversation_id: conversation_id, exchange_id: exchange_id,
|
|
61
|
+
index: nil, block_index: nil, item_id: nil,
|
|
62
|
+
delta: nil, tool_call: nil, signature: nil,
|
|
63
|
+
usage: usage, stop_reason: nil, metadata: {},
|
|
64
|
+
timestamp: ::Time.now
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Build a done chunk.
|
|
69
|
+
def self.done(request_id:, usage: nil, stop_reason: nil, conversation_id: nil, exchange_id: nil)
|
|
70
|
+
new(
|
|
71
|
+
type: :done, request_id: request_id,
|
|
72
|
+
conversation_id: conversation_id, exchange_id: exchange_id,
|
|
73
|
+
index: nil, block_index: nil, item_id: nil,
|
|
74
|
+
delta: nil, tool_call: nil, signature: nil,
|
|
75
|
+
usage: usage, stop_reason: stop_reason, metadata: {},
|
|
76
|
+
timestamp: ::Time.now
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Build an error chunk.
|
|
81
|
+
def self.error_chunk(error:, request_id:, conversation_id: nil, exchange_id: nil, metadata: nil)
|
|
82
|
+
new(
|
|
83
|
+
type: :error, request_id: request_id,
|
|
84
|
+
conversation_id: conversation_id, exchange_id: exchange_id,
|
|
85
|
+
index: nil, block_index: nil, item_id: nil,
|
|
86
|
+
delta: nil, tool_call: nil, signature: nil,
|
|
87
|
+
usage: nil, stop_reason: :error,
|
|
88
|
+
metadata: (metadata || {}).merge(error: error),
|
|
89
|
+
timestamp: ::Time.now
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Build from a Hash (raw provider response or deserialized wire payload).
|
|
94
|
+
# Per G20d: ignore-unknown on consume — unknown chunk types are passed through.
|
|
95
|
+
def self.from_hash(source)
|
|
96
|
+
return nil if source.nil?
|
|
97
|
+
|
|
98
|
+
h = source.transform_keys(&:to_sym)
|
|
99
|
+
|
|
100
|
+
# Normalize type
|
|
101
|
+
type_raw = h.delete(:type)
|
|
102
|
+
type_sym = type_raw&.to_sym if type_raw
|
|
103
|
+
|
|
104
|
+
# Normalize nested objects
|
|
105
|
+
tool_call_raw = h.delete(:tool_call)
|
|
106
|
+
h[:tool_call] = if tool_call_raw.is_a?(ToolCall)
|
|
107
|
+
tool_call_raw
|
|
108
|
+
elsif tool_call_raw.is_a?(Hash)
|
|
109
|
+
ToolCall.from_hash(tool_call_raw)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
usage_raw = h.delete(:usage)
|
|
113
|
+
h[:usage] = if usage_raw.is_a?(Usage)
|
|
114
|
+
usage_raw
|
|
115
|
+
elsif usage_raw.is_a?(Hash)
|
|
116
|
+
Usage.from_hash(usage_raw)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Normalize stop_reason
|
|
120
|
+
stop_reason_raw = h.delete(:stop_reason) || h.delete(:finish_reason)
|
|
121
|
+
h[:stop_reason] = stop_reason_raw&.to_sym if stop_reason_raw
|
|
122
|
+
|
|
123
|
+
# Ensure metadata is a Hash
|
|
124
|
+
h[:metadata] = h[:metadata] || {}
|
|
125
|
+
|
|
126
|
+
# Provide defaults for missing fields
|
|
127
|
+
new(
|
|
128
|
+
request_id: h[:request_id],
|
|
129
|
+
conversation_id: h[:conversation_id],
|
|
130
|
+
exchange_id: h[:exchange_id],
|
|
131
|
+
index: h[:index],
|
|
132
|
+
type: type_sym,
|
|
133
|
+
block_index: h[:block_index],
|
|
134
|
+
item_id: h[:item_id],
|
|
135
|
+
delta: h[:delta],
|
|
136
|
+
tool_call: h[:tool_call],
|
|
137
|
+
signature: h[:signature],
|
|
138
|
+
usage: h[:usage],
|
|
139
|
+
stop_reason: h[:stop_reason],
|
|
140
|
+
metadata: h[:metadata],
|
|
141
|
+
timestamp: h[:timestamp] || ::Time.now
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Serialize to a Hash for AMQP/fleet/wire transport.
|
|
146
|
+
def to_h
|
|
147
|
+
{
|
|
148
|
+
request_id: request_id,
|
|
149
|
+
conversation_id: conversation_id,
|
|
150
|
+
exchange_id: exchange_id,
|
|
151
|
+
index: index,
|
|
152
|
+
type: type,
|
|
153
|
+
block_index: block_index,
|
|
154
|
+
item_id: item_id,
|
|
155
|
+
delta: delta,
|
|
156
|
+
tool_call: tool_call&.to_h,
|
|
157
|
+
signature: signature,
|
|
158
|
+
usage: usage&.to_h,
|
|
159
|
+
stop_reason: stop_reason,
|
|
160
|
+
metadata: metadata,
|
|
161
|
+
timestamp: timestamp
|
|
162
|
+
}.compact
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Type predicate helpers.
|
|
166
|
+
def text_delta? = type == :text_delta
|
|
167
|
+
def thinking_delta? = type == :thinking_delta
|
|
168
|
+
def tool_call_delta? = type == :tool_call_delta
|
|
169
|
+
def usage? = type == :usage
|
|
170
|
+
def done? = type == :done
|
|
171
|
+
def error? = type == :error
|
|
172
|
+
|
|
173
|
+
# Whether this chunk carries content (text or thinking).
|
|
174
|
+
def content?
|
|
175
|
+
%i[text_delta thinking_delta].include?(type)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
Chunk::CHUNK_TYPES = %i[text_delta thinking_delta tool_call_delta usage done error].freeze
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
# rubocop:enable Metrics/ParameterLists
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Llm
|
|
6
|
+
module Canonical
|
|
7
|
+
# Typed content block with media_type support per G20a.
|
|
8
|
+
# Ports field vocabulary from Legion::LLM::Types::ContentBlock.
|
|
9
|
+
ContentBlock = ::Data.define(
|
|
10
|
+
:type, :text, :data, :source_type, :media_type,
|
|
11
|
+
:detail, :name, :file_id,
|
|
12
|
+
:id, :input, :tool_use_id, :is_error,
|
|
13
|
+
:source, :start_index, :end_index,
|
|
14
|
+
:code, :message, :cache_control
|
|
15
|
+
) do
|
|
16
|
+
# Build a text content block.
|
|
17
|
+
def self.text(content, cache_control: nil)
|
|
18
|
+
new(
|
|
19
|
+
type: :text, text: content, data: nil, source_type: nil, media_type: nil,
|
|
20
|
+
detail: nil, name: nil, file_id: nil, id: nil, input: nil,
|
|
21
|
+
tool_use_id: nil, is_error: nil, source: nil, start_index: nil,
|
|
22
|
+
end_index: nil, code: nil, message: nil, cache_control: cache_control
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Build a thinking content block.
|
|
27
|
+
def self.thinking(content)
|
|
28
|
+
new(
|
|
29
|
+
type: :thinking, text: content, data: nil, source_type: nil, media_type: nil,
|
|
30
|
+
detail: nil, name: nil, file_id: nil, id: nil, input: nil,
|
|
31
|
+
tool_use_id: nil, is_error: nil, source: nil, start_index: nil,
|
|
32
|
+
end_index: nil, code: nil, message: nil, cache_control: nil
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Build a tool_use content block.
|
|
37
|
+
def self.tool_use(id:, name:, input:)
|
|
38
|
+
new(
|
|
39
|
+
type: :tool_use, text: nil, data: nil, source_type: nil, media_type: nil,
|
|
40
|
+
detail: nil, name: name, file_id: nil, id: id, input: input,
|
|
41
|
+
tool_use_id: nil, is_error: nil, source: nil, start_index: nil,
|
|
42
|
+
end_index: nil, code: nil, message: nil, cache_control: nil
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Build a tool_result content block.
|
|
47
|
+
def self.tool_result(tool_use_id:, content:, is_error: false)
|
|
48
|
+
new(
|
|
49
|
+
type: :tool_result, text: content, data: nil, source_type: nil, media_type: nil,
|
|
50
|
+
detail: nil, name: nil, file_id: nil, id: nil, input: nil,
|
|
51
|
+
tool_use_id: tool_use_id, is_error: is_error, source: nil, start_index: nil,
|
|
52
|
+
end_index: nil, code: nil, message: nil, cache_control: nil
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Build an image content block with media_type (G20a).
|
|
57
|
+
def self.image(data:, media_type:, source_type: :base64, detail: nil)
|
|
58
|
+
new(
|
|
59
|
+
type: :image, text: nil, data: data, source_type: source_type, media_type: media_type,
|
|
60
|
+
detail: detail, name: nil, file_id: nil, id: nil, input: nil,
|
|
61
|
+
tool_use_id: nil, is_error: nil, source: nil, start_index: nil,
|
|
62
|
+
end_index: nil, code: nil, message: nil, cache_control: nil
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Build from a Hash (raw provider response or deserialized wire payload).
|
|
67
|
+
def self.from_hash(source)
|
|
68
|
+
return nil if source.nil?
|
|
69
|
+
|
|
70
|
+
h = source.transform_keys(&:to_sym)
|
|
71
|
+
type_raw = h.delete(:type)
|
|
72
|
+
h[:type] = type_raw&.to_sym if type_raw
|
|
73
|
+
|
|
74
|
+
new(
|
|
75
|
+
type: h[:type],
|
|
76
|
+
text: h[:text],
|
|
77
|
+
data: h[:data],
|
|
78
|
+
source_type: h[:source_type],
|
|
79
|
+
media_type: h[:media_type],
|
|
80
|
+
detail: h[:detail],
|
|
81
|
+
name: h[:name],
|
|
82
|
+
file_id: h[:file_id],
|
|
83
|
+
id: h[:id],
|
|
84
|
+
input: h[:input],
|
|
85
|
+
tool_use_id: h[:tool_use_id],
|
|
86
|
+
is_error: h[:is_error],
|
|
87
|
+
source: h[:source],
|
|
88
|
+
start_index: h[:start_index],
|
|
89
|
+
end_index: h[:end_index],
|
|
90
|
+
code: h[:code],
|
|
91
|
+
message: h[:message],
|
|
92
|
+
cache_control: h[:cache_control]
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Serialize to a Hash for AMQP/fleet/wire transport.
|
|
97
|
+
def to_h
|
|
98
|
+
super.compact
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Whether this block carries textual content.
|
|
102
|
+
def text?
|
|
103
|
+
type == :text
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Whether this block carries thinking/reasoning content.
|
|
107
|
+
def thinking?
|
|
108
|
+
type == :thinking
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Whether this block represents a tool use request.
|
|
112
|
+
def tool_use?
|
|
113
|
+
type == :tool_use
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Whether this block represents a tool result.
|
|
117
|
+
def tool_result?
|
|
118
|
+
type == :tool_result
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
ContentBlock::CONTENT_BLOCK_TYPES = %i[text thinking tool_use tool_result image audio video].freeze
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
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 message in a conversation.
|
|
12
|
+
# Ports field vocabulary from Legion::LLM::Types::Message and lex-llm Message.
|
|
13
|
+
Message = ::Data.define(
|
|
14
|
+
:id, :parent_id, :role, :content, :tool_calls, :tool_call_id,
|
|
15
|
+
:name, :status, :version, :timestamp, :seq,
|
|
16
|
+
:provider, :model, :input_tokens, :output_tokens,
|
|
17
|
+
:conversation_id, :task_id
|
|
18
|
+
) do
|
|
19
|
+
ROLES = %i[system user assistant tool].freeze
|
|
20
|
+
|
|
21
|
+
# Build from keyword args (primary constructor).
|
|
22
|
+
def self.build(
|
|
23
|
+
id: nil, parent_id: nil, role: :user, content: nil, tool_calls: nil,
|
|
24
|
+
tool_call_id: nil, name: nil, status: :created, version: 1,
|
|
25
|
+
timestamp: nil, seq: nil, provider: nil, model: nil,
|
|
26
|
+
input_tokens: nil, output_tokens: nil, conversation_id: nil, task_id: nil
|
|
27
|
+
)
|
|
28
|
+
role_sym = role.is_a?(String) ? role.to_sym : role
|
|
29
|
+
unless ROLES.include?(role_sym)
|
|
30
|
+
raise ArgumentError,
|
|
31
|
+
"Invalid role: #{role_sym}. Must be one of: #{ROLES.join(', ')}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
new(
|
|
35
|
+
id: id || "msg_#{SecureRandom.hex(12)}",
|
|
36
|
+
parent_id: parent_id,
|
|
37
|
+
role: role_sym,
|
|
38
|
+
content: content,
|
|
39
|
+
tool_calls: tool_calls,
|
|
40
|
+
tool_call_id: tool_call_id,
|
|
41
|
+
name: name,
|
|
42
|
+
status: status,
|
|
43
|
+
version: version,
|
|
44
|
+
timestamp: timestamp || ::Time.now,
|
|
45
|
+
seq: seq,
|
|
46
|
+
provider: provider,
|
|
47
|
+
model: model,
|
|
48
|
+
input_tokens: input_tokens,
|
|
49
|
+
output_tokens: output_tokens,
|
|
50
|
+
conversation_id: conversation_id,
|
|
51
|
+
task_id: task_id
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Build from a Hash (raw provider response or deserialized wire payload).
|
|
56
|
+
def self.from_hash(hash)
|
|
57
|
+
return nil if hash.nil?
|
|
58
|
+
|
|
59
|
+
h = hash.transform_keys(&:to_sym)
|
|
60
|
+
|
|
61
|
+
# Normalize role to symbol
|
|
62
|
+
role_raw = h[:role]
|
|
63
|
+
h[:role] = role_raw&.to_sym if role_raw
|
|
64
|
+
|
|
65
|
+
# Parse content blocks if they're an array of hashes
|
|
66
|
+
content = h[:content]
|
|
67
|
+
if content.is_a?(Array)
|
|
68
|
+
h[:content] = content.map do |block|
|
|
69
|
+
block.is_a?(ContentBlock) ? block : ContentBlock.from_hash(block)
|
|
70
|
+
end
|
|
71
|
+
elsif content.is_a?(Hash)
|
|
72
|
+
h[:content] = ContentBlock.from_hash(content)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Parse tool calls if they're an array of hashes
|
|
76
|
+
tool_calls = h[:tool_calls]
|
|
77
|
+
if tool_calls.is_a?(Array)
|
|
78
|
+
h[:tool_calls] = tool_calls.map do |tc|
|
|
79
|
+
tc.is_a?(ToolCall) ? tc : ToolCall.from_hash(tc)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
build(**h)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Wrap input: pass through if already a Message, parse if Hash.
|
|
87
|
+
def self.wrap(input)
|
|
88
|
+
return input if input.is_a?(Message)
|
|
89
|
+
return from_hash(input) if input.is_a?(Hash)
|
|
90
|
+
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Extract plain text from content (String or ContentBlock array).
|
|
95
|
+
def text
|
|
96
|
+
case content
|
|
97
|
+
when String then content
|
|
98
|
+
when Array
|
|
99
|
+
content.filter_map do |block|
|
|
100
|
+
block.is_a?(ContentBlock) && block.text? ? block.text : nil
|
|
101
|
+
end.join
|
|
102
|
+
when ContentBlock then content.text if content.text?
|
|
103
|
+
else
|
|
104
|
+
content.to_s
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Serialize to a Hash for AMQP/fleet/wire transport.
|
|
109
|
+
def to_h
|
|
110
|
+
super.compact
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Minimal provider-facing hash (role + text content).
|
|
114
|
+
def to_provider_hash
|
|
115
|
+
{ role: role.to_s, content: text }.compact
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
Message::ROLES = %i[system user assistant tool].freeze
|
|
120
|
+
# rubocop:enable Lint/ConstantDefinitionInBlock
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
# rubocop:enable Metrics/ParameterLists
|
|
@@ -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
|