lex-llm 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +8 -0
  3. data/lib/legion/extensions/llm/canonical/message.rb +16 -3
  4. data/lib/legion/extensions/llm/canonical/tool_definition.rb +26 -1
  5. data/lib/legion/extensions/llm/canonical/tool_schema.rb +46 -0
  6. data/lib/legion/extensions/llm/canonical/usage.rb +13 -0
  7. data/lib/legion/extensions/llm/canonical.rb +1 -0
  8. data/lib/legion/extensions/llm/error.rb +2 -0
  9. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +9 -8
  10. data/lib/legion/extensions/llm/provider.rb +21 -4
  11. data/lib/legion/extensions/llm/provider_contract.rb +10 -1
  12. data/lib/legion/extensions/llm/stream_accumulator.rb +39 -0
  13. data/lib/legion/extensions/llm/streaming.rb +12 -2
  14. data/lib/legion/extensions/llm/version.rb +1 -1
  15. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +49 -2
  16. data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +83 -0
  17. data/spec/legion/extensions/llm/canonical/usage_spec.rb +40 -0
  18. data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +163 -0
  19. data/spec/legion/extensions/llm/conformance/fixtures/canonical_server_tool_continuation_request.json +43 -0
  20. data/spec/legion/extensions/llm/conformance/fixtures/canonical_server_tool_use_response.json +29 -0
  21. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_server_tool_chunks.json +52 -0
  22. data/spec/legion/extensions/llm/conformance/provider_tool_rendering_examples.rb +77 -0
  23. data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +68 -0
  24. data/spec/legion/extensions/llm/provider_spec.rb +24 -3
  25. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +52 -0
  26. metadata +8 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9ab0a51687719efbd7f048be70ff4ba57b6d81fafddfcaefb2f0d78f5f3721ae
4
- data.tar.gz: 49e57ce1c99330d956cc92b6c6a75a283f078f5e0b8fb036ae7aad9579caccd0
3
+ metadata.gz: 2aef21677943bb1b621d6defad35f76e3ca8195ea971b172e7924c7b83774f62
4
+ data.tar.gz: 8a46fdb9ead39ffb08335e019e4623844ad91de4af1cb30960802a39ecf75ab0
5
5
  SHA512:
6
- metadata.gz: 8d8fcea6f732dd4c5dfcd713024796a640941d18c8ef90f3c5b5ca0b3fcbc0094942f2cadabbdd78669ecabce8cd76f893488a92f15ec70c21ebec865fb0a531
7
- data.tar.gz: 4a062635e491cb84b60117b1c28520a8fc5bb4d0775609ed1104639e549114c2a373e68461833008a234e33d7ac14a5fe1292cc2f37fe37b0f00818387cc4851
6
+ metadata.gz: 6909cc6018428bde9983ab3cf001d31f87cd9a90c7c58571e6daa3041dbe25962f610ea37b79a1abf1094385feea37e6f2d466c2131ed41e939e7c4da8ac3980
7
+ data.tar.gz: 5c2b52d64916c8b169a49dc3459ca1aac9c4af6f148865ae190368d364754dce3e35865bf3cfa525ce431cd0bfe0f940240c989b20f5a4be0f26c9cba4574471
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.1 - 2026-06-12
4
+
5
+ ### Fixed
6
+ - **ToolDefinition constants** — Move `OBJECT_SCHEMA_KEYWORDS` and `COMPOSITE_SCHEMA_KEYWORDS` out of `Data.define` block to satisfy `Lint/ConstantDefinitionInBlock`.
7
+ - **ToolSchema documentation** — Add top-level module documentation comment.
8
+ - **Conformance spec cleanup** — Remove unused block argument from shared examples, fix duplicate describe block and context wording in tool_definition_spec.
9
+ - **RuboCop clean** — Zero offenses across 140 files.
10
+
3
11
  ## 0.5.0 - 2026-06-10
4
12
 
5
13
  ### Added
@@ -72,11 +72,24 @@ module Legion
72
72
  h[:content] = ContentBlock.from_hash(content)
73
73
  end
74
74
 
75
- # Parse tool calls if they're an array of hashes
75
+ # Parse tool calls Array is canonical; Hash is legacy lex-llm format (name → ToolCall)
76
76
  tool_calls = h[:tool_calls]
77
- if tool_calls.is_a?(Array)
77
+ if tool_calls.is_a?(Hash)
78
+ h[:tool_calls] = tool_calls.values.map do |tc|
79
+ next tc if tc.is_a?(ToolCall)
80
+
81
+ raw = tc.respond_to?(:to_h) ? tc.to_h : tc
82
+ ToolCall.from_hash(raw)
83
+ end
84
+ elsif tool_calls.is_a?(Array)
78
85
  h[:tool_calls] = tool_calls.map do |tc|
79
- tc.is_a?(ToolCall) ? tc : ToolCall.from_hash(tc)
86
+ next tc if tc.is_a?(ToolCall)
87
+
88
+ if tc.is_a?(Hash)
89
+ ToolCall.from_hash(tc)
90
+ else
91
+ ToolCall.from_hash(tc.respond_to?(:to_h) ? tc.to_h : tc)
92
+ end
80
93
  end
81
94
  end
82
95
 
@@ -5,16 +5,33 @@ module Legion
5
5
  module Llm
6
6
  module Canonical
7
7
  TOOL_NAME_MAX_LENGTH = 64
8
+ OBJECT_SCHEMA_KEYWORDS = %i[properties required additionalProperties].freeze
9
+ COMPOSITE_SCHEMA_KEYWORDS = %i[oneOf anyOf allOf enum $ref $defs definitions].freeze
8
10
 
9
11
  # Canonical tool definition.
10
12
  # Ports field vocabulary from Legion::LLM::Types::ToolDefinition.
11
13
  ToolDefinition = ::Data.define(:name, :description, :parameters, :source) do
14
+ def self.normalize_parameters(parameters)
15
+ empty = { type: 'object', properties: {} }
16
+ return empty if parameters.nil?
17
+
18
+ schema = if parameters.respond_to?(:transform_keys)
19
+ parameters.transform_keys { |k| k.respond_to?(:to_sym) ? k.to_sym : k }
20
+ end
21
+ return empty if schema.nil? || schema.empty?
22
+ return schema if schema.key?(:type)
23
+ return schema.merge(type: 'object') if OBJECT_SCHEMA_KEYWORDS.any? { |k| schema.key?(k) }
24
+ return schema if COMPOSITE_SCHEMA_KEYWORDS.any? { |k| schema.key?(k) }
25
+
26
+ { type: 'object', properties: schema }
27
+ end
28
+
12
29
  # Build from keyword args (primary constructor).
13
30
  def self.build(name:, description: '', parameters: nil, source: nil)
14
31
  new(
15
32
  sanitize_tool_name(name),
16
33
  description.to_s,
17
- parameters || {},
34
+ normalize_parameters(parameters),
18
35
  source || { type: :builtin }
19
36
  )
20
37
  end
@@ -58,6 +75,14 @@ module Legion
58
75
  name.empty? ? 'tool' : name
59
76
  end
60
77
 
78
+ def params_schema
79
+ parameters
80
+ end
81
+
82
+ def input_schema
83
+ parameters
84
+ end
85
+
61
86
  # Serialize to a Hash for AMQP/fleet/wire transport.
62
87
  def to_h
63
88
  {
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Llm
6
+ module Canonical
7
+ # Extracts and normalizes tool schemas from heterogeneous sources.
8
+ module ToolSchema
9
+ EMPTY_OBJECT = { type: 'object', properties: {} }.freeze
10
+
11
+ module_function
12
+
13
+ def extract(tool)
14
+ raw = raw_schema(tool)
15
+ ToolDefinition.normalize_parameters(raw)
16
+ end
17
+
18
+ def raw_schema(tool)
19
+ return nil if tool.nil?
20
+ return tool.params_schema if tool.respond_to?(:params_schema) && tool.params_schema
21
+ return tool.parameters if tool.respond_to?(:parameters) && tool.parameters
22
+
23
+ return unless tool.respond_to?(:[])
24
+
25
+ tool[:parameters] || tool['parameters'] || tool[:input_schema] || tool['input_schema'] ||
26
+ tool[:params_schema] || tool['params_schema']
27
+ end
28
+
29
+ def tool_name(tool)
30
+ return tool.name if tool.respond_to?(:name) && !tool.is_a?(Hash)
31
+ return tool[:name] || tool['name'] if tool.respond_to?(:[])
32
+
33
+ 'unknown'
34
+ end
35
+
36
+ def tool_description(tool)
37
+ return tool.description if tool.respond_to?(:description) && !tool.is_a?(Hash)
38
+ return (tool[:description] || tool['description'] || '').to_s if tool.respond_to?(:[])
39
+
40
+ ''
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -30,6 +30,12 @@ module Legion
30
30
  h[:cache_write_tokens] ||= h.delete(:cache_creation) || h.delete(:cache_write)
31
31
  h[:thinking_tokens] ||= h.delete(:thinking) || h.delete(:reasoning)
32
32
 
33
+ # Extract nested details (OpenAI prompt_tokens_details / input_tokens_details)
34
+ h[:cache_read_tokens] ||= dig_nested(h, :prompt_tokens_details, :cached_tokens) ||
35
+ dig_nested(h, :input_tokens_details, :cached_tokens)
36
+ h[:thinking_tokens] ||= dig_nested(h, :completion_tokens_details, :reasoning_tokens) ||
37
+ dig_nested(h, :output_tokens_details, :reasoning_tokens)
38
+
33
39
  # Extract units (non-token extension point — G20b)
34
40
  units = h.delete(:units) || {}
35
41
 
@@ -43,6 +49,13 @@ module Legion
43
49
  )
44
50
  end
45
51
 
52
+ def self.dig_nested(hash, details_key, value_key)
53
+ details = hash[details_key]
54
+ return nil unless details.is_a?(Hash)
55
+
56
+ details[value_key] || details[value_key.to_s]
57
+ end
58
+
46
59
  # Serialize to a Hash for AMQP/fleet/wire transport.
47
60
  def to_h
48
61
  super.compact
@@ -5,6 +5,7 @@ require_relative 'canonical/usage'
5
5
  require_relative 'canonical/params'
6
6
  require_relative 'canonical/content_block'
7
7
  require_relative 'canonical/tool_definition'
8
+ require_relative 'canonical/tool_schema'
8
9
  require_relative 'canonical/tool_call'
9
10
  require_relative 'canonical/message'
10
11
  require_relative 'canonical/request'
@@ -54,6 +54,8 @@ module Legion
54
54
 
55
55
  # Faraday middleware that maps provider-specific API errors to Legion::Extensions::Llm errors.
56
56
  class ErrorMiddleware < Faraday::Middleware
57
+ extend Legion::Logging::Helper
58
+
57
59
  STREAM_ERROR_BODY_KEY = :legion_llm_stream_error_body
58
60
 
59
61
  def initialize(app, options = {})
@@ -76,7 +76,12 @@ module Legion
76
76
  def format_openai_tool_calls(tool_calls)
77
77
  return nil unless tool_calls&.any?
78
78
 
