lex-llm 0.4.18 → 0.5.1

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 (125) 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 +27 -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 +138 -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 +98 -0
  16. data/lib/legion/extensions/llm/canonical/tool_schema.rb +46 -0
  17. data/lib/legion/extensions/llm/canonical/usage.rb +74 -0
  18. data/lib/legion/extensions/llm/canonical.rb +50 -0
  19. data/lib/legion/extensions/llm/chat.rb +3 -5
  20. data/lib/legion/extensions/llm/connection.rb +5 -1
  21. data/lib/legion/extensions/llm/error.rb +5 -7
  22. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +1 -3
  23. data/lib/legion/extensions/llm/fleet/provider_responder.rb +1 -3
  24. data/lib/legion/extensions/llm/fleet/token_validator.rb +1 -3
  25. data/lib/legion/extensions/llm/model/info.rb +4 -6
  26. data/lib/legion/extensions/llm/models.rb +3 -3
  27. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +9 -4
  28. data/lib/legion/extensions/llm/provider.rb +21 -4
  29. data/lib/legion/extensions/llm/provider_contract.rb +10 -1
  30. data/lib/legion/extensions/llm/routing/lane_key.rb +1 -3
  31. data/lib/legion/extensions/llm/stream_accumulator.rb +40 -1
  32. data/lib/legion/extensions/llm/streaming.rb +13 -5
  33. data/lib/legion/extensions/llm/tool.rb +1 -3
  34. data/lib/legion/extensions/llm/version.rb +1 -1
  35. data/lib/legion/extensions/llm.rb +118 -35
  36. data/spec/fixtures/ruby.mp3 +0 -0
  37. data/spec/fixtures/ruby.mp4 +0 -0
  38. data/spec/fixtures/ruby.png +0 -0
  39. data/spec/fixtures/ruby.txt +1 -0
  40. data/spec/fixtures/ruby.wav +0 -0
  41. data/spec/fixtures/ruby.xml +1 -0
  42. data/spec/fixtures/sample.pdf +0 -0
  43. data/spec/legion/extensions/llm/agent_spec.rb +179 -0
  44. data/spec/legion/extensions/llm/attachment_spec.rb +25 -0
  45. data/spec/legion/extensions/llm/auto_registration_spec.rb +38 -0
  46. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +285 -0
  47. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +179 -0
  48. data/spec/legion/extensions/llm/canonical/message_spec.rb +203 -0
  49. data/spec/legion/extensions/llm/canonical/params_spec.rb +159 -0
  50. data/spec/legion/extensions/llm/canonical/request_spec.rb +174 -0
  51. data/spec/legion/extensions/llm/canonical/response_spec.rb +234 -0
  52. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +151 -0
  53. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +191 -0
  54. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +221 -0
  55. data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +83 -0
  56. data/spec/legion/extensions/llm/canonical/usage_spec.rb +178 -0
  57. data/spec/legion/extensions/llm/configuration_spec.rb +38 -0
  58. data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +432 -0
  59. data/spec/legion/extensions/llm/conformance/conformance.rb +51 -0
  60. data/spec/legion/extensions/llm/conformance/echo_translator.rb +56 -0
  61. data/spec/legion/extensions/llm/conformance/echo_translator_spec.rb +13 -0
  62. data/spec/legion/extensions/llm/conformance/fixtures/canonical_empty_response.json +13 -0
  63. data/spec/legion/extensions/llm/conformance/fixtures/canonical_error_response.json +19 -0
  64. data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +81 -0
  65. data/spec/legion/extensions/llm/conformance/fixtures/canonical_metering_audit_events.json +101 -0
  66. data/spec/legion/extensions/llm/conformance/fixtures/canonical_params_mapping_request.json +21 -0
  67. data/spec/legion/extensions/llm/conformance/fixtures/canonical_server_tool_continuation_request.json +43 -0
  68. data/spec/legion/extensions/llm/conformance/fixtures/canonical_server_tool_use_response.json +29 -0
  69. data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_request.json +13 -0
  70. data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_response.json +13 -0
  71. data/spec/legion/extensions/llm/conformance/fixtures/canonical_stop_reason_matrix.json +36 -0
  72. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_accumulated_response.json +20 -0
  73. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_error_chunks.json +26 -0
  74. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_server_tool_chunks.json +52 -0
  75. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_text_chunks.json +33 -0
  76. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_thinking_chunks.json +42 -0
  77. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_tool_call_chunks.json +41 -0
  78. data/spec/legion/extensions/llm/conformance/fixtures/canonical_system_prompt_request.json +14 -0
  79. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +18 -0
  80. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_response.json +17 -0
  81. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_results_continuation_request.json +75 -0
  82. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_use_response.json +25 -0
  83. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tools_request.json +34 -0
  84. data/spec/legion/extensions/llm/conformance/provider_tool_rendering_examples.rb +77 -0
  85. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +390 -0
  86. data/spec/legion/extensions/llm/connection_logging_spec.rb +53 -0
  87. data/spec/legion/extensions/llm/connection_retry_spec.rb +36 -0
  88. data/spec/legion/extensions/llm/context_spec.rb +127 -0
  89. data/spec/legion/extensions/llm/credential_sources_spec.rb +468 -0
  90. data/spec/legion/extensions/llm/error_middleware_spec.rb +102 -0
  91. data/spec/legion/extensions/llm/error_spec.rb +87 -0
  92. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +120 -0
  93. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +163 -0
  94. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +128 -0
  95. data/spec/legion/extensions/llm/fleet_messages_spec.rb +402 -0
  96. data/spec/legion/extensions/llm/gemspec_spec.rb +25 -0
  97. data/spec/legion/extensions/llm/message_spec.rb +64 -0
  98. data/spec/legion/extensions/llm/model/info_spec.rb +222 -0
  99. data/spec/legion/extensions/llm/models_spec.rb +104 -0
  100. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +203 -0
  101. data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +68 -0
  102. data/spec/legion/extensions/llm/provider_contract_spec.rb +60 -0
  103. data/spec/legion/extensions/llm/provider_settings_spec.rb +76 -0
  104. data/spec/legion/extensions/llm/provider_spec.rb +613 -0
  105. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +68 -0
  106. data/spec/legion/extensions/llm/registry_publisher_spec.rb +22 -0
  107. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +75 -0
  108. data/spec/legion/extensions/llm/responses/thinking_extractor_spec.rb +75 -0
  109. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +222 -0
  110. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +50 -0
  111. data/spec/legion/extensions/llm/routing/registry_event_spec.rb +120 -0
  112. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +155 -0
  113. data/spec/legion/extensions/llm/streaming_spec.rb +108 -0
  114. data/spec/legion/extensions/llm/tool_spec.rb +94 -0
  115. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +60 -0
  116. data/spec/legion/extensions/llm/utils_spec.rb +113 -0
  117. data/spec/legion/extensions/llm_base_contract_spec.rb +110 -0
  118. data/spec/legion/extensions/llm_extension_spec.rb +78 -0
  119. data/spec/legion/extensions/llm_root_spec.rb +51 -0
  120. data/spec/spec_helper.rb +24 -0
  121. data/spec/support/fake_llm_provider.rb +148 -0
  122. data/spec/support/llm_configuration.rb +21 -0
  123. data/spec/support/rspec_configuration.rb +19 -0
  124. data/spec/support/simplecov_configuration.rb +20 -0
  125. metadata +103 -15
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Llm::Canonical::Usage do
6
+ describe '.from_hash' do
7
+ it 'returns a Usage instance with canonical fields' do
8
+ usage = described_class.from_hash(input_tokens: 100, output_tokens: 50)
9
+
10
+ expect(usage).to be_a(described_class)
11
+ expect(usage.input_tokens).to eq(100)
12
+ expect(usage.output_tokens).to eq(50)
13
+ expect(usage.cache_read_tokens).to be_nil
14
+ expect(usage.cache_write_tokens).to be_nil
15
+ expect(usage.thinking_tokens).to be_nil
16
+ expect(usage.units).to eq({})
17
+ end
18
+
19
+ it 'normalizes legacy key names' do
20
+ usage = described_class.from_hash(
21
+ input: 200, output: 100, cached: 50, cache_creation: 25, thinking: 10
22
+ )
23
+
24
+ expect(usage.input_tokens).to eq(200)
25
+ expect(usage.output_tokens).to eq(100)
26
+ expect(usage.cache_read_tokens).to eq(50)
27
+ expect(usage.cache_write_tokens).to eq(25)
28
+ expect(usage.thinking_tokens).to eq(10)
29
+ end
30
+
31
+ it 'normalizes prompt_tokens/completion_tokens aliases' do
32
+ usage = described_class.from_hash(prompt_tokens: 300, completion_tokens: 150)
33
+
34
+ expect(usage.input_tokens).to eq(300)
35
+ expect(usage.output_tokens).to eq(150)
36
+ end
37
+
38
+ it 'normalizes reasoning alias for thinking_tokens' do
39
+ usage = described_class.from_hash(reasoning: 75)
40
+
41
+ expect(usage.thinking_tokens).to eq(75)
42
+ end
43
+
44
+ it 'handles string keys' do
45
+ usage = described_class.from_hash('input_tokens' => '100', 'output_tokens' => '50')
46
+
47
+ expect(usage.input_tokens).to eq('100')
48
+ expect(usage.output_tokens).to eq('50')
49
+ end
50
+
51
+ it 'returns nil for nil source' do
52
+ expect(described_class.from_hash(nil)).to be_nil
53
+ end
54
+
55
+ it 'returns nil for empty hash' do
56
+ expect(described_class.from_hash({})).to be_nil
57
+ end
58
+
59
+ it 'preserves units extension point' do
60
+ units = { images: 3, characters: 1500 }
61
+ usage = described_class.from_hash(input_tokens: 10, units: units)
62
+
63
+ expect(usage.units).to eq(units)
64
+ end
65
+ end
66
+
67
+ describe '#to_h' do
68
+ it 'serializes to compact hash' do
69
+ usage = described_class.new(
70
+ input_tokens: 100, output_tokens: 50,
71
+ cache_read_tokens: nil, cache_write_tokens: nil,
72
+ thinking_tokens: nil, units: {}
73
+ )
74
+ hash = usage.to_h
75
+
76
+ expect(hash).to eq(input_tokens: 100, output_tokens: 50, units: {})
77
+ end
78
+
79
+ it 'includes all non-nil fields' do
80
+ usage = described_class.new(
81
+ input_tokens: 200, output_tokens: 100,
82
+ cache_read_tokens: 50, cache_write_tokens: 25,
83
+ thinking_tokens: 10, units: { images: 2 }
84
+ )
85
+ hash = usage.to_h
86
+
87
+ expect(hash).to include(
88
+ input_tokens: 200, output_tokens: 100,
89
+ cache_read_tokens: 50, cache_write_tokens: 25,
90
+ thinking_tokens: 10, units: { images: 2 }
91
+ )
92
+ end
93
+ end
94
+
95
+ describe '#total_tokens' do
96
+ it 'sums all token categories' do
97
+ usage = described_class.new(
98
+ input_tokens: 100, output_tokens: 50,
99
+ cache_read_tokens: 20, cache_write_tokens: 10,
100
+ thinking_tokens: 5, units: {}
101
+ )
102
+
103
+ expect(usage.total_tokens).to eq(185)
104
+ end
105
+
106
+ it 'ignores nil values' do
107
+ usage = described_class.new(
108
+ input_tokens: 100, output_tokens: 50,
109
+ cache_read_tokens: nil, cache_write_tokens: nil,
110
+ thinking_tokens: nil, units: {}
111
+ )
112
+
113
+ expect(usage.total_tokens).to eq(150)
114
+ end
115
+ end
116
+
117
+ describe 'OpenAI nested details extraction' do
118
+ it 'extracts cached_tokens from prompt_tokens_details (Chat API)' do
119
+ usage = described_class.from_hash(
120
+ prompt_tokens: 1000,
121
+ completion_tokens: 200,
122
+ prompt_tokens_details: { cached_tokens: 800 },
123
+ completion_tokens_details: { reasoning_tokens: 50 }
124
+ )
125
+
126
+ expect(usage.input_tokens).to eq(1000)
127
+ expect(usage.output_tokens).to eq(200)
128
+ expect(usage.cache_read_tokens).to eq(800)
129
+ expect(usage.thinking_tokens).to eq(50)
130
+ end
131
+
132
+ it 'extracts cached_tokens from input_tokens_details (Responses API)' do
133
+ usage = described_class.from_hash(
134
+ input_tokens: 500,
135
+ output_tokens: 100,
136
+ input_tokens_details: { cached_tokens: 400 },
137
+ output_tokens_details: { reasoning_tokens: 30 }
138
+ )
139
+
140
+ expect(usage.input_tokens).to eq(500)
141
+ expect(usage.output_tokens).to eq(100)
142
+ expect(usage.cache_read_tokens).to eq(400)
143
+ expect(usage.thinking_tokens).to eq(30)
144
+ end
145
+
146
+ it 'prefers top-level cache_read_tokens over nested details' do
147
+ usage = described_class.from_hash(
148
+ input_tokens: 500,
149
+ cache_read_tokens: 300,
150
+ prompt_tokens_details: { cached_tokens: 400 }
151
+ )
152
+
153
+ expect(usage.cache_read_tokens).to eq(300)
154
+ end
155
+ end
156
+
157
+ describe 'round-trip' do
158
+ it 'preserves values through from_hash/to_h' do
159
+ original = { input_tokens: 100, output_tokens: 50, cache_read_tokens: 10 }
160
+ usage = described_class.from_hash(original)
161
+ serialized = usage.to_h
162
+
163
+ expect(serialized[:input_tokens]).to eq(100)
164
+ expect(serialized[:output_tokens]).to eq(50)
165
+ expect(serialized[:cache_read_tokens]).to eq(10)
166
+ end
167
+
168
+ it 'preserves legacy key normalization through round-trip' do
169
+ original = { input: 200, output: 100, cached: 50 }
170
+ usage = described_class.from_hash(original)
171
+ serialized = usage.to_h
172
+
173
+ expect(serialized[:input_tokens]).to eq(200)
174
+ expect(serialized[:output_tokens]).to eq(100)
175
+ expect(serialized[:cache_read_tokens]).to eq(50)
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Llm::Configuration do
6
+ describe 'DSL defaults' do
7
+ subject(:config) { described_class.new }
8
+
9
+ it 'applies core default values' do
10
+ expect(config.request_timeout).to eq(300)
11
+ expect(config.max_retries).to eq(3)
12
+ expect(config.retry_interval).to eq(0.1)
13
+ expect(config.retry_backoff_factor).to eq(2)
14
+ expect(config.retry_interval_randomness).to eq(0.5)
15
+ end
16
+
17
+ it 'exposes a discoverable options API' do
18
+ expect(described_class.options).to include(
19
+ :request_timeout,
20
+ :default_model,
21
+ :default_embedding_model,
22
+ :model_registry_file
23
+ )
24
+ end
25
+
26
+ it 'includes prompt caching configuration options' do
27
+ expect(described_class.options).to include(:llm_cache_enabled, :cache_control_prefix_tokens)
28
+ end
29
+
30
+ it 'defaults llm_cache_enabled to true' do
31
+ expect(config.llm_cache_enabled).to be true
32
+ end
33
+
34
+ it 'defaults cache_control_prefix_tokens to 4' do
35
+ expect(config.cache_control_prefix_tokens).to eq(4)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,432 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shared examples for canonical client translator conformance.
4
+ #
5
+ # Every client translator must implement:
6
+ # - parse_request(body, env) → Canonical::Request
7
+ # - format_response(canonical_response) → Hash
8
+ # - format_chunk(canonical_chunk) → Hash | nil
9
+ # - format_error(error, status) → [status, Hash]
10
+ #
11
+ # Usage:
12
+ # it_behaves_like 'a canonical client translator', MyClientTranslatorClass
13
+ # rubocop:disable Lint/NonLocalExitFromIterator -- return guard is idiomatic in shared_example blocks
14
+ RSpec.shared_examples 'a canonical client translator' do |translator_class|
15
+ let(:translator) { translator_class.new }
16
+ let(:canonical) { Legion::Extensions::Llm::Canonical }
17
+ let(:conformance) { Canonical::Conformance }
18
+
19
+ describe '#parse_request' do
20
+ context 'with a simple text request' do
21
+ let(:canonical_req) do
22
+ canonical::Request.from_hash(conformance.fixture_symbolized('canonical_simple_text_request'))
23
+ end
24
+
25
+ it 'returns a Canonical::Request' do
26
+ return unless translator.respond_to?(:format_request)
27
+
28
+ formatted = translator.format_request(canonical_req)
29
+ return unless formatted
30
+
31
+ parsed = translator.parse_request(formatted, {})
32
+ expect(parsed).to be_a(canonical::Request)
33
+ expect(parsed.messages).to be_an(Array)
34
+ expect(parsed.messages.length).to be > 0
35
+ end
36
+ end
37
+
38
+ context 'with a system prompt' do
39
+ let(:canonical_req) do
40
+ canonical::Request.from_hash(conformance.fixture_symbolized('canonical_system_prompt_request'))
41
+ end
42
+
43
+ it 'preserves the system prompt' do
44
+ return unless translator.respond_to?(:format_request)
45
+
46
+ formatted = translator.format_request(canonical_req)
47
+ return unless formatted
48
+
49
+ parsed = translator.parse_request(formatted, {})
50
+ expect(parsed.system).to be_a(String)
51
+ expect(parsed.system).to include('haiku')
52
+ end
53
+ end
54
+
55
+ context 'with tools defined' do
56
+ let(:canonical_req) do
57
+ canonical::Request.from_hash(conformance.fixture_symbolized('canonical_tools_request'))
58
+ end
59
+
60
+ it 'preserves tool definitions' do
61
+ return unless translator.respond_to?(:format_request)
62
+
63
+ formatted = translator.format_request(canonical_req)
64
+ return unless formatted
65
+
66
+ parsed = translator.parse_request(formatted, {})
67
+ expect(parsed.tools).to be_a(Hash)
68
+ expect(parsed.tools.keys).to include(:get_weather)
69
+ end
70
+ end
71
+
72
+ context 'with thinking enabled' do
73
+ let(:canonical_req) do
74
+ canonical::Request.from_hash(conformance.fixture_symbolized('canonical_thinking_request'))
75
+ end
76
+
77
+ it 'preserves thinking configuration' do
78
+ return unless translator.respond_to?(:format_request)
79
+
80
+ formatted = translator.format_request(canonical_req)
81
+ return unless formatted
82
+
83
+ parsed = translator.parse_request(formatted, {})
84
+ expect(parsed.thinking).to be_a(canonical::Thinking::Config)
85
+ expect(parsed.thinking.enabled?).to be true
86
+ end
87
+ end
88
+
89
+ context 'with parameter mapping' do
90
+ let(:canonical_req) do
91
+ canonical::Request.from_hash(conformance.fixture_symbolized('canonical_params_mapping_request'))
92
+ end
93
+
94
+ it 'preserves sampling parameters' do
95
+ return unless translator.respond_to?(:format_request)
96
+
97
+ formatted = translator.format_request(canonical_req)
98
+ return unless formatted
99
+
100
+ parsed = translator.parse_request(formatted, {})
101
+ expect(parsed.params).to be_a(canonical::Params)
102
+ expect(parsed.params.max_tokens).to eq(2048)
103
+ expect(parsed.params.temperature).to eq(0.7)
104
+ end
105
+ end
106
+ end
107
+
108
+ describe '#format_response' do
109
+ context 'with a simple text response' do
110
+ let(:canonical_resp) do
111
+ canonical::Response.from_hash(conformance.fixture_symbolized('canonical_simple_text_response'))
112
+ end
113
+
114
+ it 'formats a valid client response' do
115
+ formatted = translator.format_response(canonical_resp)
116
+ expect(formatted).to be_a(Hash)
117
+ expect(formatted).not_to be_empty
118
+ end
119
+
120
+ it 'includes the text content' do
121
+ formatted = translator.format_response(canonical_resp)
122
+ formatted_str = formatted.to_s
123
+ expect(formatted_str).to include('doing well')
124
+ end
125
+ end
126
+
127
+ context 'with a tool use response' do
128
+ let(:canonical_resp) do
129
+ canonical::Response.from_hash(conformance.fixture_symbolized('canonical_tool_use_response'))
130
+ end
131
+
132
+ it 'formats tool calls in client-appropriate format' do
133
+ formatted = translator.format_response(canonical_resp)
134
+ formatted_str = formatted.to_s.downcase
135
+ expect(formatted_str).to include('get_weather')
136
+ end
137
+
138
+ it 'includes tool call arguments' do
139
+ formatted = translator.format_response(canonical_resp)
140
+ formatted_str = formatted.to_s
141
+ expect(formatted_str).to include('San Francisco')
142
+ end
143
+ end
144
+
145
+ context 'with a thinking response' do
146
+ let(:canonical_resp) do
147
+ canonical::Response.from_hash(conformance.fixture_symbolized('canonical_thinking_response'))
148
+ end
149
+
150
+ it 'includes thinking content in client format' do
151
+ formatted = translator.format_response(canonical_resp)
152
+ formatted_str = formatted.to_s.downcase
153
+ expect(formatted_str).to match(/think|reason|quantum/)
154
+ end
155
+ end
156
+
157
+ context 'with an error response' do
158
+ let(:canonical_resp) do
159
+ canonical::Response.from_hash(conformance.fixture_symbolized('canonical_error_response'))
160
+ end
161
+
162
+ it 'formats error responses without crashing' do
163
+ formatted = translator.format_response(canonical_resp)
164
+ expect(formatted).to be_a(Hash)
165
+ end
166
+ end
167
+ end
168
+
169
+ describe '#format_chunk' do
170
+ context 'with text delta chunks' do
171
+ let(:stream_fixture) { conformance.fixture('canonical_streaming_text_chunks') }
172
+ let(:chunks_data) { stream_fixture['chunks'] }
173
+
174
+ it 'formats text delta chunks' do
175
+ text_chunk_hash = chunks_data.find { |c| c['type'] == 'text_delta' }
176
+ chunk = canonical::Chunk.from_hash(text_chunk_hash)
177
+ formatted = translator.format_chunk(chunk)
178
+
179
+ return unless formatted
180
+
181
+ expect(formatted).to be_a(Hash)
182
+ formatted_str = formatted.to_s
183
+ expect(formatted_str).to include(chunk.delta)
184
+ end
185
+
186
+ it 'formats the done chunk' do
187
+ done_chunk_hash = chunks_data.find { |c| c['type'] == 'done' }
188
+ chunk = canonical::Chunk.from_hash(done_chunk_hash)
189
+ formatted = translator.format_chunk(chunk)
190
+
191
+ return unless formatted
192
+
193
+ expect(formatted).to be_a(Hash)
194
+ end
195
+ end
196
+
197
+ context 'with thinking delta chunks' do
198
+ let(:stream_fixture) { conformance.fixture('canonical_streaming_thinking_chunks') }
199
+ let(:chunks_data) { stream_fixture['chunks'] }
200
+
201
+ it 'formats thinking delta chunks' do
202
+ thinking_chunk_hash = chunks_data.find { |c| c['type'] == 'thinking_delta' }
203
+ chunk = canonical::Chunk.from_hash(thinking_chunk_hash)
204
+ formatted = translator.format_chunk(chunk)
205
+
206
+ return unless formatted
207
+
208
+ expect(formatted).to be_a(Hash)
209
+ end
210
+ end
211
+
212
+ context 'with tool call delta chunks' do
213
+ let(:stream_fixture) { conformance.fixture('canonical_streaming_tool_call_chunks') }
214
+ let(:chunks_data) { stream_fixture['chunks'] }
215
+
216
+ it 'formats tool call delta chunks' do
217
+ tool_chunk_hash = chunks_data.find { |c| c['type'] == 'tool_call_delta' }
218
+ chunk = canonical::Chunk.from_hash(tool_chunk_hash)
219
+ formatted = translator.format_chunk(chunk)
220
+
221
+ return unless formatted
222
+
223
+ expect(formatted).to be_a(Hash)
224
+ formatted_str = formatted.to_s.downcase
225
+ expect(formatted_str).to include('get_weather')
226
+ end
227
+ end
228
+ end
229
+
230
+ describe '#format_error' do
231
+ it 'formats an error with status code' do
232
+ error = StandardError.new('Test error')
233
+ result = translator.format_error(error, 500)
234
+ expect(result).to be_an(Array)
235
+ expect(result.length).to eq(2)
236
+ expect(result[0]).to eq(500)
237
+ expect(result[1]).to be_a(Hash)
238
+ end
239
+ end
240
+
241
+ describe 'round-trip consistency' do
242
+ context 'with request round-trip' do
243
+ let(:canonical_req) do
244
+ canonical::Request.from_hash(conformance.fixture_symbolized('canonical_simple_text_request'))
245
+ end
246
+
247
+ it 'preserves message content through format/parse cycle' do
248
+ return unless translator.respond_to?(:format_request)
249
+
250
+ formatted = translator.format_request(canonical_req)
251
+ parsed = translator.parse_request(formatted, {})
252
+ expect(parsed.messages.length).to eq(canonical_req.messages.length)
253
+ end
254
+ end
255
+
256
+ context 'with response round-trip' do
257
+ let(:canonical_resp) do
258
+ canonical::Response.from_hash(conformance.fixture_symbolized('canonical_simple_text_response'))
259
+ end
260
+
261
+ it 'preserves text through format cycle' do
262
+ formatted = translator.format_response(canonical_resp)
263
+ formatted_str = formatted.to_s
264
+ expect(formatted_str).to include(canonical_resp.text)
265
+ end
266
+ end
267
+ end
268
+
269
+ # G24 — execution-proxy response contract.
270
+ #
271
+ # When a server-executed LegionIO tool resolves before the canonical response
272
+ # is returned, the tool_call carries `:result` and a server-tool source
273
+ # (registry/special/extension/mcp). Client translators MUST surface that
274
+ # exchange as a completed, NON-actionable item — the client must not try to
275
+ # re-execute it. Per format:
276
+ #
277
+ # * Claude /v1/messages — server_tool_use + server_tool_result content
278
+ # blocks (NOT plain tool_use). stop_reason end_turn once all server
279
+ # results are present.
280
+ # * Codex /v1/responses — completed function_call items (or message items)
281
+ # showing name+arguments+result, status 'completed' (NOT 'in_progress'
282
+ # or 'requires_action'). The response status is 'completed', not
283
+ # 'requires_action'.
284
+ # * Codex /v1/chat/completions — finish_reason 'stop' (not 'tool_calls')
285
+ # when only server tools were called and they all have results; the
286
+ # server tool exchange does not appear as actionable tool_calls.
287
+ #
288
+ # Translators declare their family via `g24_format` (one of :claude_messages,
289
+ # :openai_responses, :openai_chat) so the shared examples can pick the right
290
+ # shape assertions. Translators that don't implement g24_format are skipped.
291
+ describe 'G24 execution-proxy contract' do
292
+ let(:canonical_resp) do
293
+ canonical::Response.from_hash(conformance.fixture_symbolized('canonical_server_tool_use_response'))
294
+ end
295
+
296
+ let(:format) do
297
+ next nil unless translator.respond_to?(:g24_format)
298
+
299
+ translator.g24_format
300
+ end
301
+
302
+ context 'with a server-executed tool result in the canonical response' do
303
+ it 'surfaces the server tool name in the formatted response' do
304
+ next if format.nil?
305
+
306
+ formatted = translator.format_response(canonical_resp)
307
+ formatted_str = formatted.to_s
308
+ expect(formatted_str).to include('legion_list_all_tools')
309
+ end
310
+
311
+ it 'surfaces the server tool result text in the formatted response' do
312
+ next if format.nil?
313
+
314
+ formatted = translator.format_response(canonical_resp)
315
+ formatted_str = formatted.to_s
316
+ expect(formatted_str).to include('legion_list_all_tools, legion_apollo_search')
317
+ end
318
+
319
+ it 'never surfaces server-executed tools as actionable items', :aggregate_failures do
320
+ next if format.nil?
321
+
322
+ formatted = translator.format_response(canonical_resp)
323
+
324
+ case format
325
+ when :claude_messages
326
+ # Server-side tools must appear as server_tool_use, never tool_use.
327
+ # The G24 contract says the model must know the call happened AND
328
+ # the client must not re-execute, so server_tool_use+server_tool_result
329
+ # are the only shape — plain tool_use would put the client in a
330
+ # tool-loop trying to fulfill an already-resolved exchange.
331
+ types = (formatted[:content] || formatted['content']).map { |b| b[:type] || b['type'] }
332
+ expect(types).to include('server_tool_use')
333
+ expect(types).to include('server_tool_result')
334
+ expect(types).not_to include('tool_use')
335
+
336
+ stop_reason = formatted[:stop_reason] || formatted['stop_reason']
337
+ expect(stop_reason).to eq('end_turn')
338
+
339
+ server_use = (formatted[:content] || formatted['content']).find do |b|
340
+ (b[:type] || b['type']).to_s == 'server_tool_use'
341
+ end
342
+ expect(server_use[:name] || server_use['name']).to eq('legion_list_all_tools')
343
+
344
+ server_result = (formatted[:content] || formatted['content']).find do |b|
345
+ (b[:type] || b['type']).to_s == 'server_tool_result'
346
+ end
347
+ result_text = (server_result[:content] || server_result['content']).first
348
+ expect(result_text[:text] || result_text['text']).to include('legion_list_all_tools')
349
+ when :openai_responses
350
+ status = formatted[:status] || formatted['status']
351
+ expect(status).to eq('completed')
352
+
353
+ output = formatted[:output] || formatted['output']
354
+ actionable = output.select do |item|
355
+ type = (item[:type] || item['type']).to_s
356
+ status_str = (item[:status] || item['status']).to_s
357
+ type == 'function_call' && status_str != 'completed'
358
+ end
359
+ expect(actionable).to be_empty,
360
+ "found actionable function_call items for server tools: #{actionable.inspect}"
361
+
362
+ # action_required is the legacy requires-action surface — server
363
+ # tools must never end up there.
364
+ action_required = formatted[:action_required] || formatted['action_required']
365
+ expect(action_required).to be_nil
366
+ when :openai_chat
367
+ choice = (formatted[:choices] || formatted['choices']).first
368
+ finish_reason = choice[:finish_reason] || choice['finish_reason']
369
+ expect(finish_reason).to eq('stop')
370
+
371
+ message = choice[:message] || choice['message']
372
+ actionable = (message[:tool_calls] || message['tool_calls'] || []).reject do |tc|
373
+ (tc[:status] || tc['status']).to_s == 'completed'
374
+ end
375
+ expect(actionable).to be_empty,
376
+ "found actionable tool_calls for server tools: #{actionable.inspect}"
377
+ else
378
+ raise "unknown G24 format: #{format.inspect}"
379
+ end
380
+ end
381
+ end
382
+
383
+ it 'formats the streaming tool_call_delta with the registry source' do
384
+ next if format.nil?
385
+
386
+ # The streaming tool_call_delta carries the resolved server_tool result.
387
+ # We don't assert the per-chunk SSE encoding here (that's the route's
388
+ # event emitter contract); we assert format_chunk doesn't drop the call
389
+ # and the source flows through.
390
+ stream_fixture = conformance.fixture('canonical_streaming_server_tool_chunks')
391
+ tool_chunk = stream_fixture['chunks'].find do |c|
392
+ c['type'] == 'tool_call_delta' && c.dig('tool_call', 'result')
393
+ end
394
+ expect(tool_chunk).not_to be_nil
395
+
396
+ chunk = canonical::Chunk.from_hash(tool_chunk)
397
+ formatted = translator.format_chunk(chunk)
398
+
399
+ next if formatted.nil?
400
+
401
+ # Format-specific minimum: the tool name is reachable. Streaming
402
+ # shape per format is asserted in the matrix harness; here we only
403
+ # require that the server-tool name survives chunk formatting.
404
+ expect(formatted.to_s).to include('legion_list_all_tools')
405
+ end
406
+
407
+ it 'parses a continuation request with a prior server-executed exchange losslessly' do
408
+ next if format.nil?
409
+ next unless translator.respond_to?(:format_request)
410
+
411
+ # Each translator round-trips its own format. We render the canonical
412
+ # continuation with format_request (when available) then re-parse —
413
+ # the prior server tool exchange must survive intact.
414
+ continuation_body = conformance.fixture_symbolized('canonical_server_tool_continuation_request')
415
+ canonical_req = canonical::Request.from_hash(continuation_body)
416
+ formatted_body = translator.format_request(canonical_req)
417
+ next if formatted_body.nil?
418
+
419
+ parsed = translator.parse_request(formatted_body, {})
420
+ expect(parsed).to be_a(canonical::Request)
421
+
422
+ # The assistant tool_call and the tool result both survive the cycle.
423
+ roles = parsed.messages.map { |m| m.role.to_sym }
424
+ expect(roles).to include(:assistant)
425
+ expect(roles).to include(:tool)
426
+
427
+ tool_msg = parsed.messages.find { |m| m.role.to_sym == :tool }
428
+ expect(tool_msg.content.to_s).to include('legion_list_all_tools')
429
+ end
430
+ end
431
+ end
432
+ # rubocop:enable Lint/NonLocalExitFromIterator
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Conformance kit: shared RSpec example groups for N×N canonical routing.
4
+ #
5
+ # Ship location: spec/legion/extensions/llm/conformance/
6
+ # Module: Canonical::Conformance
7
+ #
8
+ # Consumer pattern (in provider gem spec_helper):
9
+ # kit = File.join(Gem.loaded_specs['lex-llm'].full_gem_path,
10
+ # 'spec/legion/extensions/llm/conformance')
11
+ # Dir[File.join(kit, '**', '*.rb')].sort.each { |f| require f }
12
+ #
13
+ # Then in specs:
14
+ # it_behaves_like 'a canonical provider translator', described_class
15
+ # it_behaves_like 'a canonical client translator', described_class
16
+
17
+ module Canonical
18
+ module Conformance
19
+ class << self
20
+ def fixtures_path
21
+ @fixtures_path ||= File.expand_path('fixtures', __dir__)
22
+ end
23
+
24
+ def fixture(name)
25
+ path = File.join(fixtures_path, "#{name}.json")
26
+ raise ArgumentError, "Fixture not found: #{name}" unless File.exist?(path)
27
+
28
+ # Explicit encoding: fixtures contain UTF-8; a bare File.read obeys the
29
+ # ambient locale and breaks in shells without LANG set (CI, tool runners).
30
+ ::JSON.parse(File.read(path, encoding: 'UTF-8'))
31
+ end
32
+
33
+ def fixture_symbolized(name)
34
+ deep_symbolize(fixture(name))
35
+ end
36
+
37
+ private
38
+
39
+ def deep_symbolize(obj)
40
+ case obj
41
+ when Hash then obj.transform_keys(&:to_sym).transform_values { |v| deep_symbolize(v) }
42
+ when Array then obj.map { |v| deep_symbolize(v) }
43
+ else obj
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ require_relative 'provider_translator_examples'
51
+ require_relative 'client_translator_examples'