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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 251e67e07340b260806c156849759a36717aa0edded8d487e4781bc18dd6a796
4
- data.tar.gz: 91cc4357b30623f8d05c26781838daaca04cc610bd0e9437f1dd8e3c7333d965
3
+ metadata.gz: 9ab0a51687719efbd7f048be70ff4ba57b6d81fafddfcaefb2f0d78f5f3721ae
4
+ data.tar.gz: 49e57ce1c99330d956cc92b6c6a75a283f078f5e0b8fb036ae7aad9579caccd0
5
5
  SHA512:
6
- metadata.gz: a221259bedc60837bab73ef1e497799d2b7712c3c0e9a74f782ba69e361878fc56d5bb3dc63fc0ffffd731d89ea10233a5e015b86033122550ff571cef667030
7
- data.tar.gz: '0709a98c1239c7e053b8d333d19150e4153c4c1ace084520d92221ceb532cb3fdc01cece4734eda9f0ef37f351f36c4a8aa50d78a500b74af7558ad25ac82c86'
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: false
21
+ Enabled: true
22
+ Max: 50
23
+ Metrics/PerceivedComplexity:
24
+ Max: 50
20
25
  Metrics/CyclomaticComplexity:
21
- Enabled: false
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{^(spec|test|features|tmp|coverage)/}) }
28
- spec.require_paths = ['lib']
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
@@ -196,7 +196,7 @@ module Legion
196
196
  end
197
197
  end
198
198
 
199
- def extract_filename_from_active_storage # rubocop:disable Metrics/PerceivedComplexity
199
+ def extract_filename_from_active_storage
200
200
  return 'attachment' unless defined?(ActiveStorage)
201
201
 
202
202
  case @source
@@ -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