lex-llm-openai 0.3.11 → 0.4.4

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: 7bb7451045c6e85e6e89657665065fe502888f49982d3dc43a53fdcb1bf4ebcd
4
+ data.tar.gz: 05e91bb97ec042142b126b310e9e05dcfb99fe1ae999b8595f7924102106d592
5
5
  SHA512:
6
- metadata.gz: 077412edd3903af264268d9863f7ce69f522940d12abc47fb63b9f948ef607d5b39da14a8d5bf42912fd2143494577882aaf34c2656fd4df6d91ed3dadf4d09d
7
- data.tar.gz: f6b2c1f473e83012b548d80763320b8d66263ade5438b13743b35b7bd4716bb72f2bf333a03679608f79dd520682b5c6d3f1778c7c4197eecde8869aa0338f57
6
+ metadata.gz: a7bdaf1e6de4518d02d1f0bc6af5ddcd39392f4fd8153902ce0414264ec773e2295ad14b8508984ed8140f96742081b1e76f16c2843dd4667e59d25fb558bf3a
7
+ data.tar.gz: d5dfe88b242d5b9ab7d3c27621c1761fd7e5a8a6958dfadcafe20be3e7a89a9f7c33f4b21bcda2574bc29e0ef7ea025b9f905f4d01d9e45c0de2aecc8f22648b
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,30 @@
1
1
  # Changelog
2
2
 
3
- ## 0.3.11 - 2026-06-05
3
+ ## 0.4.4 - 2026-06-16
4
+
5
+ - dependency updates, code quality improvements
6
+
7
+ ## 0.4.3 - 2026-06-15
8
+
9
+ - **CapabilityPolicy integration** — CAPABILITY_MAP fed as `:provider_catalog` source. Settings overrides at provider/instance/model level supported.
10
+
11
+ ## 0.4.2 — 2026-06-13
12
+
13
+ - **Gemfile cleanup** — Remove local path overrides; dependencies resolve from gemspec via rubygems.
14
+ - **Capabilities** — Add canonical `:tools` to capability declarations.
15
+ - **Bug fix** — Extract nested `cached_tokens` from usage details (G26).
16
+ - 153 examples, 0 failures; 16 files, 0 rubocop offenses.
17
+
18
+ ## 0.4.1 — 2026-06-10
19
+
20
+ - Canonical translator (`Translator`): `render_request`, `parse_response`, `parse_chunk`, `capabilities` — provider-boundary contract per N×N routing Amendment A
21
+ - 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)
22
+ - `Provider#translator` exposes a lazy `Translator` instance; provider becomes transport + config
23
+ - 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
24
+ - G18 stop_reason matrix: stop → end_turn, tool_calls → tool_use, length → max_tokens, content_filter → content_filter
25
+ - Require `lex-llm >= 0.5.0` (canonical types, conformance kit, Zeitwerk removal)
26
+
27
+ ## 0.3.11 — 2026-06-05
4
28
 
5
29
  - Fix missing top-level documentation comment in `DiscoveryRefresh` actor (RuboCop `Style/Documentation`).
6
30
 
data/Gemfile CHANGED
@@ -2,13 +2,6 @@
2
2
 
3
3
  source 'https://rubygems.org'
4
4
 
5
- group :test do
6
- llm_base_path = ENV.fetch('LEX_LLM_PATH', File.expand_path('../lex-llm', __dir__))
7
- transport_path = ENV.fetch('LEGION_TRANSPORT_PATH', File.expand_path('../../legion-transport', __dir__))
8
- 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)
10
- end
11
-
12
5
  gemspec
13
6
 
14
7
  group :development do
@@ -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
@@ -37,6 +37,13 @@ module Legion
37
37
  return unless defined?(Legion::LLM::Discovery)
38
38
 
39
39
  Legion::LLM::Discovery.refresh_discovered_models!(provider: :openai)
40
+
41
+ if defined?(Legion::LLM::Router) && Legion::LLM::Router.respond_to?(:populate_auto_rules)
42
+ Legion::LLM::Router.populate_auto_rules(Legion::LLM::Discovery.discovered_instances)
43
+ end
44
+ if defined?(Legion::LLM::Inventory) && Legion::LLM::Inventory.respond_to?(:invalidate_offerings_cache!)
45
+ Legion::LLM::Inventory.invalidate_offerings_cache!
46
+ end
40
47
  rescue StandardError => e
41
48
  handle_exception(e, level: :warn, handled: true, operation: 'openai.actor.discovery_refresh')
42
49
  end
@@ -17,43 +17,43 @@ module Legion
17
17
  # the raw /v1/models response.
18
18
  CAPABILITY_MAP = {
19
19
  'gpt-4o' => {
20
- capabilities: %i[completion streaming function_calling vision structured_output],
20
+ capabilities: %i[completion streaming function_calling tools vision structured_output],
21
21
  modalities_input: %w[text image audio],
22
22
  modalities_output: %w[text],
23
23
  context_window: 128_000
24
24
  },
25
25
  'gpt-4.1' => {
26
- capabilities: %i[completion streaming function_calling vision structured_output],
26
+ capabilities: %i[completion streaming function_calling tools vision structured_output],
27
27
  modalities_input: %w[text image],
28
28
  modalities_output: %w[text],
29
29
  context_window: 1_047_576
30
30
  },
31
31
  'gpt-4' => {
32
- capabilities: %i[completion streaming function_calling vision],
32
+ capabilities: %i[completion streaming function_calling tools vision],
33
33
  modalities_input: %w[text image],
34
34
  modalities_output: %w[text],
35
35
  context_window: 128_000
36
36
  },
37
37
  'gpt-5' => {
38
- capabilities: %i[completion streaming function_calling vision structured_output reasoning],
38
+ capabilities: %i[completion streaming function_calling tools vision structured_output reasoning],
39
39
  modalities_input: %w[text image],
40
40
  modalities_output: %w[text],
41
41
  context_window: 1_047_576
42
42
  },
43
43
  'o4' => {
44
- capabilities: %i[completion streaming function_calling vision reasoning],
44
+ capabilities: %i[completion streaming function_calling tools vision reasoning],
45
45
  modalities_input: %w[text image],
46
46
  modalities_output: %w[text],
47
47
  context_window: 200_000
48
48
  },
49
49
  'o3' => {
50
- capabilities: %i[completion streaming function_calling vision reasoning],
50
+ capabilities: %i[completion streaming function_calling tools vision reasoning],
51
51
  modalities_input: %w[text image],
52
52
  modalities_output: %w[text],
53
53
  context_window: 200_000
54
54
  },
55
55
  'o1' => {
56
- capabilities: %i[completion streaming function_calling vision reasoning],
56
+ capabilities: %i[completion streaming function_calling tools vision reasoning],
57
57
  modalities_input: %w[text image],
58
58
  modalities_output: %w[text],
59
59
  context_window: 200_000
@@ -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
@@ -205,6 +211,36 @@ module Legion
205
211
  raise
206
212
  end
207
213
 
214
+ def discover_offerings(live: false, **)
215
+ models = if live
216
+ @cached_models = list_models
217
+ else
218
+ Array(@cached_models)
219
+ end
220
+ offerings = models.filter_map { |model_info| offering_from_model(model_info) }
221
+ log.debug { "built #{offerings.size} OpenAI offering(s) live=#{live}" }
222
+ offerings
223
+ rescue StandardError => e
224
+ handle_exception(e, level: :warn, handled: true, operation: 'openai.discover_offerings')
225
+ []
226
+ end
227
+
228
+ # ── CapabilityPolicy integration ─────────────────────────────────
229
+ # Maps raw CAPABILITY_MAP symbol arrays to the boolean hash format
230
+ # that CapabilityPolicy.resolve expects as :provider_catalog.
231
+ CATALOG_CAPABILITY_MAPPING = {
232
+ streaming: :streaming,
233
+ function_calling: :tools,
234
+ tools: :tools,
235
+ vision: :vision,
236
+ structured_output: :structured_output,
237
+ reasoning: :thinking,
238
+ embedding: :embeddings,
239
+ image_generation: :image,
240
+ audio_transcription: :audio_transcription,
241
+ audio_generation: :audio_speech
242
+ }.freeze
243
+
208
244
  private
209
245
 
210
246
  def build_model_infos(body)
@@ -242,6 +278,115 @@ module Legion
242
278
  }
243
279
  end
244
280
 
281
+ def offering_from_model(model_info)
282
+ policy = resolve_model_policy(model_info)
283
+
284
+ Legion::Extensions::Llm::Routing::ModelOffering.new(
285
+ offering_attrs_for(model_info, policy)
286
+ )
287
+ end
288
+
289
+ def resolve_model_policy(model_info)
290
+ catalog = capabilities_to_boolean_hash(capability_entry_for(model_info.id)[:capabilities])
291
+
292
+ Legion::Extensions::Llm::CapabilityPolicy.resolve(
293
+ real: {},
294
+ provider_catalog: catalog,
295
+ probe: {},
296
+ provider_envelope: {},
297
+ provider_config: provider_capability_config,
298
+ instance_config: instance_capability_config,
299
+ model_config: model_capability_config(model_info.id)
300
+ )
301
+ end
302
+
303
+ def offering_attrs_for(model_info, policy)
304
+ {
305
+ provider_family: :openai,
306
+ instance_id: config.respond_to?(:instance_id) ? config.instance_id : :default,
307
+ transport: :http,
308
+ tier: :frontier,
309
+ model: model_info.id,
310
+ canonical_model_alias: model_info.respond_to?(:name) ? model_info.name : nil,
311
+ model_family: infer_model_family(model_info.id),
312
+ usage_type: infer_usage_type(model_info),
313
+ capabilities: policy[:capabilities],
314
+ capability_sources: policy[:sources],
315
+ limits: { context_window: model_info.context_length }.compact,
316
+ metadata: { capability_sources: policy[:sources] }
317
+ }
318
+ end
319
+
320
+ def capabilities_to_boolean_hash(capability_symbols)
321
+ return {} unless capability_symbols.is_a?(Array)
322
+
323
+ result = {}
324
+ capability_symbols.each do |sym|
325
+ mapped = CATALOG_CAPABILITY_MAPPING[sym]
326
+ result[mapped] = true if mapped
327
+ end
328
+ result
329
+ end
330
+
331
+ def provider_capability_config
332
+ return {} unless defined?(Legion::Extensions::Llm::CredentialSources)
333
+
334
+ conf = Legion::Extensions::Llm::CredentialSources.setting(:extensions, :llm, :openai)
335
+ conf.is_a?(Hash) ? conf.to_h.except(:instances, 'instances') : {}
336
+ rescue StandardError => e
337
+ handle_exception(e, level: :debug, handled: true, operation: 'openai.provider_capability_config')
338
+ {}
339
+ end
340
+
341
+ def instance_capability_config
342
+ cfg = config
343
+ result = {}
344
+ %i[capabilities enable_thinking enable_tools enable_streaming enable_vision enable_embeddings
345
+ thinking_flag tools_flag streaming_flag vision_flag embedding_flag embeddings_flag
346
+ tool_flag images_flag image_flag].each do |key|
347
+ next unless cfg.respond_to?(key)
348
+
349
+ val = cfg.send(key)
350
+ result[key] = val unless val.nil?
351
+ rescue StandardError
352
+ next
353
+ end
354
+ result
355
+ end
356
+
357
+ def model_capability_config(model_id)
358
+ models_conf = fetch_models_config
359
+ return {} unless models_conf.respond_to?(:to_h)
360
+
361
+ models_conf.to_h[model_id.to_s] || models_conf.to_h[model_id.to_sym] || {}
362
+ rescue StandardError => e
363
+ handle_exception(e, level: :debug, handled: true, operation: 'openai.model_capability_config')
364
+ {}
365
+ end
366
+
367
+ def fetch_models_config
368
+ return config.models if config.respond_to?(:models)
369
+ return config[:models] if config.respond_to?(:[])
370
+
371
+ nil
372
+ end
373
+
374
+ def infer_model_family(model_id)
375
+ CAPABILITY_MAP.each_key do |prefix|
376
+ return prefix.tr('-', '_').to_sym if model_id.start_with?(prefix)
377
+ end
378
+ :unknown
379
+ end
380
+
381
+ def infer_usage_type(model_info)
382
+ caps = model_info.respond_to?(:capabilities) ? Array(model_info.capabilities) : []
383
+ return :embedding if caps.include?(:embedding)
384
+ return :moderation if caps.include?(:moderation)
385
+ return :image if caps.include?(:image_generation)
386
+
387
+ :inference
388
+ end
389
+
245
390
  def fetch_model_detail(model_name)
246
391
  entry = capability_entry_for(model_name)
247
392
  ctx = entry[:context_window]
@@ -0,0 +1,455 @@
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
+ tc_array = tool_calls.is_a?(Hash) ? tool_calls.values : Array(tool_calls)
291
+ tc_array.map do |tc|
292
+ args = tc.arguments.is_a?(String) ? tc.arguments : Legion::JSON.generate(tc.arguments || {})
293
+
294
+ {
295
+ id: tc.id,
296
+ type: 'function',
297
+ function: { name: tc.name, arguments: args }
298
+ }
299
+ end
300
+ end
301
+
302
+ # Extracted from OpenAICompatible mixin response parsing
303
+
304
+ def extract_thinking_from_message(message)
305
+ metadata = {
306
+ reasoning_content: message['reasoning_content'],
307
+ reasoning: message['reasoning'],
308
+ thinking: message['thinking'],
309
+ thinking_text: message['thinking_text'],
310
+ thinking_signature: message['thinking_signature'],
311
+ reasoning_signature: message['reasoning_signature']
312
+ }.compact
313
+
314
+ extraction = Llm::Responses::ThinkingExtractor.extract(
315
+ message['content'],
316
+ metadata: metadata
317
+ )
318
+
319
+ [
320
+ extraction.content || '',
321
+ Canonical::Thinking.from_hash(
322
+ content: extraction.thinking,
323
+ signature: extraction.signature
324
+ )
325
+ ]
326
+ end
327
+
328
+ def parse_tool_calls(raw)
329
+ return [] unless raw&.any?
330
+
331
+ Array(raw).flat_map do |call|
332
+ function = call.fetch('function', {})
333
+ args = parse_tool_arguments(function['arguments'])
334
+
335
+ Canonical::ToolCall.build(
336
+ id: call['id'],
337
+ name: function['name'],
338
+ arguments: args,
339
+ source: :client
340
+ )
341
+ end.compact
342
+ end
343
+
344
+ def parse_tool_arguments(arguments)
345
+ return {} if arguments.nil? || arguments == ''
346
+ return arguments if arguments.is_a?(Hash)
347
+
348
+ Legion::JSON.load(arguments)
349
+ rescue Legion::JSON::ParseError => e
350
+ handle_exception(e, level: :warn, handled: true, operation: 'openai.translator.parse_tool_arguments')
351
+ {}
352
+ end
353
+
354
+ def parse_usage(raw)
355
+ return nil unless raw&.any?
356
+
357
+ normalized = raw.dup
358
+ normalized[:cache_read_tokens] ||= extract_nested_cached_tokens(raw)
359
+ normalized[:thinking_tokens] ||= extract_nested_reasoning_tokens(raw)
360
+ Canonical::Usage.from_hash(normalized)
361
+ end
362
+
363
+ def extract_nested_cached_tokens(raw)
364
+ raw.dig(:prompt_tokens_details, :cached_tokens) ||
365
+ raw.dig('prompt_tokens_details', 'cached_tokens') ||
366
+ raw.dig(:input_tokens_details, :cached_tokens) ||
367
+ raw.dig('input_tokens_details', 'cached_tokens')
368
+ end
369
+
370
+ def extract_nested_reasoning_tokens(raw)
371
+ raw.dig(:completion_tokens_details, :reasoning_tokens) ||
372
+ raw.dig('completion_tokens_details', 'reasoning_tokens') ||
373
+ raw.dig(:output_tokens_details, :reasoning_tokens) ||
374
+ raw.dig('output_tokens_details', 'reasoning_tokens')
375
+ end
376
+
377
+ def map_stop_reason(raw)
378
+ return nil unless raw
379
+
380
+ mapped = STOP_REASON_MAP.fetch(raw.to_s, nil)
381
+ mapped || raw.to_sym
382
+ end
383
+
384
+ def extract_response_metadata(message)
385
+ {
386
+ reasoning: message['reasoning'],
387
+ reasoning_content: message['reasoning_content']
388
+ }.compact
389
+ end
390
+
391
+ def parse_error_chunk(data)
392
+ Canonical::Chunk.error_chunk(
393
+ error: data['error'].to_s,
394
+ request_id: data['id']
395
+ )
396
+ end
397
+
398
+ def build_done_chunk(data, finish_reason, usage_raw)
399
+ Canonical::Chunk.done(
400
+ request_id: data['id'],
401
+ stop_reason: map_stop_reason(finish_reason),
402
+ usage: parse_usage(usage_raw)
403
+ )
404
+ end
405
+
406
+ def build_thinking_chunk(reasoning, request_id)
407
+ Canonical::Chunk.thinking_delta(
408
+ delta: reasoning,
409
+ request_id: request_id
410
+ )
411
+ end
412
+
413
+ def build_text_chunk(content, request_id)
414
+ Canonical::Chunk.text_delta(
415
+ delta: content,
416
+ request_id: request_id
417
+ )
418
+ end
419
+
420
+ def parse_tool_call_delta(delta, data)
421
+ raw_tc = delta['tool_calls']
422
+ return nil unless raw_tc&.any?
423
+
424
+ tc = Array(raw_tc).first || {}
425
+ func = tc['function'] || {}
426
+
427
+ tool_call = Canonical::ToolCall.build(
428
+ id: tc['id'],
429
+ name: func['name'],
430
+ arguments: parse_tool_arguments(func['arguments'])
431
+ )
432
+
433
+ Canonical::Chunk.tool_call_delta(
434
+ tool_call: tool_call,
435
+ request_id: data['id']
436
+ )
437
+ end
438
+
439
+ # Format detection - conformance kit passes canonical-form fixtures;
440
+ # real usage sends OpenAI wire format. Detect and handle both.
441
+ def canonical_form?(hash)
442
+ h = hash.is_a?(Hash) ? hash.transform_keys(&:to_sym) : {}
443
+ !h[:text].nil? || !h[:stop_reason].nil? || !h[:tool_calls].nil? || !h[:thinking].nil?
444
+ end
445
+
446
+ def canonical_chunk_form?(hash)
447
+ h = hash.is_a?(Hash) ? hash.transform_keys(&:to_sym) : {}
448
+ !h[:type].nil?
449
+ end
450
+ end
451
+ # rubocop:enable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
452
+ end
453
+ end
454
+ end
455
+ 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.4'
8
8
  end
9
9
  end
10
10
  end
@@ -2,7 +2,9 @@
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'
7
+ require_relative 'openai/actors/discovery_refresh'
6
8
 
7
9
  module Legion
8
10
  module Extensions
@@ -39,10 +41,7 @@ module Legion
39
41
  fleet: {
40
42
  enabled: false,
41
43
  respond_to_requests: false,
42
- capabilities: %i[chat stream_chat embed image],
43
- lanes: [],
44
- concurrency: 4,
45
- queue_suffix: nil
44
+ capabilities: %i[chat stream_chat embed image]
46
45
  }
47
46
  }
48
47
  )
@@ -134,8 +133,7 @@ module Legion
134
133
  config.except(:api_key, :organization_id, :project_id)
135
134
  end
136
135
 
137
- Legion::Extensions::Llm::Configuration.register_provider_options(Provider.configuration_options) if
138
- Legion::Extensions::Llm::Configuration.respond_to?(:register_provider_options)
136
+ Legion::Extensions::Llm::Configuration.register_provider_options(Provider.configuration_options)
139
137
  end
140
138
  end
141
139
  end
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.4
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: