lex-llm-openai 0.3.11 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 570527e7fb80e2b480eafd36844264bbb59ccfbc11fc4424213cc57cc1e539ea
4
- data.tar.gz: 9ec953777f2f1b2a0c91dd63c697c5760c9264b6577ded5a50d39ae6ced7d991
3
+ metadata.gz: 1e0bcb314dd98bb72b108f3f92a05ceb40dd8fc125e3972712674537e5baa2a9
4
+ data.tar.gz: b9fd92e6d25f2e9949fa5b5e1a7d79f8d9ed04a197b873a0b754d309dc30aef6
5
5
  SHA512:
6
- metadata.gz: 077412edd3903af264268d9863f7ce69f522940d12abc47fb63b9f948ef607d5b39da14a8d5bf42912fd2143494577882aaf34c2656fd4df6d91ed3dadf4d09d
7
- data.tar.gz: f6b2c1f473e83012b548d80763320b8d66263ade5438b13743b35b7bd4716bb72f2bf333a03679608f79dd520682b5c6d3f1778c7c4197eecde8869aa0338f57
6
+ metadata.gz: 4e3886489304539fda6fe6566aa7f596682bcd480944822eebf473ff638a0c3f47b8f88243444afaf3e2ccd58a6b1699c4918b627eb859fcef7a409884536294
7
+ data.tar.gz: efaaea62fada60fe7f598426c59028d6461ad6afb5c935d9e3a77b43b22a6d9ae0e76577ae75a9b5e96b40f6f9bff8cc1dbf881a082fab3a88c93f00516bfd67
data/.rubocop.yml CHANGED
@@ -13,12 +13,14 @@ Metrics/BlockLength:
13
13
  - "*.gemspec"
14
14
  - spec/**/*
15
15
  Metrics/ClassLength:
16
- Max: 200
16
+ Max: 350
17
17
  Metrics/ModuleLength:
18
18
  Max: 110
19
19
  Metrics/MethodLength:
20
20
  Enabled: false
21
21
  RSpec/ExampleLength:
22
- Max: 10
22
+ Max: 25
23
23
  RSpec/MultipleExpectations:
24
24
  Enabled: false
25
+ RSpec/SubjectStub:
26
+ Enabled: false
data/CHANGELOG.md CHANGED
@@ -1,6 +1,15 @@
1
1
  # Changelog
2
2
 
3
- ## 0.3.11 - 2026-06-05
3
+ ## 0.4.1 2026-06-10
4
+
5
+ - Canonical translator (`Translator`): `render_request`, `parse_response`, `parse_chunk`, `capabilities` — provider-boundary contract per N×N routing Amendment A
6
+ - Conformance kit integration — loads shared `it_behaves_like 'a canonical provider translator` from `lex-llm` gem spec/ per B1b consumer pattern (54 kit scenarios passing)
7
+ - `Provider#translator` exposes a lazy `Translator` instance; provider becomes transport + config
8
+ - G18 parameter mapping: max_tokens, temperature, top_p, stop_sequences/stop, seed, penalties, response_format mapped 1:1; top_k dropped with debug log; max_thinking_tokens → thinking config
9
+ - G18 stop_reason matrix: stop → end_turn, tool_calls → tool_use, length → max_tokens, content_filter → content_filter
10
+ - Require `lex-llm >= 0.5.0` (canonical types, conformance kit, Zeitwerk removal)
11
+
12
+ ## 0.3.11 — 2026-06-05
4
13
 
5
14
  - Fix missing top-level documentation comment in `DiscoveryRefresh` actor (RuboCop `Style/Documentation`).
6
15
 
data/Gemfile CHANGED
@@ -3,10 +3,9 @@
3
3
  source 'https://rubygems.org'
4
4
 
5
5
  group :test do
6
- llm_base_path = ENV.fetch('LEX_LLM_PATH', File.expand_path('../lex-llm', __dir__))
7
6
  transport_path = ENV.fetch('LEGION_TRANSPORT_PATH', File.expand_path('../../legion-transport', __dir__))
8
7
  gem 'legion-transport', path: transport_path if File.directory?(transport_path)
9
- gem 'lex-llm', path: llm_base_path if File.directory?(llm_base_path)
8
+ # lex-llm resolved from rubygems (>= 0.5.0) via gemspec - no local path dep
10
9
  end
11
10
 
12
11
  gemspec
@@ -27,5 +27,5 @@ Gem::Specification.new do |spec|
27
27
  spec.add_dependency 'legion-logging', '>= 1.3.2'
28
28
  spec.add_dependency 'legion-settings', '>= 1.3.14'
29
29
  spec.add_dependency 'legion-transport', '>= 1.4.14'
30
- spec.add_dependency 'lex-llm', '>= 0.4.3'
30
+ spec.add_dependency 'lex-llm', '>= 0.5.0'
31
31
  end
@@ -166,7 +166,13 @@ module Legion
166
166
  end
167
167
 
168
168
  def api_base
169
- config.openai_api_base || settings[:endpoint] || 'https://api.openai.com'
169
+ config.openai_api_base || settings.dig(:instances, :default, :endpoint) || 'https://api.openai.com'
170
+ end
171
+
172
+ # Canonical translator instance - the provider boundary contract.
173
+ # Created lazily; delegate translation to the Translator class.
174
+ def translator
175
+ @translator ||= Translator.new(api_base: api_base, headers: headers)
170
176
  end
171
177
 
172
178
  def headers
