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.
- checksums.yaml +4 -4
- data/.rubocop.yml +13 -2
- data/B1b-conformance-kit.md +79 -0
- data/CHANGELOG.md +27 -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 +138 -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 +98 -0
- data/lib/legion/extensions/llm/canonical/tool_schema.rb +46 -0
- data/lib/legion/extensions/llm/canonical/usage.rb +74 -0
- data/lib/legion/extensions/llm/canonical.rb +50 -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 +5 -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 +9 -4
- data/lib/legion/extensions/llm/provider.rb +21 -4
- data/lib/legion/extensions/llm/provider_contract.rb +10 -1
- data/lib/legion/extensions/llm/routing/lane_key.rb +1 -3
- data/lib/legion/extensions/llm/stream_accumulator.rb +40 -1
- data/lib/legion/extensions/llm/streaming.rb +13 -5
- 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 +221 -0
- data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +83 -0
- data/spec/legion/extensions/llm/canonical/usage_spec.rb +178 -0
- data/spec/legion/extensions/llm/configuration_spec.rb +38 -0
- data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +432 -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_server_tool_continuation_request.json +43 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_server_tool_use_response.json +29 -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_server_tool_chunks.json +52 -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_tool_rendering_examples.rb +77 -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/open_ai_compatible_tool_calls_array_spec.rb +68 -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 +613 -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 +155 -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 +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'
|