lex-llm 0.4.18 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +13 -2
- data/B1b-conformance-kit.md +79 -0
- data/CHANGELOG.md +27 -0
- data/lex-llm.gemspec +2 -3
- data/lib/legion/extensions/llm/attachment.rb +1 -1
- data/lib/legion/extensions/llm/canonical/chunk.rb +184 -0
- data/lib/legion/extensions/llm/canonical/content_block.rb +126 -0
- data/lib/legion/extensions/llm/canonical/message.rb +138 -0
- data/lib/legion/extensions/llm/canonical/params.rb +61 -0
- data/lib/legion/extensions/llm/canonical/request.rb +117 -0
- data/lib/legion/extensions/llm/canonical/response.rb +124 -0
- data/lib/legion/extensions/llm/canonical/thinking.rb +81 -0
- data/lib/legion/extensions/llm/canonical/tool_call.rb +134 -0
- data/lib/legion/extensions/llm/canonical/tool_definition.rb +98 -0
- data/lib/legion/extensions/llm/canonical/tool_schema.rb +46 -0
- data/lib/legion/extensions/llm/canonical/usage.rb +74 -0
- data/lib/legion/extensions/llm/canonical.rb +50 -0
- data/lib/legion/extensions/llm/chat.rb +3 -5
- data/lib/legion/extensions/llm/connection.rb +5 -1
- data/lib/legion/extensions/llm/error.rb +5 -7
- data/lib/legion/extensions/llm/fleet/envelope_validation.rb +1 -3
- data/lib/legion/extensions/llm/fleet/provider_responder.rb +1 -3
- data/lib/legion/extensions/llm/fleet/token_validator.rb +1 -3
- data/lib/legion/extensions/llm/model/info.rb +4 -6
- data/lib/legion/extensions/llm/models.rb +3 -3
- data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +9 -4
- data/lib/legion/extensions/llm/provider.rb +21 -4
- data/lib/legion/extensions/llm/provider_contract.rb +10 -1
- data/lib/legion/extensions/llm/routing/lane_key.rb +1 -3
- data/lib/legion/extensions/llm/stream_accumulator.rb +40 -1
- data/lib/legion/extensions/llm/streaming.rb +13 -5
- data/lib/legion/extensions/llm/tool.rb +1 -3
- data/lib/legion/extensions/llm/version.rb +1 -1
- data/lib/legion/extensions/llm.rb +118 -35
- data/spec/fixtures/ruby.mp3 +0 -0
- data/spec/fixtures/ruby.mp4 +0 -0
- data/spec/fixtures/ruby.png +0 -0
- data/spec/fixtures/ruby.txt +1 -0
- data/spec/fixtures/ruby.wav +0 -0
- data/spec/fixtures/ruby.xml +1 -0
- data/spec/fixtures/sample.pdf +0 -0
- data/spec/legion/extensions/llm/agent_spec.rb +179 -0
- data/spec/legion/extensions/llm/attachment_spec.rb +25 -0
- data/spec/legion/extensions/llm/auto_registration_spec.rb +38 -0
- data/spec/legion/extensions/llm/canonical/chunk_spec.rb +285 -0
- data/spec/legion/extensions/llm/canonical/content_block_spec.rb +179 -0
- data/spec/legion/extensions/llm/canonical/message_spec.rb +203 -0
- data/spec/legion/extensions/llm/canonical/params_spec.rb +159 -0
- data/spec/legion/extensions/llm/canonical/request_spec.rb +174 -0
- data/spec/legion/extensions/llm/canonical/response_spec.rb +234 -0
- data/spec/legion/extensions/llm/canonical/thinking_spec.rb +151 -0
- data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +191 -0
- data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +221 -0
- data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +83 -0
- data/spec/legion/extensions/llm/canonical/usage_spec.rb +178 -0
- data/spec/legion/extensions/llm/configuration_spec.rb +38 -0
- data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +432 -0
- data/spec/legion/extensions/llm/conformance/conformance.rb +51 -0
- data/spec/legion/extensions/llm/conformance/echo_translator.rb +56 -0
- data/spec/legion/extensions/llm/conformance/echo_translator_spec.rb +13 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_empty_response.json +13 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_error_response.json +19 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +81 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_metering_audit_events.json +101 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_params_mapping_request.json +21 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_server_tool_continuation_request.json +43 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_server_tool_use_response.json +29 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_request.json +13 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_response.json +13 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_stop_reason_matrix.json +36 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_accumulated_response.json +20 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_error_chunks.json +26 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_server_tool_chunks.json +52 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_text_chunks.json +33 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_thinking_chunks.json +42 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_tool_call_chunks.json +41 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_system_prompt_request.json +14 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +18 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_response.json +17 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_results_continuation_request.json +75 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_use_response.json +25 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_tools_request.json +34 -0
- data/spec/legion/extensions/llm/conformance/provider_tool_rendering_examples.rb +77 -0
- data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +390 -0
- data/spec/legion/extensions/llm/connection_logging_spec.rb +53 -0
- data/spec/legion/extensions/llm/connection_retry_spec.rb +36 -0
- data/spec/legion/extensions/llm/context_spec.rb +127 -0
- data/spec/legion/extensions/llm/credential_sources_spec.rb +468 -0
- data/spec/legion/extensions/llm/error_middleware_spec.rb +102 -0
- data/spec/legion/extensions/llm/error_spec.rb +87 -0
- data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +120 -0
- data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +163 -0
- data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +128 -0
- data/spec/legion/extensions/llm/fleet_messages_spec.rb +402 -0
- data/spec/legion/extensions/llm/gemspec_spec.rb +25 -0
- data/spec/legion/extensions/llm/message_spec.rb +64 -0
- data/spec/legion/extensions/llm/model/info_spec.rb +222 -0
- data/spec/legion/extensions/llm/models_spec.rb +104 -0
- data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +203 -0
- data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +68 -0
- data/spec/legion/extensions/llm/provider_contract_spec.rb +60 -0
- data/spec/legion/extensions/llm/provider_settings_spec.rb +76 -0
- data/spec/legion/extensions/llm/provider_spec.rb +613 -0
- data/spec/legion/extensions/llm/registry_event_builder_spec.rb +68 -0
- data/spec/legion/extensions/llm/registry_publisher_spec.rb +22 -0
- data/spec/legion/extensions/llm/responses/response_objects_spec.rb +75 -0
- data/spec/legion/extensions/llm/responses/thinking_extractor_spec.rb +75 -0
- data/spec/legion/extensions/llm/routing/model_offering_spec.rb +222 -0
- data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +50 -0
- data/spec/legion/extensions/llm/routing/registry_event_spec.rb +120 -0
- data/spec/legion/extensions/llm/stream_accumulator_spec.rb +155 -0
- data/spec/legion/extensions/llm/streaming_spec.rb +108 -0
- data/spec/legion/extensions/llm/tool_spec.rb +94 -0
- data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +60 -0
- data/spec/legion/extensions/llm/utils_spec.rb +113 -0
- data/spec/legion/extensions/llm_base_contract_spec.rb +110 -0
- data/spec/legion/extensions/llm_extension_spec.rb +78 -0
- data/spec/legion/extensions/llm_root_spec.rb +51 -0
- data/spec/spec_helper.rb +24 -0
- data/spec/support/fake_llm_provider.rb +148 -0
- data/spec/support/llm_configuration.rb +21 -0
- data/spec/support/rspec_configuration.rb +19 -0
- data/spec/support/simplecov_configuration.rb +20 -0
- metadata +103 -15
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "req_system_prompt_001",
|
|
3
|
+
"system": "You are a helpful assistant that always responds in haiku form.",
|
|
4
|
+
"messages": [
|
|
5
|
+
{
|
|
6
|
+
"role": "user",
|
|
7
|
+
"content": [{ "type": "text", "text": "Tell me about Ruby." }]
|
|
8
|
+
}
|
|
9
|
+
],
|
|
10
|
+
"params": {
|
|
11
|
+
"max_tokens": 512
|
|
12
|
+
},
|
|
13
|
+
"stream": false
|
|
14
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "req_thinking_001",
|
|
3
|
+
"messages": [
|
|
4
|
+
{
|
|
5
|
+
"role": "user",
|
|
6
|
+
"content": [{ "type": "text", "text": "Explain quantum computing in simple terms." }]
|
|
7
|
+
}
|
|
8
|
+
],
|
|
9
|
+
"thinking": {
|
|
10
|
+
"effort": "high",
|
|
11
|
+
"budget": 4096
|
|
12
|
+
},
|
|
13
|
+
"params": {
|
|
14
|
+
"max_tokens": 2048,
|
|
15
|
+
"max_thinking_tokens": 4096
|
|
16
|
+
},
|
|
17
|
+
"stream": false
|
|
18
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"text": "Quantum computing uses quantum bits (qubits) that can represent both 0 and 1 simultaneously, unlike classical bits.",
|
|
3
|
+
"thinking": {
|
|
4
|
+
"content": "I need to explain quantum computing simply. Start with the basic difference between classical and quantum bits, then mention superposition and entanglement as key concepts.",
|
|
5
|
+
"signature": "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9"
|
|
6
|
+
},
|
|
7
|
+
"tool_calls": [],
|
|
8
|
+
"usage": {
|
|
9
|
+
"input_tokens": 15,
|
|
10
|
+
"output_tokens": 45,
|
|
11
|
+
"thinking_tokens": 120
|
|
12
|
+
},
|
|
13
|
+
"stop_reason": "end_turn",
|
|
14
|
+
"model": "test-model-thinking-1",
|
|
15
|
+
"routing": {},
|
|
16
|
+
"metadata": {}
|
|
17
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Full multi-turn history: mixed client/server tool calls + thinking block per G4",
|
|
3
|
+
"id": "req_tool_continuation_001",
|
|
4
|
+
"system": "You are a helpful assistant.",
|
|
5
|
+
"messages": [
|
|
6
|
+
{
|
|
7
|
+
"role": "user",
|
|
8
|
+
"content": [{"type": "text", "text": "What's the weather in San Francisco, and summarize today's news about it?"}]
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"role": "assistant",
|
|
12
|
+
"content": [
|
|
13
|
+
{"type": "text", "text": ""},
|
|
14
|
+
{"type": "tool_use", "id": "call_weather_01", "name": "get_weather", "input": {"location": "San Francisco, CA", "unit": "fahrenheit"}}
|
|
15
|
+
],
|
|
16
|
+
"tool_calls": [
|
|
17
|
+
{
|
|
18
|
+
"id": "call_weather_01",
|
|
19
|
+
"exchange_id": "exch_turn1_001",
|
|
20
|
+
"name": "get_weather",
|
|
21
|
+
"arguments": {"location": "San Francisco, CA", "unit": "fahrenheit"},
|
|
22
|
+
"source": "client",
|
|
23
|
+
"status": "success",
|
|
24
|
+
"result": {"temperature": 68, "unit": "fahrenheit", "conditions": "partly cloudy"}
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"id": "call_summarize_01",
|
|
28
|
+
"exchange_id": "exch_turn1_002",
|
|
29
|
+
"name": "summarize_article",
|
|
30
|
+
"arguments": {"topic": "San Francisco weather today"},
|
|
31
|
+
"source": "registry",
|
|
32
|
+
"status": "success",
|
|
33
|
+
"result": {"headline": "Partly cloudy skies, mild temperatures continue in Bay Area", "summary": "San Francisco enjoys its typical spring weather..."}
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"role": "tool",
|
|
39
|
+
"tool_call_id": "call_weather_01",
|
|
40
|
+
"content": [{"type": "tool_result", "tool_use_id": "call_weather_01", "text": "{\"temperature\":68,\"unit\":\"fahrenheit\",\"conditions\":\"partly cloudy\"}"}]
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"role": "tool",
|
|
44
|
+
"tool_call_id": "call_summarize_01",
|
|
45
|
+
"content": [{"type": "tool_result", "tool_use_id": "call_summarize_01", "text": "{\"headline\":\"SF weather report\",\"summary\":\"Partly cloudy, 68F\"}"}]
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"tools": {
|
|
49
|
+
"get_weather": {
|
|
50
|
+
"name": "get_weather",
|
|
51
|
+
"description": "Get current weather for a location",
|
|
52
|
+
"parameters": {
|
|
53
|
+
"type": "object",
|
|
54
|
+
"properties": {
|
|
55
|
+
"location": {"type": "string"},
|
|
56
|
+
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
|
|
57
|
+
},
|
|
58
|
+
"required": ["location"]
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"summarize_article": {
|
|
62
|
+
"name": "summarize_article",
|
|
63
|
+
"description": "Summarize a news article by topic",
|
|
64
|
+
"parameters": {
|
|
65
|
+
"type": "object",
|
|
66
|
+
"properties": {
|
|
67
|
+
"topic": {"type": "string"}
|
|
68
|
+
},
|
|
69
|
+
"required": ["topic"]
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
"params": {"max_tokens": 1024},
|
|
74
|
+
"stream": false
|
|
75
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"text": "",
|
|
3
|
+
"thinking": null,
|
|
4
|
+
"tool_calls": [
|
|
5
|
+
{
|
|
6
|
+
"id": "call_abc123",
|
|
7
|
+
"exchange_id": "exch_001",
|
|
8
|
+
"name": "get_weather",
|
|
9
|
+
"arguments": {
|
|
10
|
+
"location": "San Francisco, CA",
|
|
11
|
+
"unit": "fahrenheit"
|
|
12
|
+
},
|
|
13
|
+
"source": "client",
|
|
14
|
+
"status": "pending"
|
|
15
|
+
}
|
|
16
|
+
],
|
|
17
|
+
"usage": {
|
|
18
|
+
"input_tokens": 45,
|
|
19
|
+
"output_tokens": 28
|
|
20
|
+
},
|
|
21
|
+
"stop_reason": "tool_use",
|
|
22
|
+
"model": "test-model-1",
|
|
23
|
+
"routing": {},
|
|
24
|
+
"metadata": {}
|
|
25
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "req_tools_001",
|
|
3
|
+
"messages": [
|
|
4
|
+
{
|
|
5
|
+
"role": "user",
|
|
6
|
+
"content": [{ "type": "text", "text": "What's the weather in San Francisco?" }]
|
|
7
|
+
}
|
|
8
|
+
],
|
|
9
|
+
"tools": {
|
|
10
|
+
"get_weather": {
|
|
11
|
+
"name": "get_weather",
|
|
12
|
+
"description": "Get the current weather in a given location",
|
|
13
|
+
"parameters": {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"properties": {
|
|
16
|
+
"location": {
|
|
17
|
+
"type": "string",
|
|
18
|
+
"description": "The city and state, e.g. San Francisco, CA"
|
|
19
|
+
},
|
|
20
|
+
"unit": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"enum": ["celsius", "fahrenheit"],
|
|
23
|
+
"description": "The unit of temperature"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"required": ["location"]
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"params": {
|
|
31
|
+
"max_tokens": 1024
|
|
32
|
+
},
|
|
33
|
+
"stream": false
|
|
34
|
+
}
|
|
@@ -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,390 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Shared examples for canonical provider translator conformance.
|
|
4
|
+
#
|
|
5
|
+
# Every provider translator must implement:
|
|
6
|
+
# - render_request(canonical_request) => wire Hash
|
|
7
|
+
# - parse_response(wire_hash) => Canonical::Response
|
|
8
|
+
# - parse_chunk(raw_chunk) => Canonical::Chunk | nil
|
|
9
|
+
# - capabilities => Hash
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# it_behaves_like 'a canonical provider translator', MyTranslatorClass
|
|
13
|
+
|
|
14
|
+
RSpec.shared_examples 'a canonical provider translator' do |translator_class|
|
|
15
|
+
let(:translator) { translator_class.new }
|
|
16
|
+
let(:canonical) { Legion::Extensions::Llm::Canonical }
|
|
17
|
+
let(:conformance) { Canonical::Conformance }
|
|
18
|
+
|
|
19
|
+
describe '#capabilities' do
|
|
20
|
+
it 'returns a Hash' do
|
|
21
|
+
expect(translator.capabilities).to be_a(Hash)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'includes a :provider key' do
|
|
25
|
+
expect(translator.capabilities).to have_key(:provider)
|
|
26
|
+
expect(translator.capabilities[:provider]).to be_a(String)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
describe '#render_request' do
|
|
31
|
+
context 'with a simple text request' do
|
|
32
|
+
let(:canonical_req) do
|
|
33
|
+
canonical::Request.from_hash(conformance.fixture_symbolized('canonical_simple_text_request'))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'renders a non-empty wire payload' do
|
|
37
|
+
wire = translator.render_request(canonical_req)
|
|
38
|
+
expect(wire).to be_a(Hash)
|
|
39
|
+
expect(wire).not_to be_empty
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it 'includes model or messages' do
|
|
43
|
+
wire = translator.render_request(canonical_req)
|
|
44
|
+
expect(wire.keys & %i[model messages]).not_to be_empty
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'preserves message content' do
|
|
48
|
+
wire = translator.render_request(canonical_req)
|
|
49
|
+
wire_str = wire.to_s
|
|
50
|
+
expect(wire_str).to include('how are you')
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
context 'with a system prompt' do
|
|
55
|
+
let(:canonical_req) do
|
|
56
|
+
canonical::Request.from_hash(conformance.fixture_symbolized('canonical_system_prompt_request'))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it 'renders the system prompt in provider-appropriate format' do
|
|
60
|
+
wire = translator.render_request(canonical_req)
|
|
61
|
+
wire_str = wire.to_s.downcase
|
|
62
|
+
expect(wire_str).to match(/helpful|haiku/)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
context 'with parameter mapping' do
|
|
67
|
+
let(:canonical_req) do
|
|
68
|
+
canonical::Request.from_hash(conformance.fixture_symbolized('canonical_params_mapping_request'))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it 'renders params in provider-appropriate format' do
|
|
72
|
+
wire = translator.render_request(canonical_req)
|
|
73
|
+
expect(wire).to be_a(Hash)
|
|
74
|
+
wire_str = wire.to_s
|
|
75
|
+
expect(wire_str).to match(/[0-9]+/)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
context 'with tools defined' do
|
|
80
|
+
let(:canonical_req) do
|
|
81
|
+
canonical::Request.from_hash(conformance.fixture_symbolized('canonical_tools_request'))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it 'renders tools in provider format' do
|
|
85
|
+
wire = translator.render_request(canonical_req)
|
|
86
|
+
wire_str = wire.to_s.downcase
|
|
87
|
+
expect(wire_str).to include('get_weather')
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it 'includes tool parameters' do
|
|
91
|
+
wire = translator.render_request(canonical_req)
|
|
92
|
+
wire_str = wire.to_s.downcase
|
|
93
|
+
expect(wire_str).to include('location')
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
context 'with tool results continuation (multi-turn)' do
|
|
98
|
+
let(:canonical_req) do
|
|
99
|
+
canonical::Request.from_hash(
|
|
100
|
+
conformance.fixture_symbolized('canonical_tool_results_continuation_request')
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it 'renders the full conversation history' do
|
|
105
|
+
wire = translator.render_request(canonical_req)
|
|
106
|
+
wire_str = wire.to_s.downcase
|
|
107
|
+
expect(wire_str).to include('weather')
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it 'renders mixed client and registry tool calls' do
|
|
111
|
+
wire = translator.render_request(canonical_req)
|
|
112
|
+
wire_str = wire.to_s.downcase
|
|
113
|
+
expect(wire_str).to include('get_weather')
|
|
114
|
+
expect(wire_str).to include('summarize')
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
context 'with thinking enabled' do
|
|
119
|
+
let(:canonical_req) do
|
|
120
|
+
canonical::Request.from_hash(conformance.fixture_symbolized('canonical_thinking_request'))
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
it 'renders thinking configuration' do
|
|
124
|
+
wire = translator.render_request(canonical_req)
|
|
125
|
+
wire_str = wire.to_s.downcase
|
|
126
|
+
expect(wire_str).to match(/think|reason|budget|effort/)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
context 'with streaming request' do
|
|
131
|
+
let(:canonical_req) do
|
|
132
|
+
canonical::Request.from_hash(
|
|
133
|
+
conformance.fixture_symbolized('canonical_simple_text_request').merge({ 'stream' => true })
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it 'renders with streaming indicator' do
|
|
138
|
+
wire = translator.render_request(canonical_req)
|
|
139
|
+
wire_str = wire.to_s.downcase
|
|
140
|
+
expect(wire_str).to include('stream')
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# parse_response: tests translator.parse_response(wire_hash) => Canonical::Response
|
|
146
|
+
# For self-test (echo translator), wire == canonical-form (symbolized).
|
|
147
|
+
# Real provider translators convert provider-specific wire format to canonical.
|
|
148
|
+
describe '#parse_response' do
|
|
149
|
+
context 'with a simple text response' do
|
|
150
|
+
let(:wire_response) { conformance.fixture_symbolized('canonical_simple_text_response') }
|
|
151
|
+
|
|
152
|
+
it 'returns a Canonical::Response' do
|
|
153
|
+
response = translator.parse_response(wire_response)
|
|
154
|
+
expect(response).to be_a(canonical::Response)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
it 'preserves text content' do
|
|
158
|
+
response = translator.parse_response(wire_response)
|
|
159
|
+
expect(response.text).to eq("I'm doing well, thank you for asking!")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it 'sets stop_reason' do
|
|
163
|
+
response = translator.parse_response(wire_response)
|
|
164
|
+
expect(response.stop_reason).to eq(:end_turn)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
it 'includes usage data' do
|
|
168
|
+
response = translator.parse_response(wire_response)
|
|
169
|
+
expect(response.usage).to be_a(canonical::Usage)
|
|
170
|
+
expect(response.usage.input_tokens).to eq(12)
|
|
171
|
+
expect(response.usage.output_tokens).to eq(10)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
context 'with a tool use response' do
|
|
176
|
+
let(:wire_response) { conformance.fixture_symbolized('canonical_tool_use_response') }
|
|
177
|
+
|
|
178
|
+
it 'parses tool calls correctly' do
|
|
179
|
+
response = translator.parse_response(wire_response)
|
|
180
|
+
expect(response).to be_a(canonical::Response)
|
|
181
|
+
expect(response.tool_call?).to be true
|
|
182
|
+
expect(response.tool_calls).to be_an(Array)
|
|
183
|
+
expect(response.tool_calls.first).to be_a(canonical::ToolCall)
|
|
184
|
+
expect(response.stop_reason).to eq(:tool_use)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
it 'preserves tool call arguments as a Hash' do
|
|
188
|
+
response = translator.parse_response(wire_response)
|
|
189
|
+
args = response.tool_calls.first.arguments
|
|
190
|
+
expect(args).to be_a(Hash)
|
|
191
|
+
expect(args[:location]).to eq('San Francisco, CA')
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
it 'has no text when response is tool-only' do
|
|
195
|
+
response = translator.parse_response(wire_response)
|
|
196
|
+
expect(response.text).to eq('')
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
context 'with thinking response' do
|
|
201
|
+
let(:wire_response) { conformance.fixture_symbolized('canonical_thinking_response') }
|
|
202
|
+
|
|
203
|
+
it 'parses thinking content and signature' do
|
|
204
|
+
response = translator.parse_response(wire_response)
|
|
205
|
+
expect(response.thinking).to be_a(canonical::Thinking)
|
|
206
|
+
expect(response.thinking.content).to include('quantum')
|
|
207
|
+
expect(response.thinking.signature).to be_a(String)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
it 'preserves thinking tokens in usage' do
|
|
211
|
+
response = translator.parse_response(wire_response)
|
|
212
|
+
expect(response.usage).to be_a(canonical::Usage)
|
|
213
|
+
expect(response.usage.thinking_tokens).to eq(120)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
context 'with error response' do
|
|
218
|
+
let(:wire_response) { conformance.fixture_symbolized('canonical_error_response') }
|
|
219
|
+
|
|
220
|
+
it 'parses error responses without crashing' do
|
|
221
|
+
response = translator.parse_response(wire_response)
|
|
222
|
+
expect(response).to be_a(canonical::Response)
|
|
223
|
+
expect(response.error?).to be true
|
|
224
|
+
expect(response.stop_reason).to eq(:error)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
it 'preserves error metadata' do
|
|
228
|
+
response = translator.parse_response(wire_response)
|
|
229
|
+
expect(response.metadata).to have_key(:error)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
context 'with empty response' do
|
|
234
|
+
let(:wire_response) { conformance.fixture_symbolized('canonical_empty_response') }
|
|
235
|
+
|
|
236
|
+
it 'handles empty responses gracefully' do
|
|
237
|
+
response = translator.parse_response(wire_response)
|
|
238
|
+
expect(response).to be_a(canonical::Response)
|
|
239
|
+
expect(response.text).to eq('')
|
|
240
|
+
expect(response.tool_calls).to eq([])
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
describe '#parse_chunk' do
|
|
246
|
+
context 'with text delta chunks' do
|
|
247
|
+
let(:stream_fixture) { conformance.fixture('canonical_streaming_text_chunks') }
|
|
248
|
+
let(:chunks) { stream_fixture['chunks'] }
|
|
249
|
+
|
|
250
|
+
it 'parses text delta chunks' do
|
|
251
|
+
text_chunk = chunks.find { |c| c['type'] == 'text_delta' }
|
|
252
|
+
parsed = translator.parse_chunk(text_chunk)
|
|
253
|
+
expect(parsed).to be_a(canonical::Chunk)
|
|
254
|
+
expect(parsed.type).to eq(:text_delta)
|
|
255
|
+
expect(parsed.delta).to be_a(String)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
it 'parses the done chunk' do
|
|
259
|
+
done_chunk = chunks.find { |c| c['type'] == 'done' }
|
|
260
|
+
parsed = translator.parse_chunk(done_chunk)
|
|
261
|
+
expect(parsed).to be_a(canonical::Chunk)
|
|
262
|
+
expect(parsed.type).to eq(:done)
|
|
263
|
+
expect(parsed.stop_reason).to eq(:end_turn)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
context 'with thinking delta chunks' do
|
|
268
|
+
let(:stream_fixture) { conformance.fixture('canonical_streaming_thinking_chunks') }
|
|
269
|
+
let(:chunks) { stream_fixture['chunks'] }
|
|
270
|
+
|
|
271
|
+
it 'parses thinking delta chunks' do
|
|
272
|
+
thinking_chunk = chunks.find { |c| c['type'] == 'thinking_delta' }
|
|
273
|
+
parsed = translator.parse_chunk(thinking_chunk)
|
|
274
|
+
expect(parsed).to be_a(canonical::Chunk)
|
|
275
|
+
expect(parsed.type).to eq(:thinking_delta)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
it 'preserves signature on thinking deltas' do
|
|
279
|
+
sig_chunk = chunks.find { |c| c['type'] == 'thinking_delta' && !c['signature'].nil? }
|
|
280
|
+
next if sig_chunk.nil?
|
|
281
|
+
|
|
282
|
+
parsed = translator.parse_chunk(sig_chunk)
|
|
283
|
+
expect(parsed.signature).to be_a(String)
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
context 'with tool call delta chunks' do
|
|
288
|
+
let(:stream_fixture) { conformance.fixture('canonical_streaming_tool_call_chunks') }
|
|
289
|
+
let(:chunks) { stream_fixture['chunks'] }
|
|
290
|
+
|
|
291
|
+
it 'parses tool call delta chunks' do
|
|
292
|
+
tool_chunk = chunks.find { |c| c['type'] == 'tool_call_delta' }
|
|
293
|
+
parsed = translator.parse_chunk(tool_chunk)
|
|
294
|
+
expect(parsed).to be_a(canonical::Chunk)
|
|
295
|
+
expect(parsed.type).to eq(:tool_call_delta)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
it 'preserves tool call identity across chunks' do
|
|
299
|
+
tool_chunks = chunks.select { |c| c['type'] == 'tool_call_delta' }
|
|
300
|
+
parsed_chunks = tool_chunks.map { |c| translator.parse_chunk(c) }
|
|
301
|
+
ids = parsed_chunks.map { |c| c.tool_call&.id }
|
|
302
|
+
expect(ids.uniq.length).to eq(1)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
context 'with error chunk' do
|
|
307
|
+
let(:stream_fixture) { conformance.fixture('canonical_streaming_error_chunks') }
|
|
308
|
+
let(:chunks) { stream_fixture['chunks'] }
|
|
309
|
+
|
|
310
|
+
it 'parses error chunks' do
|
|
311
|
+
error_chunk = chunks.find { |c| c['type'] == 'error' }
|
|
312
|
+
parsed = translator.parse_chunk(error_chunk)
|
|
313
|
+
expect(parsed).to be_a(canonical::Chunk)
|
|
314
|
+
expect(parsed.type).to eq(:error)
|
|
315
|
+
expect(parsed.error?).to be true
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
describe 'stop_reason mapping' do
|
|
321
|
+
let(:matrix) { conformance.fixture_symbolized('canonical_stop_reason_matrix') }
|
|
322
|
+
|
|
323
|
+
it 'maps all canonical stop reasons' do
|
|
324
|
+
canonical::Response::STOP_REASONS.each do |reason|
|
|
325
|
+
resp = canonical::Response.build(stop_reason: reason, text: 'test')
|
|
326
|
+
expect(resp.stop_reason).to eq(reason)
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
it 'rejects invalid stop reasons' do
|
|
331
|
+
expect { canonical::Response.build(stop_reason: :invalid_reason, text: 'test') }
|
|
332
|
+
.to raise_error(ArgumentError, /Invalid stop_reason/)
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
describe 'round-trip consistency' do
|
|
337
|
+
it 'accumulated chunks equal non-streaming response for text' do
|
|
338
|
+
stream_fixture = conformance.fixture('canonical_streaming_text_chunks')
|
|
339
|
+
chunks = stream_fixture['chunks']
|
|
340
|
+
|
|
341
|
+
accumulated_text = ''
|
|
342
|
+
final_stop_reason = nil
|
|
343
|
+
|
|
344
|
+
chunks.each do |raw_chunk|
|
|
345
|
+
chunk = translator.parse_chunk(raw_chunk)
|
|
346
|
+
next unless chunk
|
|
347
|
+
|
|
348
|
+
case chunk.type
|
|
349
|
+
when :text_delta
|
|
350
|
+
accumulated_text += chunk.delta
|
|
351
|
+
when :done
|
|
352
|
+
final_stop_reason = chunk.stop_reason
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
expect(accumulated_text).to eq('Hello, world! How can I help you today?')
|
|
357
|
+
expect(final_stop_reason).to eq(:end_turn)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
it 'accumulated chunks equal non-streaming response for thinking + text' do
|
|
361
|
+
stream_fixture = conformance.fixture('canonical_streaming_thinking_chunks')
|
|
362
|
+
chunks = stream_fixture['chunks']
|
|
363
|
+
|
|
364
|
+
accumulated_thinking = ''
|
|
365
|
+
accumulated_text = ''
|
|
366
|
+
final_stop_reason = nil
|
|
367
|
+
final_signature = nil
|
|
368
|
+
|
|
369
|
+
chunks.each do |raw_chunk|
|
|
370
|
+
chunk = translator.parse_chunk(raw_chunk)
|
|
371
|
+
next unless chunk
|
|
372
|
+
|
|
373
|
+
case chunk.type
|
|
374
|
+
when :thinking_delta
|
|
375
|
+
accumulated_thinking += chunk.delta
|
|
376
|
+
final_signature = chunk.signature if chunk.signature
|
|
377
|
+
when :text_delta
|
|
378
|
+
accumulated_text += chunk.delta
|
|
379
|
+
when :done
|
|
380
|
+
final_stop_reason = chunk.stop_reason
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
expect(accumulated_thinking).not_to be_empty
|
|
385
|
+
expect(accumulated_text).not_to be_empty
|
|
386
|
+
expect(final_signature).to be_a(String)
|
|
387
|
+
expect(final_stop_reason).to eq(:end_turn)
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
end
|