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 +4 -4
- data/.rubocop.yml +4 -2
- data/CHANGELOG.md +25 -1
- data/Gemfile +0 -7
- data/lex-llm-openai.gemspec +1 -1
- data/lib/legion/extensions/llm/openai/actors/discovery_refresh.rb +7 -0
- data/lib/legion/extensions/llm/openai/provider.rb +153 -8
- data/lib/legion/extensions/llm/openai/translator.rb +455 -0
- data/lib/legion/extensions/llm/openai/version.rb +1 -1
- data/lib/legion/extensions/llm/openai.rb +4 -6
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7bb7451045c6e85e6e89657665065fe502888f49982d3dc43a53fdcb1bf4ebcd
|
|
4
|
+
data.tar.gz: 05e91bb97ec042142b126b310e9e05dcfb99fe1ae999b8595f7924102106d592
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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:
|
|
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:
|
|
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
|
+
## 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
|
data/lex-llm-openai.gemspec
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
@@ -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)
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|