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.
Files changed (125) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +13 -2
  3. data/B1b-conformance-kit.md +79 -0
  4. data/CHANGELOG.md +27 -0
  5. data/lex-llm.gemspec +2 -3
  6. data/lib/legion/extensions/llm/attachment.rb +1 -1
  7. data/lib/legion/extensions/llm/canonical/chunk.rb +184 -0
  8. data/lib/legion/extensions/llm/canonical/content_block.rb +126 -0
  9. data/lib/legion/extensions/llm/canonical/message.rb +138 -0
  10. data/lib/legion/extensions/llm/canonical/params.rb +61 -0
  11. data/lib/legion/extensions/llm/canonical/request.rb +117 -0
  12. data/lib/legion/extensions/llm/canonical/response.rb +124 -0
  13. data/lib/legion/extensions/llm/canonical/thinking.rb +81 -0
  14. data/lib/legion/extensions/llm/canonical/tool_call.rb +134 -0
  15. data/lib/legion/extensions/llm/canonical/tool_definition.rb +98 -0
  16. data/lib/legion/extensions/llm/canonical/tool_schema.rb +46 -0
  17. data/lib/legion/extensions/llm/canonical/usage.rb +74 -0
  18. data/lib/legion/extensions/llm/canonical.rb +50 -0
  19. data/lib/legion/extensions/llm/chat.rb +3 -5
  20. data/lib/legion/extensions/llm/connection.rb +5 -1
  21. data/lib/legion/extensions/llm/error.rb +5 -7
  22. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +1 -3
  23. data/lib/legion/extensions/llm/fleet/provider_responder.rb +1 -3
  24. data/lib/legion/extensions/llm/fleet/token_validator.rb +1 -3
  25. data/lib/legion/extensions/llm/model/info.rb +4 -6
  26. data/lib/legion/extensions/llm/models.rb +3 -3
  27. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +9 -4
  28. data/lib/legion/extensions/llm/provider.rb +21 -4
  29. data/lib/legion/extensions/llm/provider_contract.rb +10 -1
  30. data/lib/legion/extensions/llm/routing/lane_key.rb +1 -3
  31. data/lib/legion/extensions/llm/stream_accumulator.rb +40 -1
  32. data/lib/legion/extensions/llm/streaming.rb +13 -5
  33. data/lib/legion/extensions/llm/tool.rb +1 -3
  34. data/lib/legion/extensions/llm/version.rb +1 -1
  35. data/lib/legion/extensions/llm.rb +118 -35
  36. data/spec/fixtures/ruby.mp3 +0 -0
  37. data/spec/fixtures/ruby.mp4 +0 -0
  38. data/spec/fixtures/ruby.png +0 -0
  39. data/spec/fixtures/ruby.txt +1 -0
  40. data/spec/fixtures/ruby.wav +0 -0
  41. data/spec/fixtures/ruby.xml +1 -0
  42. data/spec/fixtures/sample.pdf +0 -0
  43. data/spec/legion/extensions/llm/agent_spec.rb +179 -0
  44. data/spec/legion/extensions/llm/attachment_spec.rb +25 -0
  45. data/spec/legion/extensions/llm/auto_registration_spec.rb +38 -0
  46. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +285 -0
  47. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +179 -0
  48. data/spec/legion/extensions/llm/canonical/message_spec.rb +203 -0
  49. data/spec/legion/extensions/llm/canonical/params_spec.rb +159 -0
  50. data/spec/legion/extensions/llm/canonical/request_spec.rb +174 -0
  51. data/spec/legion/extensions/llm/canonical/response_spec.rb +234 -0
  52. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +151 -0
  53. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +191 -0
  54. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +221 -0
  55. data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +83 -0
  56. data/spec/legion/extensions/llm/canonical/usage_spec.rb +178 -0
  57. data/spec/legion/extensions/llm/configuration_spec.rb +38 -0
  58. data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +432 -0
  59. data/spec/legion/extensions/llm/conformance/conformance.rb +51 -0
  60. data/spec/legion/extensions/llm/conformance/echo_translator.rb +56 -0
  61. data/spec/legion/extensions/llm/conformance/echo_translator_spec.rb +13 -0
  62. data/spec/legion/extensions/llm/conformance/fixtures/canonical_empty_response.json +13 -0
  63. data/spec/legion/extensions/llm/conformance/fixtures/canonical_error_response.json +19 -0
  64. data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +81 -0
  65. data/spec/legion/extensions/llm/conformance/fixtures/canonical_metering_audit_events.json +101 -0
  66. data/spec/legion/extensions/llm/conformance/fixtures/canonical_params_mapping_request.json +21 -0
  67. data/spec/legion/extensions/llm/conformance/fixtures/canonical_server_tool_continuation_request.json +43 -0
  68. data/spec/legion/extensions/llm/conformance/fixtures/canonical_server_tool_use_response.json +29 -0
  69. data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_request.json +13 -0
  70. data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_response.json +13 -0
  71. data/spec/legion/extensions/llm/conformance/fixtures/canonical_stop_reason_matrix.json +36 -0
  72. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_accumulated_response.json +20 -0
  73. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_error_chunks.json +26 -0
  74. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_server_tool_chunks.json +52 -0
  75. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_text_chunks.json +33 -0
  76. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_thinking_chunks.json +42 -0
  77. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_tool_call_chunks.json +41 -0
  78. data/spec/legion/extensions/llm/conformance/fixtures/canonical_system_prompt_request.json +14 -0
  79. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +18 -0
  80. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_response.json +17 -0
  81. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_results_continuation_request.json +75 -0
  82. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_use_response.json +25 -0
  83. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tools_request.json +34 -0
  84. data/spec/legion/extensions/llm/conformance/provider_tool_rendering_examples.rb +77 -0
  85. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +390 -0
  86. data/spec/legion/extensions/llm/connection_logging_spec.rb +53 -0
  87. data/spec/legion/extensions/llm/connection_retry_spec.rb +36 -0
  88. data/spec/legion/extensions/llm/context_spec.rb +127 -0
  89. data/spec/legion/extensions/llm/credential_sources_spec.rb +468 -0
  90. data/spec/legion/extensions/llm/error_middleware_spec.rb +102 -0
  91. data/spec/legion/extensions/llm/error_spec.rb +87 -0
  92. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +120 -0
  93. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +163 -0
  94. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +128 -0
  95. data/spec/legion/extensions/llm/fleet_messages_spec.rb +402 -0
  96. data/spec/legion/extensions/llm/gemspec_spec.rb +25 -0
  97. data/spec/legion/extensions/llm/message_spec.rb +64 -0
  98. data/spec/legion/extensions/llm/model/info_spec.rb +222 -0
  99. data/spec/legion/extensions/llm/models_spec.rb +104 -0
  100. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +203 -0
  101. data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +68 -0
  102. data/spec/legion/extensions/llm/provider_contract_spec.rb +60 -0
  103. data/spec/legion/extensions/llm/provider_settings_spec.rb +76 -0
  104. data/spec/legion/extensions/llm/provider_spec.rb +613 -0
  105. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +68 -0
  106. data/spec/legion/extensions/llm/registry_publisher_spec.rb +22 -0
  107. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +75 -0
  108. data/spec/legion/extensions/llm/responses/thinking_extractor_spec.rb +75 -0
  109. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +222 -0
  110. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +50 -0
  111. data/spec/legion/extensions/llm/routing/registry_event_spec.rb +120 -0
  112. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +155 -0
  113. data/spec/legion/extensions/llm/streaming_spec.rb +108 -0
  114. data/spec/legion/extensions/llm/tool_spec.rb +94 -0
  115. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +60 -0
  116. data/spec/legion/extensions/llm/utils_spec.rb +113 -0
  117. data/spec/legion/extensions/llm_base_contract_spec.rb +110 -0
  118. data/spec/legion/extensions/llm_extension_spec.rb +78 -0
  119. data/spec/legion/extensions/llm_root_spec.rb +51 -0
  120. data/spec/spec_helper.rb +24 -0
  121. data/spec/support/fake_llm_provider.rb +148 -0
  122. data/spec/support/llm_configuration.rb +21 -0
  123. data/spec/support/rspec_configuration.rb +19 -0
  124. data/spec/support/simplecov_configuration.rb +20 -0
  125. 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