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 +4 -4
- data/.rubocop.yml +4 -2
- data/CHANGELOG.md +10 -1
- data/Gemfile +1 -2
- data/lex-llm-openai.gemspec +1 -1
- data/lib/legion/extensions/llm/openai/provider.rb +7 -1
- data/lib/legion/extensions/llm/openai/translator.rb +437 -0
- data/lib/legion/extensions/llm/openai/version.rb +1 -1
- data/lib/legion/extensions/llm/openai.rb +1 -0
- 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: 1e0bcb314dd98bb72b108f3f92a05ceb40dd8fc125e3972712674537e5baa2a9
|
|
4
|
+
data.tar.gz: b9fd92e6d25f2e9949fa5b5e1a7d79f8d9ed04a197b873a0b754d309dc30aef6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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:
|
|
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,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
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
|
-
|
|
8
|
+
# lex-llm resolved from rubygems (>= 0.5.0) via gemspec - no local path dep
|
|
10
9
|
end
|
|
11
10
|
|
|
12
11
|
gemspec
|
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
|
|
@@ -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
|
|
@@ -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
|
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.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.
|
|
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:
|