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.
Files changed (163) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +106 -0
  3. data/RULES.md +97 -0
  4. data/lib/legion/extensions/llm/auto_registration.rb +4 -13
  5. data/lib/legion/extensions/llm/canonical/chunk.rb +106 -92
  6. data/lib/legion/extensions/llm/canonical/content_block.rb +66 -75
  7. data/lib/legion/extensions/llm/canonical/message.rb +52 -67
  8. data/lib/legion/extensions/llm/canonical/params.rb +59 -33
  9. data/lib/legion/extensions/llm/canonical/request.rb +67 -54
  10. data/lib/legion/extensions/llm/canonical/response.rb +61 -76
  11. data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
  12. data/lib/legion/extensions/llm/canonical/thinking.rb +99 -51
  13. data/lib/legion/extensions/llm/canonical/tool_call.rb +42 -51
  14. data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
  15. data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
  16. data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
  17. data/lib/legion/extensions/llm/canonical.rb +6 -5
  18. data/lib/legion/extensions/llm/configuration.rb +40 -10
  19. data/lib/legion/extensions/llm/connection.rb +8 -30
  20. data/lib/legion/extensions/llm/credential_sources.rb +32 -49
  21. data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
  22. data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
  23. data/lib/legion/extensions/llm/error.rb +0 -14
  24. data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
  25. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
  26. data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
  27. data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
  28. data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -153
  29. data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
  30. data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -150
  31. data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
  32. data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
  33. data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
  34. data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
  35. data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
  36. data/lib/legion/extensions/llm/inventory/records.rb +82 -99
  37. data/lib/legion/extensions/llm/inventory/registry.rb +54 -46
  38. data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
  39. data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +249 -0
  40. data/lib/legion/extensions/llm/inventory/weight_schema.rb +146 -0
  41. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +172 -182
  42. data/lib/legion/extensions/llm/provider.rb +192 -315
  43. data/lib/legion/extensions/llm/provider_contract.rb +25 -8
  44. data/lib/legion/extensions/llm/provider_settings.rb +5 -26
  45. data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
  46. data/lib/legion/extensions/llm/responses/tool_arguments.rb +34 -0
  47. data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
  48. data/lib/legion/extensions/llm/routing/records.rb +34 -17
  49. data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
  50. data/lib/legion/extensions/llm/streaming.rb +50 -35
  51. data/lib/legion/extensions/llm/taxonomies.rb +29 -17
  52. data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
  53. data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
  54. data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
  55. data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
  56. data/lib/legion/extensions/llm/utils.rb +23 -5
  57. data/lib/legion/extensions/llm/version.rb +1 -1
  58. data/lib/legion/extensions/llm.rb +10 -98
  59. data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
  60. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
  61. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
  62. data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
  63. data/spec/legion/extensions/llm/canonical/params_spec.rb +57 -136
  64. data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
  65. data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
  66. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +68 -148
  67. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
  68. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
  69. data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
  70. data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
  71. data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
  72. data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
  73. data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
  74. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
  75. data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +130 -0
  76. data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +507 -0
  77. data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
  78. data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
  79. data/spec/legion/extensions/llm/error_spec.rb +2 -12
  80. data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
  81. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +178 -67
  82. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
  83. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +101 -62
  84. data/spec/legion/extensions/llm/fleet_messages_spec.rb +118 -123
  85. data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
  86. data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
  87. data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
  88. data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
  89. data/spec/legion/extensions/llm/inventory/records_spec.rb +129 -41
  90. data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +55 -4
  91. data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
  92. data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
  93. data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +312 -0
  94. data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +141 -0
  95. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +105 -68
  96. data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
  97. data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
  98. data/spec/legion/extensions/llm/provider_spec.rb +98 -78
  99. data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
  100. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
  101. data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
  102. data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -16
  103. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
  104. data/spec/legion/extensions/llm/utils_spec.rb +26 -7
  105. data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
  106. data/spec/legion/extensions/llm_extension_spec.rb +5 -5
  107. data/spec/support/fake_llm_provider.rb +45 -39
  108. data/spec/support/fake_ssot_harness.rb +7 -2
  109. data/spec/support/ssot_registry_helpers.rb +3 -2
  110. metadata +15 -54
  111. data/lib/legion/extensions/llm/agent.rb +0 -366
  112. data/lib/legion/extensions/llm/aliases.json +0 -436
  113. data/lib/legion/extensions/llm/aliases.rb +0 -67
  114. data/lib/legion/extensions/llm/attachment.rb +0 -229
  115. data/lib/legion/extensions/llm/chat.rb +0 -354
  116. data/lib/legion/extensions/llm/chunk.rb +0 -10
  117. data/lib/legion/extensions/llm/content.rb +0 -81
  118. data/lib/legion/extensions/llm/context.rb +0 -33
  119. data/lib/legion/extensions/llm/embedding.rb +0 -33
  120. data/lib/legion/extensions/llm/image.rb +0 -109
  121. data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
  122. data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -311
  123. data/lib/legion/extensions/llm/message.rb +0 -118
  124. data/lib/legion/extensions/llm/mime_type.rb +0 -75
  125. data/lib/legion/extensions/llm/model/info.rb +0 -286
  126. data/lib/legion/extensions/llm/model/modalities.rb +0 -26
  127. data/lib/legion/extensions/llm/model/pricing.rb +0 -52
  128. data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
  129. data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
  130. data/lib/legion/extensions/llm/model.rb +0 -11
  131. data/lib/legion/extensions/llm/models.json +0 -57313
  132. data/lib/legion/extensions/llm/models.rb +0 -530
  133. data/lib/legion/extensions/llm/models_schema.json +0 -168
  134. data/lib/legion/extensions/llm/moderation.rb +0 -60
  135. data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
  136. data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
  137. data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
  138. data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
  139. data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
  140. data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
  141. data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
  142. data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
  143. data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
  144. data/lib/legion/extensions/llm/thinking.rb +0 -53
  145. data/lib/legion/extensions/llm/tokens.rb +0 -51
  146. data/lib/legion/extensions/llm/tool_call.rb +0 -34
  147. data/lib/legion/extensions/llm/transcription.rb +0 -39
  148. data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
  149. data/spec/legion/extensions/llm/agent_spec.rb +0 -179
  150. data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
  151. data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
  152. data/spec/legion/extensions/llm/context_spec.rb +0 -127
  153. data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
  154. data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -337
  155. data/spec/legion/extensions/llm/message_spec.rb +0 -64
  156. data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
  157. data/spec/legion/extensions/llm/models_spec.rb +0 -104
  158. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
  159. data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
  160. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
  161. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
  162. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
  163. 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(:model) { Legion::Extensions::Llm::Model::Info.from_hash(id: 'model-a', provider: :openai_compatible) }
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(['model-a', false, 0.2])
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 = Legion::Extensions::Llm::Message.new(
32
+ message = canonical::Message.build(
32
33
  role: :assistant,
33
- content: "internal\n</think>\n\nHello"
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
- temperature: 0.2,
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</think>\n\nHello" }])
49
+ expect(payload[:messages]).to eq([{ role: 'assistant', content: "internal\n</thinking>\n\nHello" }])
49
50
  end
50
51
 
51
- it 'parses chat completion responses with usage and tool calls' do
52
- message = provider.send(:parse_completion_response, fake_response(completion_body))
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
- expect([message.content, message.input_tokens, message.output_tokens]).to eq(['hi', 3, 5])
55
- expect(message.tool_calls[:lookup].arguments).to eq('id' => 1)
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
- message = provider.send(
100
+ response = provider.send(
60
101
  :parse_completion_response,
61
- fake_response(completion_body(content: "hidden only\n</think>\n\nvisible"))
102
+ fake_response(completion_body(content: "hidden only\n</thinking>\n\nvisible"))
62
103
  )
63
104
 
64
- expect(message.content).to eq('visible')
65
- expect(message.thinking.text).to eq('hidden only')
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
- message = provider.send(
110
+ response = provider.send(
70
111
  :parse_completion_response,
71
- fake_response(completion_body(content: "visible\n<think>hidden only"))
112
+ fake_response(completion_body(content: "visible\n<thinking>hidden only"))
72
113
  )
73
114
 
74
- expect(message.content).to eq('visible')
75
- expect(message.thinking.text).to eq('hidden only')
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
- filtered = think_tag_stream.filter_map do |content|
81
- chunk = provider.send(:build_chunk, stream_delta(content: content))
82
- accumulator.add(chunk)
83
- accumulator.filtered_chunk(chunk)
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.to_message(nil)
127
+ message = accumulator.to_response(model: model_id)
87
128
 
88
- expect(filtered.filter_map(&:content)).to eq(['visible'])
89
- expect(filtered.filter_map { |chunk| chunk.thinking&.text }.join).to eq('internal')
90
- expect(message.content).to eq('visible')
91
- expect(message.thinking.text).to eq('internal')
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 'ignores incomplete OpenAI-compatible tool call deltas without a function name' do
95
- chunk = nil
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
- expect do
98
- chunk = provider.send(:build_chunk, stream_delta(tool_calls: [tool_call_delta_without_name]))
99
- end.not_to raise_error
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
- expect(chunk.tool_calls[:'0'].id).to be_nil
102
- expect(chunk.tool_calls[:'0'].name).to be_nil
103
- expect(chunk.tool_calls[:'0'].arguments).to eq('{}')
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.to_message(nil)
114
- expect(message.tool_calls['call-1'].arguments).to eq('city' => 'Chicago')
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: model, dimensions: 768)
166
+ payload = provider.send(:render_embedding_payload, 'hello', model: model_id, dimensions: 768)
119
167
 
120
- expect(payload).to eq(model: 'model-a', input: 'hello', dimensions: 768)
168
+ expect(payload).to eq(model: model_id, input: 'hello', dimensions: 768)
121
169
  end
122
170
 
123
- it 'parses embedding responses for single and batch inputs' do
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([single.vectors, batch.vectors]).to eq([[0.1], [[0.1], [0.2]]])
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 'maps OpenAI-compatible model listings to explicit capabilities and modalities' do
131
- models = provider.send(:parse_list_models_response, fake_response(models_body), :compatible,
132
- provider_class.capabilities)
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(models.map(&:capabilities)).to eq([%i[streaming function_calling tools], %i[embeddings embedding]])
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 = Legion::Extensions::Llm::Message.new(role: :user, content: 'hello')
188
+ message = canonical::Message.build(role: :user, content: 'hello')
143
189
  provider.send(
144
190
  :render_payload,
145
191
  [message],
146
192
  tools: {},
147
- temperature: 0.2,
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' => 'model-a',
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
- ['<think>', 'internal', '</think>visible']
226
+ ['<thinking>', 'internal', '</thinking>visible']
181
227
  end
182
228
 
183
229
  def stream_delta(content: nil, tool_calls: nil)
184
- { 'model' => 'model-a', 'choices' => [{ 'delta' => { 'content' => content, 'tool_calls' => tool_calls }.compact }] }
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
- # Failing-test for the legionio-e2e claude/openai legionio_tool_injection
9
- # regression. After the LexLLMAdapter#normalize_message_tool_calls fix
10
- # (legion-llm 4317d56), the adapter now emits Array<ToolCall> on the
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.new(
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> (the post-canonical shape)' do
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
- end
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 canonical keyword provider methods' do
20
+ it 'requires the 0.8.0 provider method signatures' do
23
21
  expected = {
24
- chat: [%i[keyreq messages], %i[keyreq model]],
25
- stream_chat: [%i[keyreq messages], %i[keyreq model]],
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 canonical arguments' do
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 eight-entry reflection baseline' do
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[keyreq messages], %i[keyreq model]],
64
- stream_chat: [%i[keyreq messages], %i[keyreq model]],
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]]