79
- tool_calls.values.map do |tool_call|
79
+ # Array is the canonical shape (per canonical/message.rb); Hash
80
+ # is the legacy lex-llm shape (id => ToolCall). Both flow through
81
+ # this renderer depending on caller.
82
+ calls = tool_calls.is_a?(Hash) ? tool_calls.values : Array(tool_calls)
83
+
84
+ calls.map do |tool_call|
80
85
  {
81
86
  id: tool_call.id,
82
87
  type: 'function',
@@ -92,16 +97,12 @@ module Legion
92
97
  return nil if tools.empty?
93
98
 
94
99
  tools.values.map do |tool|
95
- # Tools can be ToolDefinition objects or plain Hashes from native_dispatch.
96
- tool_name = tool.respond_to?(:name) ? tool.name : (tool[:name] || tool['name'])
97
- tool_desc = tool.respond_to?(:description) ? tool.description : (tool[:description] || tool['description'] || '')
98
- tool_params = tool.respond_to?(:params_schema) ? tool.params_schema : (tool[:parameters] || tool['parameters'] || {})
99
100
  {
100
101
  type: 'function',
101
102
  function: {
102
- name: tool_name,
103
- description: tool_desc,
104
- parameters: tool_params || { type: 'object', properties: {} }
103
+ name: Canonical::ToolSchema.tool_name(tool),
104
+ description: Canonical::ToolSchema.tool_description(tool),
105
+ parameters: Canonical::ToolSchema.extract(tool)
105
106
  }
106
107
  }
107
108
  end
@@ -137,7 +137,7 @@ module Legion
137
137
  parse_list_models_response response, slug, capabilities
138
138
  end
139
139
 
140
- def discover_offerings(live: false, **filters)
140
+ def discover_offerings(live: false, raise_on_unreachable: false, **filters)
141
141
  return filter_cached_offerings(Array(@cached_offerings), filters) unless live
142
142
 
143
143
  provider_health = health(live:)
@@ -148,8 +148,10 @@ module Legion
148
148
  offering_from_model(model, health: provider_health)
149
149
  end
150
150
  @cached_offerings
151
- rescue Faraday::ConnectionFailed => e
151
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
152
152
  log.warn("[#{slug}] instance=#{provider_instance_id} unreachable: #{e.message}")
153
+ raise if raise_on_unreachable
154
+
153
155
  []
154
156
  end
155
157
 
@@ -224,9 +226,16 @@ module Legion
224
226
  end
225
227
 
226
228
  def cache_enabled?
227
- return false unless config.respond_to?(:llm_cache_enabled)
229
+ explicit = config.llm_cache_enabled if config.respond_to?(:llm_cache_enabled)
230
+
231
+ unless explicit.nil?
232
+ log.debug { "[#{slug}] cache_enabled? source=per_provider value=#{explicit}" }
233
+ return explicit == true
234
+ end
228
235
 
229
- config.llm_cache_enabled == true
236
+ global = global_prompt_caching_enabled?
237
+ log.debug { "[#{slug}] cache_enabled? source=global value=#{global}" }
238
+ global
230
239
  end
231
240
 
232
241
  def cache_control_prefix_tokens
@@ -528,6 +537,14 @@ module Legion
528
537
 
529
538
  private
530
539
 
540
+ def global_prompt_caching_enabled?
541
+ return false unless defined?(Legion::Settings)
542
+
543
+ Legion::Settings.dig(:llm, :prompt_caching, :enabled) == true
544
+ rescue StandardError
545
+ false
546
+ end
547
+
531
548
  def model_detail_cache_key(model_name)
532
549
  tier = offering_tier
533
550
  instance_key = cache_instance_key
@@ -11,10 +11,19 @@ module Legion
11
11
  embed: [%i[keyreq text], %i[keyreq model]],
12
12
  image: [%i[keyreq prompt], %i[keyreq model]],
13
13
  list_models: [%i[key live], %i[keyrest filters]],
14
- discover_offerings: [%i[key live], %i[keyrest filters]],
14
+ discover_offerings: [%i[key live], %i[key raise_on_unreachable], %i[keyrest filters]],
15
15
  health: [%i[key live]],
16
16
  count_tokens: [%i[keyreq messages], %i[keyreq model], %i[key params]]
17
17
  }.freeze
18
+
19
+ # Tools passed to chat/stream_chat must support Canonical::ToolDefinition objects.
20
+ # Providers must not crash on Data.define instances (not Hashes).
21
+ TOOL_SUPPORT_CONTRACT = <<~DOC
22
+ - chat and stream_chat accept keyword `tools:` (Hash<name, tool_object>)
23
+ - tools may be Canonical::ToolDefinition, Hash, or legacy Lex::Llm::Tool
24
+ - Renderers must use Canonical::ToolSchema.extract(tool) for schema access
25
+ - discover_offerings(live: true, raise_on_unreachable: true) raises on transport failure
26
+ DOC
18
27
  end
19
28
  end
20
29
  end
@@ -57,6 +57,27 @@ module Legion
57
57
  )
58
58
  end
59
59
 
60
+ # Flush any text still held in the untagged-preamble buffer as a final
61
+ # streamed chunk. Without this, short responses that match the
62
+ # untagged-reasoning heuristic (e.g. starting with "I", "The", "Let me")
63
+ # and never hit a double newline are buffered for the entire stream and
64
+ # the caller's block never receives a single delta.
65
+ def flush_pending_chunk
66
+ return nil if @untagged_preamble_buffer.empty?
67
+
68
+ @last_content_delta = +''
69
+ @last_thinking_delta = +''
70
+ flush_pending_untagged_preamble_into_deltas
71
+ return nil if @last_content_delta.empty? && @last_thinking_delta.empty?
72
+
73
+ Chunk.new(
74
+ role: :assistant,
75
+ content: @last_content_delta.empty? ? nil : @last_content_delta,
76
+ thinking: @last_thinking_delta.empty? ? nil : Thinking.build(text: @last_thinking_delta),
77
+ model_id: model_id
78
+ )
79
+ end
80
+
60
81
  def to_message(response)
61
82
  flush_pending_untagged_preamble
62
83
 
@@ -233,6 +254,24 @@ module Legion
233
254
  @untagged_preamble_pending = false
234
255
  end
235
256
 
257
+ # Same as flush_pending_untagged_preamble, but also records the flushed
258
+ # text in the per-chunk delta accumulators so flush_pending_chunk can
259
+ # surface it to the streaming block.
260
+ def flush_pending_untagged_preamble_into_deltas
261
+ content, thinking = Responses::ThinkingExtractor.extract_untagged_preamble(@untagged_preamble_buffer)
262
+ if thinking
263
+ @content << content
264
+ @last_content_delta << content
265
+ @thinking_text << thinking
266
+ @last_thinking_delta << thinking
267
+ else
268
+ @content << @untagged_preamble_buffer
269
+ @last_content_delta << @untagged_preamble_buffer
270
+ end
271
+ @untagged_preamble_buffer = +''
272
+ @untagged_preamble_pending = false
273
+ end
274
+
236
275
  def append_thinking_from_chunk(chunk)
237
276
  thinking = chunk.thinking
238
277
  return unless thinking
@@ -24,6 +24,11 @@ module Legion
24
24
  end
25
25
  end
26
26
 
27
+ # Release any text held by the untagged-preamble heuristic so short
28
+ # responses still stream at least one delta to the caller.
29
+ final_chunk = accumulator.flush_pending_chunk
30
+ block&.call(final_chunk) if final_chunk
31
+
27
32
  message = accumulator.to_message(response)
28
33
  log.debug { "Stream completed: #{message.content}" }
29
34
  message
@@ -31,6 +36,8 @@ module Legion
31
36
 
32
37
  def build_stream_callback(accumulator, block)
33
38
  proc do |chunk|
39
+ next unless chunk
40
+
34
41
  accumulator.add chunk
35
42
  filtered = accumulator.filtered_chunk(chunk)
36
43
  block.call(filtered) if filtered
@@ -39,7 +46,10 @@ module Legion
39
46
 
40
47
  def handle_stream(&block)
41
48
  build_on_data_handler do |data|
42
- block.call(build_chunk(data)) if data.is_a?(Hash)
49
+ next unless data.is_a?(Hash)
50
+
51
+ chunk = build_chunk(data)
52
+ block.call(chunk) if chunk
43
53
  end
44
54
  end
45
55
 
@@ -183,7 +193,7 @@ module Legion
183
193
  def build_stream_error_response(parsed_data, env, status)
184
194
  error_status = status || env&.status || 500
185
195
 
186
- if faraday_1?
196
+ if faraday_1? || env.nil?
187
197
  Struct.new(:body, :status).new(parsed_data, error_status)
188
198
  else
189
199
  env.merge(body: parsed_data, status: error_status)
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Llm
6
- VERSION = '0.5.0'
6
+ VERSION = '0.5.1'
7
7
  end
8
8
  end
9
9
  end
@@ -9,7 +9,7 @@ RSpec.describe Legion::Extensions::Llm::Canonical::ToolDefinition do
9
9
 
10
10
  expect(tool.name).to eq('search')
11
11
  expect(tool.description).to eq('Search the web')
12
- expect(tool.parameters).to eq({})
12
+ expect(tool.parameters).to eq(type: 'object', properties: {})
13
13
  expect(tool.source).to eq({ type: :builtin })
14
14
  end
15
15
 
@@ -113,7 +113,7 @@ RSpec.describe Legion::Extensions::Llm::Canonical::ToolDefinition do
113
113
  entry = {
114
114
  name: 'custom',
115
115
  description: 'Custom tool',
116
- parameters: {},
116
+ parameters: { type: 'object', properties: { name: { type: 'string' } } },
117
117
  extension: 'custom-ext'
118
118
  }
119
119
  tool = described_class.from_registry_entry(entry)
@@ -160,6 +160,53 @@ RSpec.describe Legion::Extensions::Llm::Canonical::ToolDefinition do
160
160
  end
161
161
  end
162
162
 
163
+ describe '.normalize_parameters' do
164
+ it 'injects type: object when a schema has properties but no type' do
165
+ schema = { properties: { task: { type: 'string' } }, required: ['task'] }
166
+ result = described_class.normalize_parameters(schema)
167
+ expect(result[:type]).to eq('object')
168
+ expect(result[:properties]).to eq(task: { type: 'string' })
169
+ expect(result[:required]).to eq(['task'])
170
+ end
171
+
172
+ it 'passes schemas with an explicit type through unchanged' do
173
+ schema = { type: 'object', properties: { a: { type: 'string' } } }
174
+ expect(described_class.normalize_parameters(schema)).to eq(schema)
175
+ end
176
+
177
+ it 'wraps a bare property map under type:object/properties' do
178
+ expect(described_class.normalize_parameters(location: { type: 'string' }))
179
+ .to eq(type: 'object', properties: { location: { type: 'string' } })
180
+ end
181
+
182
+ it 'returns an empty object schema for nil/empty' do
183
+ expect(described_class.normalize_parameters(nil)).to eq(type: 'object', properties: {})
184
+ expect(described_class.normalize_parameters({})).to eq(type: 'object', properties: {})
185
+ end
186
+
187
+ it 'symbolizes top-level string keys' do
188
+ result = described_class.normalize_parameters('properties' => { 'a' => { 'type' => 'string' } })
189
+ expect(result[:type]).to eq('object')
190
+ expect(result).to have_key(:properties)
191
+ end
192
+
193
+ it 'leaves composite schemas (oneOf etc.) without forcing type' do
194
+ schema = { oneOf: [{ type: 'string' }, { type: 'integer' }] }
195
+ expect(described_class.normalize_parameters(schema)).to eq(schema)
196
+ end
197
+
198
+ it 'normalizes parameters at construction via .build' do
199
+ td = described_class.build(name: 'multi_agent_v1',
200
+ parameters: { properties: { task: { type: 'string' } } })
201
+ expect(td.parameters[:type]).to eq('object')
202
+ end
203
+
204
+ it 'normalizes nil parameters to empty object schema via .build' do
205
+ td = described_class.build(name: 'bare_tool')
206
+ expect(td.parameters).to eq(type: 'object', properties: {})
207
+ end
208
+ end
209
+
163
210
  describe 'round-trip' do
164
211
  it 'preserves values through from_hash/to_h' do
165
212
  original = { name: 'search', description: 'Search', parameters: { type: 'object' } }
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Llm::Canonical::ToolSchema do
6
+ let(:full_schema) { { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] } }
7
+ let(:canonical_tool) do
8
+ Legion::Extensions::Llm::Canonical::ToolDefinition.build(
9
+ name: 'get_weather', description: 'Weather lookup', parameters: full_schema
10
+ )
11
+ end
12
+
13
+ describe '.extract' do
14
+ it 'extracts from a Canonical::ToolDefinition' do
15
+ result = described_class.extract(canonical_tool)
16
+ expect(result[:type]).to eq('object')
17
+ expect(result[:properties]).to eq(city: { type: 'string' })
18
+ end
19
+
20
+ it 'extracts from a Hash with :parameters' do
21
+ result = described_class.extract({ parameters: full_schema })
22
+ expect(result[:type]).to eq('object')
23
+ expect(result[:properties]).to eq(city: { type: 'string' })
24
+ end
25
+
26
+ it 'extracts from a Hash with :input_schema' do
27
+ result = described_class.extract({ input_schema: full_schema })
28
+ expect(result[:type]).to eq('object')
29
+ expect(result[:properties]).to eq(city: { type: 'string' })
30
+ end
31
+
32
+ it 'extracts from a Hash with :params_schema' do
33
+ result = described_class.extract({ params_schema: full_schema })
34
+ expect(result[:type]).to eq('object')
35
+ expect(result[:properties]).to eq(city: { type: 'string' })
36
+ end
37
+
38
+ it 'extracts from an object responding to params_schema' do
39
+ tool = Struct.new(:params_schema).new(full_schema)
40
+ result = described_class.extract(tool)
41
+ expect(result[:type]).to eq('object')
42
+ expect(result[:properties]).to eq(city: { type: 'string' })
43
+ end
44
+
45
+ it 'returns empty object schema for nil' do
46
+ expect(described_class.extract(nil)).to eq(type: 'object', properties: {})
47
+ end
48
+
49
+ it 'returns empty object schema for empty hash' do
50
+ expect(described_class.extract({})).to eq(type: 'object', properties: {})
51
+ end
52
+ end
53
+
54
+ describe '.tool_name' do
55
+ it 'gets name from Canonical::ToolDefinition' do
56
+ expect(described_class.tool_name(canonical_tool)).to eq('get_weather')
57
+ end
58
+
59
+ it 'gets name from Hash' do
60
+ expect(described_class.tool_name({ name: 'foo' })).to eq('foo')
61
+ end
62
+ end
63
+
64
+ describe '.tool_description' do
65
+ it 'gets description from Canonical::ToolDefinition' do
66
+ expect(described_class.tool_description(canonical_tool)).to eq('Weather lookup')
67
+ end
68
+
69
+ it 'gets description from Hash' do
70
+ expect(described_class.tool_description({ description: 'bar' })).to eq('bar')
71
+ end
72
+ end
73
+
74
+ describe 'ToolDefinition compatibility readers' do
75
+ it 'params_schema returns normalized parameters' do
76
+ expect(canonical_tool.params_schema).to eq(full_schema)
77
+ end
78
+
79
+ it 'input_schema aliases params_schema' do
80
+ expect(canonical_tool.input_schema).to eq(canonical_tool.params_schema)
81
+ end
82
+ end
83
+ end
@@ -114,6 +114,46 @@ RSpec.describe Legion::Extensions::Llm::Canonical::Usage do
114
114
  end
115
115
  end
116
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
+
117
157
  describe 'round-trip' do
118
158
  it 'preserves values through from_hash/to_h' do
119
159
  original = { input_tokens: 100, output_tokens: 50, cache_read_tokens: 10 }
@@ -265,5 +265,168 @@ RSpec.shared_examples 'a canonical client translator' do |translator_class|
265
265
  end
266
266
  end
267
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
268
431
  end
269
432
  # rubocop:enable Lint/NonLocalExitFromIterator
@@ -0,0 +1,43 @@
1
+ {
2
+ "description": "G24 round-trip — when a client sends back a history that contains a completed server-side exchange (Claude: server_tool_use+server_tool_result blocks; Codex: completed function_call+function_call_output items), the client translator must parse them into canonical messages losslessly so the next turn rendering still attributes the call to the assistant and its result to the tool role. Tools array is empty because the server-side exchange is already closed; this request is just continuing the conversation.",
3
+ "id": "req_g24_continuation_001",
4
+ "messages": [
5
+ {
6
+ "role": "user",
7
+ "content": "what legionio tools do you have available?"
8
+ },
9
+ {
10
+ "role": "assistant",
11
+ "content": "I called the legion_list_all_tools tool.",
12
+ "tool_calls": [
13
+ {
14
+ "id": "call_legion_001",
15
+ "name": "legion_list_all_tools",
16
+ "arguments": {"filter": "all"},
17
+ "source": "registry",
18
+ "status": "success",
19
+ "result": "tools: legion_list_all_tools, legion_apollo_search"
20
+ }
21
+ ]
22
+ },
23
+ {
24
+ "role": "tool",
25
+ "tool_call_id": "call_legion_001",
26
+ "content": "tools: legion_list_all_tools, legion_apollo_search"
27
+ },
28
+ {
29
+ "role": "user",
30
+ "content": "thanks — anything else?"
31
+ }
32
+ ],
33
+ "system": null,
34
+ "tools": null,
35
+ "params": null,
36
+ "thinking": null,
37
+ "stream": false,
38
+ "conversation_id": "conv_g24_001",
39
+ "routing": {},
40
+ "metadata": {
41
+ "g24": "continuation"
42
+ }
43
+ }
@@ -0,0 +1,29 @@
1
+ {
2
+ "description": "G24 — server-executed LegionIO tool. The provider returned tool_use, the executor ran the tool registry-side, and the canonical response carries both the call AND its result so client translators can surface it as a completed exchange (Claude: server_tool_use+server_tool_result, Codex: completed function_call+function_call_output non-actionable). stop_reason is end_turn because the server-side exchange is closed by the time the canonical response is built.",
3
+ "text": "I called the legion_list_all_tools tool and here is the list.",
4
+ "thinking": null,
5
+ "tool_calls": [
6
+ {
7
+ "id": "call_legion_001",
8
+ "exchange_id": "exch_legion_001",
9
+ "name": "legion_list_all_tools",
10
+ "arguments": {
11
+ "filter": "all"
12
+ },
13
+ "source": "registry",
14
+ "status": "success",
15
+ "result": "tools: legion_list_all_tools, legion_apollo_search, legion_runner_dispatch",
16
+ "duration_ms": 42
17
+ }
18
+ ],
19
+ "usage": {
20
+ "input_tokens": 50,
21
+ "output_tokens": 22
22
+ },
23
+ "stop_reason": "end_turn",
24
+ "model": "test-model-1",
25
+ "routing": {},
26
+ "metadata": {
27
+ "g24": true
28
+ }
29
+ }
@@ -0,0 +1,52 @@
1
+ {
2
+ "description": "G24 — streaming sequence for a server-executed LegionIO tool. The server-side exchange streams as tool_call_delta(s) (with the result attached on close) followed by trailing text from round-2. Client translators must turn this into completed (non-actionable) blocks: Claude server_tool_use+server_tool_result blocks, Codex completed function_call items with results visible.",
3
+ "request_id": "req_stream_legion_001",
4
+ "chunks": [
5
+ {
6
+ "request_id": "req_stream_legion_001",
7
+ "index": 0,
8
+ "type": "tool_call_delta",
9
+ "tool_call": {
10
+ "id": "call_legion_stream_001",
11
+ "name": "legion_list_all_tools",
12
+ "arguments": {"filter": "all"},
13
+ "source": "registry",
14
+ "status": "running"
15
+ }
16
+ },
17
+ {
18
+ "request_id": "req_stream_legion_001",
19
+ "index": 0,
20
+ "type": "tool_call_delta",
21
+ "tool_call": {
22
+ "id": "call_legion_stream_001",
23
+ "name": "legion_list_all_tools",
24
+ "arguments": {"filter": "all"},
25
+ "source": "registry",
26
+ "status": "success",
27
+ "result": "tools: legion_list_all_tools, legion_apollo_search"
28
+ }
29
+ },
30
+ {
31
+ "request_id": "req_stream_legion_001",
32
+ "index": 1,
33
+ "type": "text_delta",
34
+ "delta": "I called the "
35
+ },
36
+ {
37
+ "request_id": "req_stream_legion_001",
38
+ "index": 1,
39
+ "type": "text_delta",
40
+ "delta": "legion_list_all_tools tool."
41
+ },
42
+ {
43
+ "request_id": "req_stream_legion_001",
44
+ "type": "done",
45
+ "stop_reason": "end_turn",
46
+ "usage": {
47
+ "input_tokens": 50,
48
+ "output_tokens": 25
49
+ }
50
+ }
51
+ ]
52
+ }
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shared examples for provider tool rendering conformance.
4
+ # Include in any provider gem that renders tools to prove it handles
5
+ # canonical ToolDefinition objects, Hashes, and schemas without double-wrap.
6
+ #
7
+ # Usage in provider specs:
8
+ # it_behaves_like 'canonical tool rendering', described_class.new(...)
9
+ #
10
+ RSpec.shared_examples 'canonical tool rendering' do # rubocop:disable RSpec/MultipleMemoizedHelpers
11
+ let(:full_schema) { { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] } }
12
+
13
+ let(:canonical_tool) do
14
+ Legion::Extensions::Llm::Canonical::ToolDefinition.build(
15
+ name: 'get_weather',
16
+ description: 'Weather lookup',
17
+ parameters: full_schema
18
+ )
19
+ end
20
+
21
+ let(:hash_tool_parameters) do
22
+ { name: 'get_weather', description: 'Weather lookup', parameters: full_schema }
23
+ end
24
+
25
+ let(:hash_tool_input_schema) do
26
+ { name: 'get_weather', description: 'Weather lookup', input_schema: full_schema }
27
+ end
28
+
29
+ let(:hash_tool_params_schema) do
30
+ { name: 'get_weather', description: 'Weather lookup', params_schema: full_schema }
31
+ end
32
+
33
+ let(:tools_map) { { 'get_weather' => canonical_tool } }
34
+
35
+ describe 'canonical tool rendering' do # rubocop:disable RSpec/MultipleMemoizedHelpers
36
+ it 'accepts Canonical::ToolDefinition without raising' do
37
+ expect { render_tools(tools_map) }.not_to raise_error
38
+ end
39
+
40
+ it 'renders schema with top-level type: object' do
41
+ rendered = render_tools(tools_map)
42
+ schema = extract_rendered_schema(rendered)
43
+ expect(schema[:type] || schema['type']).to eq('object')
44
+ end
45
+
46
+ it 'renders properties without double-wrap' do
47
+ rendered = render_tools(tools_map)
48
+ schema = extract_rendered_schema(rendered)
49
+ props = schema[:properties] || schema['properties']
50
+ expect(props).not_to have_key(:type)
51
+ expect(props).not_to have_key('type')
52
+ expect(props).to have_key(:city).or have_key('city')
53
+ end
54
+
55
+ it 'renders Hash tools with :parameters identically to canonical' do
56
+ map = { 'get_weather' => hash_tool_parameters }
57
+ rendered = render_tools(map)
58
+ schema = extract_rendered_schema(rendered)
59
+ expect(schema[:type] || schema['type']).to eq('object')
60
+ expect(schema[:properties] || schema['properties']).to have_key(:city).or have_key('city')
61
+ end
62
+
63
+ it 'renders Hash tools with :input_schema identically' do
64
+ map = { 'get_weather' => hash_tool_input_schema }
65
+ rendered = render_tools(map)
66
+ schema = extract_rendered_schema(rendered)
67
+ expect(schema[:type] || schema['type']).to eq('object')
68
+ end
69
+
70
+ it 'renders Hash tools with :params_schema identically' do
71
+ map = { 'get_weather' => hash_tool_params_schema }
72
+ rendered = render_tools(map)
73
+ schema = extract_rendered_schema(rendered)
74
+ expect(schema[:type] || schema['type']).to eq('object')
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/llm/provider/open_ai_compatible'
5
+ require 'legion/extensions/llm/message'
6
+ require 'legion/extensions/llm/tool_call'
7
+
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.
26
+ RSpec.describe Legion::Extensions::Llm::Provider::OpenAICompatible do
27
+ let(:host_class) do
28
+ Class.new do
29
+ include Legion::Extensions::Llm::Provider::OpenAICompatible
30
+
31
+ # Expose the otherwise-private renderer so we can test it directly
32
+ # without booting a full provider stack.
33
+ public :format_openai_tool_calls
34
+ end
35
+ end
36
+
37
+ let(:host) { host_class.new }
38
+
39
+ let(:tool_call) do
40
+ Legion::Extensions::Llm::ToolCall.new(
41
+ id: 'call_001',
42
+ name: 'legion_list_all_tools',
43
+ arguments: { 'filter' => 'all' }
44
+ )
45
+ end
46
+
47
+ it 'renders an Array<ToolCall> (the post-canonical shape)' do
48
+ rendered = host.format_openai_tool_calls([tool_call])
49
+ expect(rendered).to be_an(Array)
50
+ expect(rendered.size).to eq(1)
51
+ expect(rendered.first[:id]).to eq('call_001')
52
+ expect(rendered.first[:type]).to eq('function')
53
+ 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')
61
+ end
62
+
63
+ it 'returns nil when tool_calls is nil or empty' do
64
+ expect(host.format_openai_tool_calls(nil)).to be_nil
65
+ expect(host.format_openai_tool_calls([])).to be_nil
66
+ expect(host.format_openai_tool_calls({})).to be_nil
67
+ end
68
+ end
@@ -542,25 +542,46 @@ RSpec.describe Legion::Extensions::Llm::Provider do
542
542
  end
543
543
 
544
544
  describe '#cache_enabled?' do
545
- it 'returns true when llm_cache_enabled is true in config' do
545
+ it 'returns true when llm_cache_enabled is true in config (per-provider explicit)' do
546
546
  Legion::Extensions::Llm.config.llm_cache_enabled = true
547
547
  provider = provider_class.new(Legion::Extensions::Llm.config)
548
548
 
549
549
  expect(provider.cache_enabled?).to be true
550
550
  end
551
551
 
552
- it 'returns false when llm_cache_enabled is false in config' do
552
+ it 'returns false when llm_cache_enabled is false in config (per-provider explicit)' do
553
553
  Legion::Extensions::Llm.config.llm_cache_enabled = false
554
554
  provider = provider_class.new(Legion::Extensions::Llm.config)
555
555
 
556
556
  expect(provider.cache_enabled?).to be false
557
557
  end
558
558
 
559
- it 'returns false when llm_cache_enabled is not set on config' do
559
+ it 'falls through to global prompt_caching.enabled when config has no llm_cache_enabled' do
560
560
  config = { request_timeout: 30, max_retries: 0, retry_interval: 0, retry_backoff_factor: 0,
561
561
  retry_interval_randomness: 0 }
562
562
  provider = provider_class.new(config)
563
563
 
564
+ Legion::Settings.loader.settings[:llm] ||= {}
565
+ Legion::Settings.loader.settings[:llm][:prompt_caching] = { enabled: true }
566
+ expect(provider.cache_enabled?).to be true
567
+ end
568
+
569
+ it 'returns false when global prompt_caching.enabled is false and no per-provider setting' do
570
+ config = { request_timeout: 30, max_retries: 0, retry_interval: 0, retry_backoff_factor: 0,
571
+ retry_interval_randomness: 0 }
572
+ provider = provider_class.new(config)
573
+
574
+ Legion::Settings.loader.settings[:llm] ||= {}
575
+ Legion::Settings.loader.settings[:llm][:prompt_caching] = { enabled: false }
576
+ expect(provider.cache_enabled?).to be false
577
+ end
578
+
579
+ it 'per-provider setting overrides global when both present' do
580
+ Legion::Extensions::Llm.config.llm_cache_enabled = false
581
+ provider = provider_class.new(Legion::Extensions::Llm.config)
582
+
583
+ Legion::Settings.loader.settings[:llm] ||= {}
584
+ Legion::Settings.loader.settings[:llm][:prompt_caching] = { enabled: true }
564
585
  expect(provider.cache_enabled?).to be false
565
586
  end
566
587
  end
@@ -100,4 +100,56 @@ RSpec.describe Legion::Extensions::Llm::StreamAccumulator do
100
100
  expect(message.thinking).to be_nil
101
101
  end
102
102
  end
103
+
104
+ describe '#filtered_chunk' do
105
+ it 'returns a chunk for a plain text delta' do
106
+ accumulator = described_class.new
107
+ chunk = Legion::Extensions::Llm::Chunk.new(role: :assistant, content: 'Hello', model_id: 'm')
108
+
109
+ accumulator.add(chunk)
110
+ filtered = accumulator.filtered_chunk(chunk)
111
+
112
+ expect(filtered).not_to be_nil
113
+ expect(filtered.content.to_s).to eq('Hello')
114
+ end
115
+ end
116
+
117
+ describe '#flush_pending_chunk' do
118
+ it 'returns nil when nothing is buffered' do
119
+ accumulator = described_class.new
120
+ chunk = Legion::Extensions::Llm::Chunk.new(role: :assistant, content: 'Hello', model_id: 'm')
121
+ accumulator.add(chunk)
122
+
123
+ expect(accumulator.flush_pending_chunk).to be_nil
124
+ end
125
+
126
+ it 'releases text held by the untagged-preamble heuristic as a final delta' do
127
+ accumulator = described_class.new
128
+ deltas = []
129
+ ['I can help', ' with that.'].each do |text|
130
+ chunk = Legion::Extensions::Llm::Chunk.new(role: :assistant, content: text, model_id: 'm')
131
+ accumulator.add(chunk)
132
+ filtered = accumulator.filtered_chunk(chunk)
133
+ deltas << filtered.content.to_s if filtered
134
+ end
135
+
136
+ final = accumulator.flush_pending_chunk
137
+ deltas << final.content.to_s if final
138
+ message = accumulator.to_message(nil)
139
+
140
+ expect(deltas.join).to eq('I can help with that.')
141
+ expect(message.content).to eq('I can help with that.')
142
+ end
143
+
144
+ it 'does not double-append flushed text in to_message' do
145
+ accumulator = described_class.new
146
+ chunk = Legion::Extensions::Llm::Chunk.new(role: :assistant, content: 'The answer', model_id: 'm')
147
+ accumulator.add(chunk)
148
+
149
+ accumulator.flush_pending_chunk
150
+ message = accumulator.to_message(nil)
151
+
152
+ expect(message.content).to eq('The answer')
153
+ end
154
+ end
103
155
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-llm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - LegionIO
@@ -270,6 +270,7 @@ files:
270
270
  - lib/legion/extensions/llm/canonical/thinking.rb
271
271
  - lib/legion/extensions/llm/canonical/tool_call.rb
272
272
  - lib/legion/extensions/llm/canonical/tool_definition.rb
273
+ - lib/legion/extensions/llm/canonical/tool_schema.rb
273
274
  - lib/legion/extensions/llm/canonical/usage.rb
274
275
  - lib/legion/extensions/llm/chat.rb
275
276
  - lib/legion/extensions/llm/chunk.rb
@@ -353,6 +354,7 @@ files:
353
354
  - spec/legion/extensions/llm/canonical/thinking_spec.rb
354
355
  - spec/legion/extensions/llm/canonical/tool_call_spec.rb
355
356
  - spec/legion/extensions/llm/canonical/tool_definition_spec.rb
357
+ - spec/legion/extensions/llm/canonical/tool_schema_spec.rb
356
358
  - spec/legion/extensions/llm/canonical/usage_spec.rb
357
359
  - spec/legion/extensions/llm/configuration_spec.rb
358
360
  - spec/legion/extensions/llm/conformance/client_translator_examples.rb
@@ -364,11 +366,14 @@ files:
364
366
  - spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json
365
367
  - spec/legion/extensions/llm/conformance/fixtures/canonical_metering_audit_events.json
366
368
  - spec/legion/extensions/llm/conformance/fixtures/canonical_params_mapping_request.json
369
+ - spec/legion/extensions/llm/conformance/fixtures/canonical_server_tool_continuation_request.json
370
+ - spec/legion/extensions/llm/conformance/fixtures/canonical_server_tool_use_response.json
367
371
  - spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_request.json
368
372
  - spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_response.json
369
373
  - spec/legion/extensions/llm/conformance/fixtures/canonical_stop_reason_matrix.json
370
374
  - spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_accumulated_response.json
371
375
  - spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_error_chunks.json
376
+ - spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_server_tool_chunks.json
372
377
  - spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_text_chunks.json
373
378
  - spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_thinking_chunks.json
374
379
  - spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_tool_call_chunks.json
@@ -378,6 +383,7 @@ files:
378
383
  - spec/legion/extensions/llm/conformance/fixtures/canonical_tool_results_continuation_request.json
379
384
  - spec/legion/extensions/llm/conformance/fixtures/canonical_tool_use_response.json
380
385
  - spec/legion/extensions/llm/conformance/fixtures/canonical_tools_request.json
386
+ - spec/legion/extensions/llm/conformance/provider_tool_rendering_examples.rb
381
387
  - spec/legion/extensions/llm/conformance/provider_translator_examples.rb
382
388
  - spec/legion/extensions/llm/connection_logging_spec.rb
383
389
  - spec/legion/extensions/llm/connection_retry_spec.rb
@@ -394,6 +400,7 @@ files:
394
400
  - spec/legion/extensions/llm/model/info_spec.rb
395
401
  - spec/legion/extensions/llm/models_spec.rb
396
402
  - spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb
403
+ - spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb
397
404
  - spec/legion/extensions/llm/provider_contract_spec.rb
398
405
  - spec/legion/extensions/llm/provider_settings_spec.rb
399
406
  - spec/legion/extensions/llm/provider_spec.rb