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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/lib/legion/extensions/llm/canonical/message.rb +16 -3
- data/lib/legion/extensions/llm/canonical/tool_definition.rb +26 -1
- data/lib/legion/extensions/llm/canonical/tool_schema.rb +46 -0
- data/lib/legion/extensions/llm/canonical/usage.rb +13 -0
- data/lib/legion/extensions/llm/canonical.rb +1 -0
- data/lib/legion/extensions/llm/error.rb +2 -0
- data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +9 -8
- data/lib/legion/extensions/llm/provider.rb +21 -4
- data/lib/legion/extensions/llm/provider_contract.rb +10 -1
- data/lib/legion/extensions/llm/stream_accumulator.rb +39 -0
- data/lib/legion/extensions/llm/streaming.rb +12 -2
- data/lib/legion/extensions/llm/version.rb +1 -1
- data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +49 -2
- data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +83 -0
- data/spec/legion/extensions/llm/canonical/usage_spec.rb +40 -0
- data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +163 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_server_tool_continuation_request.json +43 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_server_tool_use_response.json +29 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_server_tool_chunks.json +52 -0
- data/spec/legion/extensions/llm/conformance/provider_tool_rendering_examples.rb +77 -0
- data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +68 -0
- data/spec/legion/extensions/llm/provider_spec.rb +24 -3
- data/spec/legion/extensions/llm/stream_accumulator_spec.rb +52 -0
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2aef21677943bb1b621d6defad35f76e3ca8195ea971b172e7924c7b83774f62
|
|
4
|
+
data.tar.gz: 8a46fdb9ead39ffb08335e019e4623844ad91de4af1cb30960802a39ecf75ab0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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?(
|
|
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)
|
|
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
|
-
|
|
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:
|
|
104
|
-
parameters:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -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
|
data/spec/legion/extensions/llm/conformance/fixtures/canonical_server_tool_continuation_request.json
ADDED
|
@@ -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
|
+
}
|
data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_server_tool_chunks.json
ADDED
|
@@ -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 '
|
|
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.
|
|
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
|