lex-llm 0.7.3 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +106 -0
- data/RULES.md +97 -0
- data/lib/legion/extensions/llm/auto_registration.rb +4 -13
- data/lib/legion/extensions/llm/canonical/chunk.rb +106 -92
- data/lib/legion/extensions/llm/canonical/content_block.rb +66 -75
- data/lib/legion/extensions/llm/canonical/message.rb +52 -67
- data/lib/legion/extensions/llm/canonical/params.rb +59 -33
- data/lib/legion/extensions/llm/canonical/request.rb +67 -54
- data/lib/legion/extensions/llm/canonical/response.rb +61 -76
- data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
- data/lib/legion/extensions/llm/canonical/thinking.rb +99 -51
- data/lib/legion/extensions/llm/canonical/tool_call.rb +42 -51
- data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
- data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
- data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
- data/lib/legion/extensions/llm/canonical.rb +6 -5
- data/lib/legion/extensions/llm/configuration.rb +40 -10
- data/lib/legion/extensions/llm/connection.rb +8 -30
- data/lib/legion/extensions/llm/credential_sources.rb +32 -49
- data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
- data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
- data/lib/legion/extensions/llm/error.rb +0 -14
- data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
- data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
- data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
- data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
- data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -153
- data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
- data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -150
- data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
- data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
- data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
- data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
- data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
- data/lib/legion/extensions/llm/inventory/records.rb +82 -99
- data/lib/legion/extensions/llm/inventory/registry.rb +54 -46
- data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
- data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +249 -0
- data/lib/legion/extensions/llm/inventory/weight_schema.rb +146 -0
- data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +172 -182
- data/lib/legion/extensions/llm/provider.rb +192 -315
- data/lib/legion/extensions/llm/provider_contract.rb +25 -8
- data/lib/legion/extensions/llm/provider_settings.rb +5 -26
- data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
- data/lib/legion/extensions/llm/responses/tool_arguments.rb +34 -0
- data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
- data/lib/legion/extensions/llm/routing/records.rb +34 -17
- data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
- data/lib/legion/extensions/llm/streaming.rb +50 -35
- data/lib/legion/extensions/llm/taxonomies.rb +29 -17
- data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
- data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
- data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
- data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
- data/lib/legion/extensions/llm/utils.rb +23 -5
- data/lib/legion/extensions/llm/version.rb +1 -1
- data/lib/legion/extensions/llm.rb +10 -98
- data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
- data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
- data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
- data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
- data/spec/legion/extensions/llm/canonical/params_spec.rb +57 -136
- data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
- data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
- data/spec/legion/extensions/llm/canonical/thinking_spec.rb +68 -148
- data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
- data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
- data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
- data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
- data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
- data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
- data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
- data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
- data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +130 -0
- data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +507 -0
- data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
- data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
- data/spec/legion/extensions/llm/error_spec.rb +2 -12
- data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
- data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +178 -67
- data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
- data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +101 -62
- data/spec/legion/extensions/llm/fleet_messages_spec.rb +118 -123
- data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
- data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
- data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
- data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
- data/spec/legion/extensions/llm/inventory/records_spec.rb +129 -41
- data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +55 -4
- data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
- data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
- data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +312 -0
- data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +141 -0
- data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +105 -68
- data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
- data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
- data/spec/legion/extensions/llm/provider_spec.rb +98 -78
- data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
- data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
- data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
- data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -16
- data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
- data/spec/legion/extensions/llm/utils_spec.rb +26 -7
- data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
- data/spec/legion/extensions/llm_extension_spec.rb +5 -5
- data/spec/support/fake_llm_provider.rb +45 -39
- data/spec/support/fake_ssot_harness.rb +7 -2
- data/spec/support/ssot_registry_helpers.rb +3 -2
- metadata +15 -54
- data/lib/legion/extensions/llm/agent.rb +0 -366
- data/lib/legion/extensions/llm/aliases.json +0 -436
- data/lib/legion/extensions/llm/aliases.rb +0 -67
- data/lib/legion/extensions/llm/attachment.rb +0 -229
- data/lib/legion/extensions/llm/chat.rb +0 -354
- data/lib/legion/extensions/llm/chunk.rb +0 -10
- data/lib/legion/extensions/llm/content.rb +0 -81
- data/lib/legion/extensions/llm/context.rb +0 -33
- data/lib/legion/extensions/llm/embedding.rb +0 -33
- data/lib/legion/extensions/llm/image.rb +0 -109
- data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
- data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -311
- data/lib/legion/extensions/llm/message.rb +0 -118
- data/lib/legion/extensions/llm/mime_type.rb +0 -75
- data/lib/legion/extensions/llm/model/info.rb +0 -286
- data/lib/legion/extensions/llm/model/modalities.rb +0 -26
- data/lib/legion/extensions/llm/model/pricing.rb +0 -52
- data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
- data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
- data/lib/legion/extensions/llm/model.rb +0 -11
- data/lib/legion/extensions/llm/models.json +0 -57313
- data/lib/legion/extensions/llm/models.rb +0 -530
- data/lib/legion/extensions/llm/models_schema.json +0 -168
- data/lib/legion/extensions/llm/moderation.rb +0 -60
- data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
- data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
- data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
- data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
- data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
- data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
- data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
- data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
- data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
- data/lib/legion/extensions/llm/thinking.rb +0 -53
- data/lib/legion/extensions/llm/tokens.rb +0 -51
- data/lib/legion/extensions/llm/tool_call.rb +0 -34
- data/lib/legion/extensions/llm/transcription.rb +0 -39
- data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
- data/spec/legion/extensions/llm/agent_spec.rb +0 -179
- data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
- data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
- data/spec/legion/extensions/llm/context_spec.rb +0 -127
- data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
- data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -337
- data/spec/legion/extensions/llm/message_spec.rb +0 -64
- data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
- data/spec/legion/extensions/llm/models_spec.rb +0 -104
- data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
- data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
- data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
- data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
- data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
- data/spec/legion/extensions/llm/routing/registry_event_spec.rb +0 -120
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'spec_helper'
|
|
4
4
|
|
|
5
5
|
RSpec.describe Legion::Extensions::Llm::Provider::OpenAICompatible do
|
|
6
|
+
let(:canonical) { Legion::Extensions::Llm::Canonical }
|
|
6
7
|
let(:provider_class) do
|
|
7
8
|
capability_object = Object.new
|
|
8
9
|
def capability_object.chat?(model) = !embeddings?(model)
|
|
@@ -18,89 +19,135 @@ RSpec.describe Legion::Extensions::Llm::Provider::OpenAICompatible do
|
|
|
18
19
|
end
|
|
19
20
|
end
|
|
20
21
|
let(:provider) { provider_class.new(Legion::Extensions::Llm.config) }
|
|
21
|
-
let(:
|
|
22
|
+
let(:model_id) { 'model-a' }
|
|
22
23
|
|
|
23
|
-
it 'renders chat payloads for OpenAI-compatible servers' do
|
|
24
|
+
it 'renders chat payloads for OpenAI-compatible servers (canonical in, O4 temperature from Params)' do
|
|
24
25
|
payload = chat_payload
|
|
25
26
|
|
|
26
|
-
expect(payload.values_at(:model, :stream, :temperature)).to eq([
|
|
27
|
+
expect(payload.values_at(:model, :stream, :temperature)).to eq([model_id, false, 0.2])
|
|
27
28
|
expect(payload[:messages]).to eq([{ role: 'user', content: 'hello' }])
|
|
28
29
|
end
|
|
29
30
|
|
|
30
31
|
it 'preserves assistant think tags in outbound OpenAI-compatible history' do
|
|
31
|
-
message =
|
|
32
|
+
message = canonical::Message.build(
|
|
32
33
|
role: :assistant,
|
|
33
|
-
content: "internal\n</
|
|
34
|
+
content: "internal\n</thinking>\n\nHello"
|
|
34
35
|
)
|
|
35
36
|
|
|
36
37
|
payload = provider.send(
|
|
37
38
|
:render_payload,
|
|
38
39
|
[message],
|
|
39
40
|
tools: {},
|
|
40
|
-
|
|
41
|
-
model: model,
|
|
41
|
+
model: model_id,
|
|
42
42
|
stream: false,
|
|
43
43
|
schema: nil,
|
|
44
44
|
thinking: nil,
|
|
45
|
-
tool_prefs: nil
|
|
45
|
+
tool_prefs: nil,
|
|
46
|
+
params: canonical::Params.build(temperature: 0.2)
|
|
46
47
|
)
|
|
47
48
|
|
|
48
|
-
expect(payload[:messages]).to eq([{ role: 'assistant', content: "internal\n</
|
|
49
|
+
expect(payload[:messages]).to eq([{ role: 'assistant', content: "internal\n</thinking>\n\nHello" }])
|
|
49
50
|
end
|
|
50
51
|
|
|
51
|
-
it '
|
|
52
|
-
message =
|
|
52
|
+
it 'renders content block arrays in the OpenAI wire shape' do
|
|
53
|
+
message = canonical::Message.build(
|
|
54
|
+
role: :user,
|
|
55
|
+
content: [canonical::ContentBlock.text('look at this'),
|
|
56
|
+
canonical::ContentBlock.image(data: 'AAA=', media_type: 'image/png')]
|
|
57
|
+
)
|
|
53
58
|
|
|
54
|
-
|
|
55
|
-
|
|
59
|
+
payload = provider.send(
|
|
60
|
+
:render_payload,
|
|
61
|
+
[message],
|
|
62
|
+
tools: {},
|
|
63
|
+
model: model_id,
|
|
64
|
+
stream: false,
|
|
65
|
+
schema: nil,
|
|
66
|
+
thinking: nil,
|
|
67
|
+
tool_prefs: nil,
|
|
68
|
+
params: nil
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Text blocks render as plain strings; non-text blocks as OpenAI content parts.
|
|
72
|
+
expect(payload[:messages].first[:content]).to eq(
|
|
73
|
+
['look at this', { type: 'image', data: 'AAA=', media_type: 'image/png', source_type: :base64 }]
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'parses chat completion responses to a Canonical::Response with usage and tool calls (08 R2)' do
|
|
78
|
+
response = provider.send(:parse_completion_response, fake_response(completion_body))
|
|
79
|
+
|
|
80
|
+
expect(response).to be_a(canonical::Response)
|
|
81
|
+
expect([response.text, response.usage.input_tokens, response.usage.output_tokens]).to eq(['hi', 3, 5])
|
|
82
|
+
expect(response.tool_calls.first).to be_a(canonical::ToolCall)
|
|
83
|
+
expect(response.tool_calls.first.arguments).to eq('id' => 1)
|
|
84
|
+
expect(response.stop_reason).to be_nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it 'maps finish_reason through the StopReasonMapping vocabulary' do
|
|
88
|
+
response = provider.send(
|
|
89
|
+
:parse_completion_response,
|
|
90
|
+
fake_response({
|
|
91
|
+
'model' => 'model-a',
|
|
92
|
+
'choices' => [{ 'message' => { 'content' => 'hi' }, 'finish_reason' => 'tool_calls' }]
|
|
93
|
+
})
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
expect(response.stop_reason).to eq(:tool_use)
|
|
56
97
|
end
|
|
57
98
|
|
|
58
99
|
it 'strips malformed trailing think close tags from chat completion responses' do
|
|
59
|
-
|
|
100
|
+
response = provider.send(
|
|
60
101
|
:parse_completion_response,
|
|
61
|
-
fake_response(completion_body(content: "hidden only\n</
|
|
102
|
+
fake_response(completion_body(content: "hidden only\n</thinking>\n\nvisible"))
|
|
62
103
|
)
|
|
63
104
|
|
|
64
|
-
expect(
|
|
65
|
-
expect(
|
|
105
|
+
expect(response.text).to eq('visible')
|
|
106
|
+
expect(response.thinking.content).to eq('hidden only')
|
|
66
107
|
end
|
|
67
108
|
|
|
68
109
|
it 'strips truncated think tags from chat completion responses' do
|
|
69
|
-
|
|
110
|
+
response = provider.send(
|
|
70
111
|
:parse_completion_response,
|
|
71
|
-
fake_response(completion_body(content: "visible\n<
|
|
112
|
+
fake_response(completion_body(content: "visible\n<thinking>hidden only"))
|
|
72
113
|
)
|
|
73
114
|
|
|
74
|
-
expect(
|
|
75
|
-
expect(
|
|
115
|
+
expect(response.text).to eq('visible')
|
|
116
|
+
expect(response.thinking.content).to eq('hidden only')
|
|
76
117
|
end
|
|
77
118
|
|
|
78
119
|
it 'does not leak streamed think-tag content split across chunks' do
|
|
79
120
|
accumulator = Legion::Extensions::Llm::StreamAccumulator.new
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
accumulator.
|
|
121
|
+
emitted = []
|
|
122
|
+
|
|
123
|
+
think_tag_stream.each do |content|
|
|
124
|
+
emitted.concat(accumulator.add(provider.send(:build_chunk, stream_delta(content: content))))
|
|
84
125
|
end
|
|
85
126
|
|
|
86
|
-
message = accumulator.
|
|
127
|
+
message = accumulator.to_response(model: model_id)
|
|
87
128
|
|
|
88
|
-
expect(
|
|
89
|
-
expect(
|
|
90
|
-
expect(message.
|
|
91
|
-
expect(message.thinking.
|
|
129
|
+
expect(emitted.select(&:text_delta?).map(&:delta)).to eq(['visible'])
|
|
130
|
+
expect(emitted.select(&:thinking_delta?).map(&:delta).join).to eq('internal')
|
|
131
|
+
expect(message.text).to eq('visible')
|
|
132
|
+
expect(message.thinking.content).to eq('internal')
|
|
92
133
|
end
|
|
93
134
|
|
|
94
|
-
it '
|
|
95
|
-
chunk =
|
|
135
|
+
it 'builds canonical chunks from streamed data (O5 — canonical out)' do
|
|
136
|
+
chunk = provider.send(:build_chunk, stream_delta(content: 'hello'))
|
|
137
|
+
expect(chunk).to be_a(canonical::Chunk)
|
|
138
|
+
expect(chunk.type).to eq(:text_delta)
|
|
139
|
+
expect(chunk.delta).to eq('hello')
|
|
96
140
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
141
|
+
usage_chunk = provider.send(:build_chunk, { 'choices' => [], 'usage' => { 'prompt_tokens' => 2 } })
|
|
142
|
+
expect(usage_chunk.type).to eq(:usage)
|
|
143
|
+
expect(usage_chunk.usage.input_tokens).to eq(2)
|
|
144
|
+
end
|
|
100
145
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
expect(chunk
|
|
146
|
+
it 'emits tool_call_delta chunks carrying the wire fragment (U1/U2 shapes)' do
|
|
147
|
+
chunk = provider.send(:build_chunk, stream_delta(tool_calls: [tool_call_delta_without_name]))
|
|
148
|
+
expect(chunk).to be_a(canonical::Chunk)
|
|
149
|
+
expect(chunk.type).to eq(:tool_call_delta)
|
|
150
|
+
expect(chunk.tool_call).to eq(id: nil, name: nil, arguments: '{}', index: 0)
|
|
104
151
|
end
|
|
105
152
|
|
|
106
153
|
it 'accumulates streamed OpenAI-compatible tool call argument fragments' do
|
|
@@ -110,52 +157,51 @@ RSpec.describe Legion::Extensions::Llm::Provider::OpenAICompatible do
|
|
|
110
157
|
accumulator.add(provider.send(:build_chunk, stream_delta(tool_calls: [delta])))
|
|
111
158
|
end
|
|
112
159
|
|
|
113
|
-
message = accumulator.
|
|
114
|
-
expect(message.tool_calls
|
|
160
|
+
message = accumulator.to_response(model: model_id)
|
|
161
|
+
expect(message.tool_calls.first.name).to eq('lookup')
|
|
162
|
+
expect(message.tool_calls.first.arguments).to eq('city' => 'Chicago')
|
|
115
163
|
end
|
|
116
164
|
|
|
117
165
|
it 'renders embedding payloads with model ids' do
|
|
118
|
-
payload = provider.send(:render_embedding_payload, 'hello', model:
|
|
166
|
+
payload = provider.send(:render_embedding_payload, 'hello', model: model_id, dimensions: 768)
|
|
119
167
|
|
|
120
|
-
expect(payload).to eq(model:
|
|
168
|
+
expect(payload).to eq(model: model_id, input: 'hello', dimensions: 768)
|
|
121
169
|
end
|
|
122
170
|
|
|
123
|
-
it 'parses embedding responses
|
|
171
|
+
it 'parses embedding responses to the documented artifact Hash (05 §3)' do
|
|
124
172
|
single = provider.send(:parse_embedding_response, fake_response(embedding_body), model: 'embed', text: 'a')
|
|
125
173
|
batch = provider.send(:parse_embedding_response, fake_response(embedding_body), model: 'embed', text: %w[a b])
|
|
126
174
|
|
|
127
|
-
expect([
|
|
175
|
+
expect(single[:embedding]).to eq([0.1])
|
|
176
|
+
expect(batch[:embedding]).to eq([[0.1], [0.2]])
|
|
177
|
+
expect(single[:usage]).to be_a(canonical::Usage)
|
|
128
178
|
end
|
|
129
179
|
|
|
130
|
-
it '
|
|
131
|
-
|
|
132
|
-
|
|
180
|
+
it 'parses moderation responses to the documented artifact Hash (05 §3)' do
|
|
181
|
+
body = { 'model' => 'mod-1', 'results' => [{ 'flagged' => true, 'categories' => { 'hate' => true } }] }
|
|
182
|
+
result = provider.send(:parse_moderation_response, fake_response(body), model: 'mod-1')
|
|
133
183
|
|
|
134
|
-
expect(
|
|
135
|
-
expect(models.map { |model| model.modalities.to_h }).to eq([
|
|
136
|
-
{ input: %w[text image], output: %w[text] },
|
|
137
|
-
{ input: %w[text], output: %w[embeddings] }
|
|
138
|
-
])
|
|
184
|
+
expect(result).to eq(model: 'mod-1', result: { flagged: true, categories: { 'hate' => true } })
|
|
139
185
|
end
|
|
140
186
|
|
|
141
187
|
def chat_payload
|
|
142
|
-
message =
|
|
188
|
+
message = canonical::Message.build(role: :user, content: 'hello')
|
|
143
189
|
provider.send(
|
|
144
190
|
:render_payload,
|
|
145
191
|
[message],
|
|
146
192
|
tools: {},
|
|
147
|
-
|
|
148
|
-
model: model,
|
|
193
|
+
model: model_id,
|
|
149
194
|
stream: false,
|
|
150
195
|
schema: nil,
|
|
151
196
|
thinking: nil,
|
|
152
|
-
tool_prefs: nil
|
|
197
|
+
tool_prefs: nil,
|
|
198
|
+
params: canonical::Params.build(temperature: 0.2)
|
|
153
199
|
)
|
|
154
200
|
end
|
|
155
201
|
|
|
156
202
|
def completion_body(content: 'hi')
|
|
157
203
|
{
|
|
158
|
-
'model' =>
|
|
204
|
+
'model' => model_id,
|
|
159
205
|
'choices' => [{ 'message' => { 'content' => content, 'tool_calls' => [tool_call] } }],
|
|
160
206
|
'usage' => { 'prompt_tokens' => 3, 'completion_tokens' => 5 }
|
|
161
207
|
}
|
|
@@ -177,26 +223,17 @@ RSpec.describe Legion::Extensions::Llm::Provider::OpenAICompatible do
|
|
|
177
223
|
end
|
|
178
224
|
|
|
179
225
|
def think_tag_stream
|
|
180
|
-
['<
|
|
226
|
+
['<thinking>', 'internal', '</thinking>visible']
|
|
181
227
|
end
|
|
182
228
|
|
|
183
229
|
def stream_delta(content: nil, tool_calls: nil)
|
|
184
|
-
{ 'model' =>
|
|
230
|
+
{ 'model' => model_id, 'choices' => [{ 'delta' => { 'content' => content, 'tool_calls' => tool_calls }.compact }] }
|
|
185
231
|
end
|
|
186
232
|
|
|
187
233
|
def embedding_body
|
|
188
234
|
{ 'data' => [{ 'embedding' => [0.1] }, { 'embedding' => [0.2] }], 'usage' => { 'prompt_tokens' => 2 } }
|
|
189
235
|
end
|
|
190
236
|
|
|
191
|
-
def models_body
|
|
192
|
-
{
|
|
193
|
-
'data' => [
|
|
194
|
-
{ 'id' => 'chat-model', 'created' => 1 },
|
|
195
|
-
{ 'id' => 'embed-model', 'created' => 2 }
|
|
196
|
-
]
|
|
197
|
-
}
|
|
198
|
-
end
|
|
199
|
-
|
|
200
237
|
def fake_response(body)
|
|
201
238
|
Struct.new(:body).new(body)
|
|
202
239
|
end
|
|
@@ -2,27 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
require 'spec_helper'
|
|
4
4
|
require 'legion/extensions/llm/provider/open_ai_compatible'
|
|
5
|
-
require 'legion/extensions/llm/message'
|
|
6
|
-
require 'legion/extensions/llm/tool_call'
|
|
7
5
|
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# assistant message — which is the historical canonical shape per
|
|
12
|
-
# canonical/message.rb:75 ("Array is canonical; Hash is legacy lex-llm
|
|
13
|
-
# format (name → ToolCall)"). The OpenAI-compatible HTTP payload renderer
|
|
14
|
-
# missed that update and still calls `.values` on tool_calls, so live
|
|
15
|
-
# requests now blow up with:
|
|
16
|
-
#
|
|
17
|
-
# undefined method 'values' for an instance of Array
|
|
18
|
-
#
|
|
19
|
-
# (captured at
|
|
20
|
-
# legionio-e2e/results/claude/openai_legionio_tool_injection_returns_response_containing_legionio_tool_references_response.json)
|
|
21
|
-
#
|
|
22
|
-
# The bedrock provider (format_invoke_model_assistant) and the
|
|
23
|
-
# lex-llm-openai canonical translator (translator.rb:290) already handle
|
|
24
|
-
# both shapes via `tool_calls.is_a?(Hash) ? .values : Array(...)`. This
|
|
25
|
-
# spec pins the same shape-tolerance for OpenAICompatible.
|
|
6
|
+
# Regression pin for the legionio-e2e claude/openai legionio_tool_injection
|
|
7
|
+
# defect: the renderer must handle Array<Canonical::ToolCall> (the canonical
|
|
8
|
+
# shape). 0.8.0: the legacy Hash shape is deleted (O03a/L4 — Array only).
|
|
26
9
|
RSpec.describe Legion::Extensions::Llm::Provider::OpenAICompatible do
|
|
27
10
|
let(:host_class) do
|
|
28
11
|
Class.new do
|
|
@@ -37,32 +20,25 @@ RSpec.describe Legion::Extensions::Llm::Provider::OpenAICompatible do
|
|
|
37
20
|
let(:host) { host_class.new }
|
|
38
21
|
|
|
39
22
|
let(:tool_call) do
|
|
40
|
-
Legion::Extensions::Llm::ToolCall.
|
|
41
|
-
id: 'call_001',
|
|
23
|
+
Legion::Extensions::Llm::Canonical::ToolCall.build(
|
|
42
24
|
name: 'legion_list_all_tools',
|
|
25
|
+
id: 'call_001',
|
|
43
26
|
arguments: { 'filter' => 'all' }
|
|
44
27
|
)
|
|
45
28
|
end
|
|
46
29
|
|
|
47
|
-
it 'renders an Array<ToolCall>
|
|
30
|
+
it 'renders an Array<Canonical::ToolCall>' do
|
|
48
31
|
rendered = host.format_openai_tool_calls([tool_call])
|
|
49
32
|
expect(rendered).to be_an(Array)
|
|
50
33
|
expect(rendered.size).to eq(1)
|
|
51
34
|
expect(rendered.first[:id]).to eq('call_001')
|
|
52
35
|
expect(rendered.first[:type]).to eq('function')
|
|
53
36
|
expect(rendered.first[:function][:name]).to eq('legion_list_all_tools')
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
it 'renders a Hash<id, ToolCall> (legacy lex-llm shape) without regression' do
|
|
57
|
-
rendered = host.format_openai_tool_calls({ call_id: tool_call })
|
|
58
|
-
expect(rendered).to be_an(Array)
|
|
59
|
-
expect(rendered.size).to eq(1)
|
|
60
|
-
expect(rendered.first[:function][:name]).to eq('legion_list_all_tools')
|
|
37
|
+
expect(Legion::JSON.parse(rendered.first[:function][:arguments])).to eq(filter: 'all')
|
|
61
38
|
end
|
|
62
39
|
|
|
63
40
|
it 'returns nil when tool_calls is nil or empty' do
|
|
64
41
|
expect(host.format_openai_tool_calls(nil)).to be_nil
|
|
65
42
|
expect(host.format_openai_tool_calls([])).to be_nil
|
|
66
|
-
expect(host.format_openai_tool_calls({})).to be_nil
|
|
67
43
|
end
|
|
68
44
|
end
|
|
@@ -10,22 +10,19 @@ RSpec.describe Legion::Extensions::Llm::ProviderContract do
|
|
|
10
10
|
def self.configuration_requirements = []
|
|
11
11
|
def self.local? = false
|
|
12
12
|
def self.remote? = true
|
|
13
|
-
def self.assume_models_exist? = false
|
|
14
13
|
|
|
15
14
|
def ensure_configured! = nil
|
|
16
15
|
def api_base = 'http://example.invalid'
|
|
17
16
|
def models_url = '/v1/models'
|
|
18
|
-
def parse_list_models_response(*) = []
|
|
19
17
|
end
|
|
20
18
|
end
|
|
21
19
|
|
|
22
|
-
it 'requires
|
|
20
|
+
it 'requires the 0.8.0 provider method signatures' do
|
|
23
21
|
expected = {
|
|
24
|
-
chat: [%i[
|
|
25
|
-
stream_chat: [%i[
|
|
22
|
+
chat: [%i[req messages], %i[keyreq model]],
|
|
23
|
+
stream_chat: [%i[req messages], %i[keyreq model]],
|
|
26
24
|
embed: [%i[keyreq text], %i[keyreq model]],
|
|
27
25
|
image: [%i[keyreq prompt], %i[keyreq model]],
|
|
28
|
-
list_models: [%i[key live], %i[keyrest filters]],
|
|
29
26
|
discover_offerings: [%i[key live], %i[keyrest filters]],
|
|
30
27
|
health: [%i[key live]],
|
|
31
28
|
count_tokens: [%i[keyreq messages], %i[keyreq model], %i[key params]]
|
|
@@ -36,13 +33,12 @@ RSpec.describe Legion::Extensions::Llm::ProviderContract do
|
|
|
36
33
|
required_parameters.each do |required_parameter|
|
|
37
34
|
expect(parameters).to include(required_parameter), "#{method_name} missing #{required_parameter.inspect}"
|
|
38
35
|
end
|
|
39
|
-
expect(parameters).not_to include(%i[req messages])
|
|
40
36
|
expect(parameters).not_to include(%i[req text])
|
|
41
37
|
expect(parameters).not_to include(%i[req prompt])
|
|
42
38
|
end
|
|
43
39
|
end
|
|
44
40
|
|
|
45
|
-
it 'rejects positional
|
|
41
|
+
it 'rejects non-canonical message input and positional artifact arguments' do
|
|
46
42
|
provider = provider_class.new(
|
|
47
43
|
request_timeout: 30,
|
|
48
44
|
max_retries: 0,
|
|
@@ -51,20 +47,19 @@ RSpec.describe Legion::Extensions::Llm::ProviderContract do
|
|
|
51
47
|
retry_interval_randomness: 0
|
|
52
48
|
)
|
|
53
49
|
|
|
54
|
-
expect { provider.chat([], model: 'model') }.to raise_error(ArgumentError)
|
|
55
|
-
expect { provider.stream_chat([], model: 'model') }.to raise_error(ArgumentError)
|
|
50
|
+
expect { provider.chat(['not canonical'], model: 'model') }.to raise_error(ArgumentError, /Canonical::Message/)
|
|
51
|
+
expect { provider.stream_chat([42], model: 'model') }.to raise_error(ArgumentError, /Canonical::Message/)
|
|
52
|
+
expect { provider.count_tokens(messages: ['nope'], model: 'model') }.to raise_error(ArgumentError, /Canonical::Message/)
|
|
56
53
|
expect { provider.embed('text', model: 'model') }.to raise_error(ArgumentError)
|
|
57
54
|
expect { provider.image('prompt', model: 'model') }.to raise_error(ArgumentError)
|
|
58
|
-
expect { provider.count_tokens([], model: 'model') }.to raise_error(ArgumentError)
|
|
59
55
|
end
|
|
60
56
|
|
|
61
|
-
it 'keeps REQUIRED_SIGNATURES as exactly the
|
|
57
|
+
it 'keeps REQUIRED_SIGNATURES as exactly the seven-entry reflection baseline' do
|
|
62
58
|
expect(described_class::REQUIRED_SIGNATURES).to eq(
|
|
63
|
-
chat: [%i[
|
|
64
|
-
stream_chat: [%i[
|
|
59
|
+
chat: [%i[req messages], %i[keyreq model]],
|
|
60
|
+
stream_chat: [%i[req messages], %i[keyreq model]],
|
|
65
61
|
embed: [%i[keyreq text], %i[keyreq model]],
|
|
66
62
|
image: [%i[keyreq prompt], %i[keyreq model]],
|
|
67
|
-
list_models: [%i[key live], %i[keyrest filters]],
|
|
68
63
|
discover_offerings: [%i[key live], %i[key raise_on_unreachable], %i[keyrest filters]],
|
|
69
64
|
health: [%i[key live]],
|
|
70
65
|
count_tokens: [%i[keyreq messages], %i[keyreq model], %i[key params]]
|