@@ -0,0 +1,437 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Llm
6
+ module Openai
7
+ # Canonical <-> OpenAI wire-format translator.
8
+ #
9
+ # Implements the provider-boundary contract (Amendment A) so that
10
+ # canonical requests/responses/chunks cross exactly one translation
11
+ # layer per provider. Extracted from OpenAICompatible mixin methods;
12
+ # semantics preserved, not rewritten.
13
+ #
14
+ # Capabilities (declarative, per the design doc):
15
+ # - reasoning_effort: true (gpt-5.x / o-series)
16
+ # - responses_api: true (chat/completions wire format)
17
+ # - thinking_metadata_keys: [...] (metadata keys for thinking)
18
+ # - stop_reason_map: { openai -> canonical }
19
+ # -- translator complexity is inherent to multi-field mapping (B1a pattern)
20
+ # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
21
+ class Translator
22
+ include Legion::Logging::Helper
23
+
24
+ # OpenAI finish_reason -> canonical stop_reason (G18 stop-reason matrix)
25
+ STOP_REASON_MAP = {
26
+ 'stop' => :end_turn,
27
+ 'tool_calls' => :tool_use,
28
+ 'function_call' => :tool_use,
29
+ 'length' => :max_tokens,
30
+ 'content_filter' => :content_filter
31
+ }.freeze
32
+
33
+ # Metadata keys carrying thinking/reasoning in OpenAI responses
34
+ THINKING_METADATA_KEYS = %i[
35
+ reasoning_content reasoning thinking thinking_text
36
+ thinking_signature reasoning_signature
37
+ ].freeze
38
+
39
+ def initialize(api_base: nil, headers: nil)
40
+ @api_base = api_base
41
+ @headers = headers || {}
42
+ end
43
+
44
+ # @return [Hash] declarative capabilities consumed by routing/dispatch
45
+ def capabilities
46
+ {
47
+ provider: 'openai',
48
+ reasoning_effort: true,
49
+ responses_api: true,
50
+ thinking_metadata_keys: THINKING_METADATA_KEYS,
51
+ stop_reason_map: STOP_REASON_MAP
52
+ }
53
+ end
54
+
55
+ # @param canonical_request [Canonical::Request]
56
+ # @return [Hash] OpenAI wire-format payload for /v1/chat/completions
57
+ def render_request(canonical_request)
58
+ wire = {
59
+ model: resolve_model(canonical_request),
60
+ messages: render_messages(canonical_request),
61
+ stream: canonical_request.stream
62
+ }.compact
63
+
64
+ apply_params(wire, canonical_request.params) if canonical_request.params
65
+ apply_tools(wire, canonical_request) if canonical_request.tools&.any?
66
+ apply_tool_choice(wire, canonical_request.tool_choice) if canonical_request.tool_choice
67
+ apply_thinking(wire, canonical_request)
68
+ use_stream_usage(wire) if canonical_request.stream
69
+
70
+ wire
71
+ end
72
+
73
+ # @param wire [Hash] OpenAI API response body (string-keyed)
74
+ # @return [Canonical::Response]
75
+ def parse_response(wire)
76
+ return Canonical::Response.from_hash(wire) if canonical_form?(wire)
77
+
78
+ body = wire.to_h
79
+ choice = Array(body['choices']).first || {}
80
+ message = choice['message'] || {}
81
+ usage_raw = body['usage'] || {}
82
+
83
+ text, thinking = extract_thinking_from_message(message)
84
+ tool_calls = parse_tool_calls(message['tool_calls'])
85
+ usage = parse_usage(usage_raw)
86
+ stop_reason = map_stop_reason(choice['finish_reason'])
87
+ metadata = extract_response_metadata(message)
88
+
89
+ Canonical::Response.build(
90
+ text: text,
91
+ thinking: thinking,
92
+ tool_calls: tool_calls,
93
+ usage: usage,
94
+ stop_reason: stop_reason,
95
+ model: body['model'],
96
+ metadata: metadata
97
+ )
98
+ rescue StandardError => e
99
+ handle_exception(e, level: :error, handled: true, operation: 'openai.translator.parse_response')
100
+ Canonical::Response.build(
101
+ text: '',
102
+ stop_reason: :error,
103
+ metadata: { error: e.message }
104
+ )
105
+ end
106
+
107
+ # @param raw [Hash] single SSE data payload or canonical chunk
108
+ # @return [Canonical::Chunk, nil]
109
+ def parse_chunk(raw)
110
+ return nil if raw.nil? || raw.to_h.empty?
111
+ return Canonical::Chunk.from_hash(raw) if canonical_chunk_form?(raw)
112
+
113
+ data = raw.to_h
114
+ choice = Array(data['choices']).first || {}
115
+ delta = choice['delta'] || {}
116
+ finish_reason = choice['finish_reason']
117
+ usage_raw = data['usage']
118
+
119
+ return build_done_chunk(data, finish_reason, usage_raw) if finish_reason
120
+ return parse_error_chunk(data) if data['error']
121
+
122
+ reasoning = delta['reasoning_content'] || delta['reasoning']
123
+ content = delta['content']
124
+
125
+ return build_thinking_chunk(reasoning, data['id']) if reasoning
126
+ return build_text_chunk(content, data['id']) if content
127
+
128
+ parse_tool_call_delta(delta, data)
129
+ rescue StandardError => e
130
+ handle_exception(e, level: :error, handled: true, operation: 'openai.translator.parse_chunk')
131
+ Canonical::Chunk.error_chunk(
132
+ error: e.message,
133
+ request_id: raw.to_h['id']
134
+ )
135
+ end
136
+
137
+ private
138
+
139
+ # G18: OpenAI param mapping - unsupported drop with debug log
140
+ def apply_params(wire, params)
141
+ wire[:max_tokens] = params.max_tokens if params.max_tokens
142
+ wire[:temperature] = params.temperature if params.temperature
143
+ wire[:top_p] = params.top_p if params.top_p
144
+
145
+ log.debug('[openai.translator] dropping unsupported param: top_k') if params.top_k
146
+
147
+ wire[:stop] = Array(params.stop_sequences) if params.stop_sequences
148
+ wire[:seed] = params.seed if params.seed
149
+ wire[:frequency_penalty] = params.frequency_penalty if params.frequency_penalty
150
+ wire[:presence_penalty] = params.presence_penalty if params.presence_penalty
151
+
152
+ wire[:response_format] = render_response_format(params.response_format) if params.response_format
153
+
154
+ return unless params.max_thinking_tokens
155
+
156
+ log.debug('[openai.translator] mapped to reasoning_effort via thinking config')
157
+ end
158
+
159
+ def render_response_format(fmt)
160
+ return fmt if fmt.is_a?(Hash)
161
+
162
+ type_val = fmt.to_s
163
+ type_val == 'json_object' ? { type: 'json_object' } : { type: type_val }
164
+ rescue StandardError => e
165
+ handle_exception(e, level: :warn, handled: true, operation: 'openai.translator.render_response_format')
166
+ { type: 'text' }
167
+ end
168
+
169
+ def apply_tools(wire, canonical_request)
170
+ wire[:tools] = canonical_request.tools.values.map do |tool_def|
171
+ {
172
+ type: 'function',
173
+ function: {
174
+ name: tool_def.name,
175
+ description: tool_def.description,
176
+ parameters: tool_def.parameters || { type: 'object', properties: {} }
177
+ }
178
+ }
179
+ end
180
+ end
181
+
182
+ def apply_tool_choice(wire, tool_choice)
183
+ wire[:tool_choice] = case tool_choice
184
+ when :auto then 'auto'
185
+ when :none then 'none'
186
+ when :required then 'required'
187
+ when Hash
188
+ {
189
+ type: 'function',
190
+ function: { name: tool_choice[:name] || tool_choice['name'] }
191
+ }
192
+ else
193
+ tool_choice.to_s
194
+ end
195
+ end
196
+
197
+ def apply_thinking(wire, canonical_request)
198
+ thinking = canonical_request.thinking
199
+ return unless thinking
200
+
201
+ effort = if thinking.respond_to?(:effort)
202
+ thinking.effort
203
+ else
204
+ thinking[:effort] || thinking['effort']
205
+ end
206
+ return unless effort
207
+
208
+ wire[:reasoning_effort] = effort.to_s.downcase
209
+ end
210
+
211
+ def use_stream_usage(wire)
212
+ wire[:stream_options] = { include_usage: true }
213
+ end
214
+
215
+ def resolve_model(canonical_request)
216
+ return canonical_request.routing[:model] if canonical_request.routing&.dig(:model)
217
+ return canonical_request.caller[:model] if canonical_request.caller&.dig(:model)
218
+
219
+ canonical_request.metadata[:model] || 'gpt-4o'
220
+ end
221
+
222
+ def render_messages(canonical_request)
223
+ messages = []
224
+
225
+ messages << { role: 'system', content: canonical_request.system } if canonical_request.system
226
+
227
+ Array(canonical_request.messages).each do |msg|
228
+ messages << render_message(msg)
229
+ end
230
+
231
+ messages
232
+ end
233
+
234
+ def render_message(msg)
235
+ openai_msg = { role: msg.role.to_s }
236
+
237
+ # rubocop:disable Lint/DuplicateBranch -- explicit per-role clarity for :tool vs unknown
238
+ case msg.role
239
+ when :user
240
+ openai_msg[:content] = extract_text_from_content(msg.content)
241
+ when :assistant
242
+ content_parts = []
243
+
244
+ openai_msg[:tool_calls] = render_openai_tool_calls(msg.tool_calls) if msg.tool_calls&.any?
245
+
246
+ text = extract_text_from_content(msg.content)
247
+ content_parts << { type: 'text', text: text } if text
248
+
249
+ if msg.content.is_a?(Array)
250
+ msg.content.each do |block|
251
+ next unless block.is_a?(Canonical::ContentBlock) && block.tool_use?
252
+
253
+ content_parts << {
254
+ type: 'tool_use',
255
+ id: block.id,
256
+ name: block.name,
257
+ input: block.input || {}
258
+ }
259
+ end
260
+ end
261
+
262
+ openai_msg[:content] = content_parts.empty? ? '' : content_parts
263
+ when :tool
264
+ openai_msg[:tool_call_id] = msg.tool_call_id if msg.tool_call_id
265
+ openai_msg[:content] = extract_text_from_content(msg.content)
266
+ else
267
+ openai_msg[:content] = extract_text_from_content(msg.content)
268
+ end
269
+ # rubocop:enable Lint/DuplicateBranch
270
+
271
+ openai_msg
272
+ end
273
+
274
+ def extract_text_from_content(content)
275
+ return content if content.is_a?(String)
276
+ return '' unless content
277
+
278
+ case content
279
+ when Array then content.filter_map { |block| extract_block_text(block) }.join
280
+ when Canonical::ContentBlock then content.text.to_s
281
+ else content.to_s
282
+ end
283
+ end
284
+
285
+ def extract_block_text(block)
286
+ block.is_a?(Canonical::ContentBlock) ? block.text.to_s : (block[:text] || block['text'] || block.to_s)
287
+ end
288
+
289
+ def render_openai_tool_calls(tool_calls)
290
+ Array(tool_calls).map do |tc|
291
+ args = tc.arguments.is_a?(String) ? tc.arguments : Legion::JSON.generate(tc.arguments || {})
292
+
293
+ {
294
+ id: tc.id,
295
+ type: 'function',
296
+ function: { name: tc.name, arguments: args }
297
+ }
298
+ end
299
+ end
300
+
301
+ # Extracted from OpenAICompatible mixin response parsing
302
+
303
+ def extract_thinking_from_message(message)
304
+ metadata = {
305
+ reasoning_content: message['reasoning_content'],
306
+ reasoning: message['reasoning'],
307
+ thinking: message['thinking'],
308
+ thinking_text: message['thinking_text'],
309
+ thinking_signature: message['thinking_signature'],
310
+ reasoning_signature: message['reasoning_signature']
311
+ }.compact
312
+
313
+ extraction = Llm::Responses::ThinkingExtractor.extract(
314
+ message['content'],
315
+ metadata: metadata
316
+ )
317
+
318
+ [
319
+ extraction.content || '',
320
+ Canonical::Thinking.from_hash(
321
+ content: extraction.thinking,
322
+ signature: extraction.signature
323
+ )
324
+ ]
325
+ end
326
+
327
+ def parse_tool_calls(raw)
328
+ return [] unless raw&.any?
329
+
330
+ Array(raw).flat_map do |call|
331
+ function = call.fetch('function', {})
332
+ args = parse_tool_arguments(function['arguments'])
333
+
334
+ Canonical::ToolCall.build(
335
+ id: call['id'],
336
+ name: function['name'],
337
+ arguments: args,
338
+ source: :client
339
+ )
340
+ end.compact
341
+ end
342
+
343
+ def parse_tool_arguments(arguments)
344
+ return {} if arguments.nil? || arguments == ''
345
+ return arguments if arguments.is_a?(Hash)
346
+
347
+ Legion::JSON.load(arguments)
348
+ rescue Legion::JSON::ParseError => e
349
+ handle_exception(e, level: :warn, handled: true, operation: 'openai.translator.parse_tool_arguments')
350
+ {}
351
+ end
352
+
353
+ def parse_usage(raw)
354
+ return nil unless raw&.any?
355
+
356
+ Canonical::Usage.from_hash(raw)
357
+ end
358
+
359
+ def map_stop_reason(raw)
360
+ return nil unless raw
361
+
362
+ mapped = STOP_REASON_MAP.fetch(raw.to_s, nil)
363
+ mapped || raw.to_sym
364
+ end
365
+
366
+ def extract_response_metadata(message)
367
+ {
368
+ reasoning: message['reasoning'],
369
+ reasoning_content: message['reasoning_content']
370
+ }.compact
371
+ end
372
+
373
+ def parse_error_chunk(data)
374
+ Canonical::Chunk.error_chunk(
375
+ error: data['error'].to_s,
376
+ request_id: data['id']
377
+ )
378
+ end
379
+
380
+ def build_done_chunk(data, finish_reason, usage_raw)
381
+ Canonical::Chunk.done(
382
+ request_id: data['id'],
383
+ stop_reason: map_stop_reason(finish_reason),
384
+ usage: parse_usage(usage_raw)
385
+ )
386
+ end
387
+
388
+ def build_thinking_chunk(reasoning, request_id)
389
+ Canonical::Chunk.thinking_delta(
390
+ delta: reasoning,
391
+ request_id: request_id
392
+ )
393
+ end
394
+
395
+ def build_text_chunk(content, request_id)
396
+ Canonical::Chunk.text_delta(
397
+ delta: content,
398
+ request_id: request_id
399
+ )
400
+ end
401
+
402
+ def parse_tool_call_delta(delta, data)
403
+ raw_tc = delta['tool_calls']
404
+ return nil unless raw_tc&.any?
405
+
406
+ tc = Array(raw_tc).first || {}
407
+ func = tc['function'] || {}
408
+
409
+ tool_call = Canonical::ToolCall.build(
410
+ id: tc['id'],
411
+ name: func['name'],
412
+ arguments: parse_tool_arguments(func['arguments'])
413
+ )
414
+
415
+ Canonical::Chunk.tool_call_delta(
416
+ tool_call: tool_call,
417
+ request_id: data['id']
418
+ )
419
+ end
420
+
421
+ # Format detection - conformance kit passes canonical-form fixtures;
422
+ # real usage sends OpenAI wire format. Detect and handle both.
423
+ def canonical_form?(hash)
424
+ h = hash.is_a?(Hash) ? hash.transform_keys(&:to_sym) : {}
425
+ !h[:text].nil? || !h[:stop_reason].nil? || !h[:tool_calls].nil? || !h[:thinking].nil?
426
+ end
427
+
428
+ def canonical_chunk_form?(hash)
429
+ h = hash.is_a?(Hash) ? hash.transform_keys(&:to_sym) : {}
430
+ !h[:type].nil?
431
+ end
432
+ end
433
+ # rubocop:enable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
434
+ end
435
+ end
436
+ end
437
+ end
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Llm
6
6
  module Openai
7
- VERSION = '0.3.11'
7
+ VERSION = '0.4.1'
8
8
  end
9
9
  end
10
10
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'legion/extensions/llm'
4
4
  require 'legion/extensions/llm/openai/provider'
5
+ require 'legion/extensions/llm/openai/translator'
5
6
  require 'legion/extensions/llm/openai/version'
6
7
 
7
8
  module Legion
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-llm-openai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.11
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - LegionIO
@@ -71,14 +71,14 @@ dependencies:
71
71
  requirements:
72
72
  - - ">="
73
73
  - !ruby/object:Gem::Version
74
- version: 0.4.3
74
+ version: 0.5.0
75
75
  type: :runtime
76
76
  prerelease: false
77
77
  version_requirements: !ruby/object:Gem::Requirement
78
78
  requirements:
79
79
  - - ">="
80
80
  - !ruby/object:Gem::Version
81
- version: 0.4.3
81
+ version: 0.5.0
82
82
  description: OpenAI provider integration for the LegionIO LLM routing framework.
83
83
  email:
84
84
  - matthewdiverson@gmail.com
@@ -101,6 +101,7 @@ files:
101
101
  - lib/legion/extensions/llm/openai/actors/fleet_worker.rb
102
102
  - lib/legion/extensions/llm/openai/provider.rb
103
103
  - lib/legion/extensions/llm/openai/runners/fleet_worker.rb
104
+ - lib/legion/extensions/llm/openai/translator.rb
104
105
  - lib/legion/extensions/llm/openai/version.rb
105
106
  homepage: https://github.com/LegionIO/lex-llm-openai
106
107
  licenses: