lex-llm-openai 0.3.10 → 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: 35f7b923651392686a2c504e6ec30249ee8efa6c3f51c7171e18a11d124bc1d8
4
- data.tar.gz: 7bc891da29aee08bb0bfa4600cd7cea7f0562703b210774d67b543682715143a
3
+ metadata.gz: 1e0bcb314dd98bb72b108f3f92a05ceb40dd8fc125e3972712674537e5baa2a9
4
+ data.tar.gz: b9fd92e6d25f2e9949fa5b5e1a7d79f8d9ed04a197b873a0b754d309dc30aef6
5
5
  SHA512:
6
- metadata.gz: 2efc2485c6e46d476c654cab015262030455c366546a649011e16be3fc4e0613d1d46d45ff5122d68a7335612bda14c9cebee0cff37012bfce7ee2df1a35687f
7
- data.tar.gz: 72c4c111bf0c447e0e8646067508382e52524de22f66381824f143f8717edbadf17d424ff12c4935c773e670d8a46aa8e0a2a2c5307f648fbc33ebeaf2b230d8
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,5 +1,18 @@
1
1
  # Changelog
2
2
 
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
13
+
14
+ - Fix missing top-level documentation comment in `DiscoveryRefresh` actor (RuboCop `Style/Documentation`).
15
+
3
16
  ## 0.3.10 - 2026-05-21
4
17
 
5
18
  - api_base reads from settings[:endpoint] fallback
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
data/README.md CHANGED
@@ -2,53 +2,138 @@
2
2
 
3
3
  LegionIO LLM provider extension for OpenAI.
4
4
 
5
- This gem lives under `Legion::Extensions::Llm::Openai` and depends on `lex-llm >= 0.4.3` for shared provider-neutral routing, response normalization, fleet envelopes, fleet responder execution, and schema primitives.
5
+ This gem provides the `:openai` provider family implementation, enabling LegionIO to route chat, streaming, embedding, moderation, image, and audio requests to OpenAI-compatible APIs through the shared `lex-llm` provider contract.
6
6
 
7
- Load it with `require 'legion/extensions/llm/openai'`.
7
+ **Namespace:** `Legion::Extensions::Llm::Openai`
8
+ **Version:** 0.3.11
9
+ **Load:** `require 'legion/extensions/llm/openai'`
10
+
11
+ ## Quick Start
12
+
13
+ ```ruby
14
+ require 'legion/extensions/llm/openai'
15
+
16
+ # Configure via the shared LLM configuration API
17
+ Legion::Extensions::Llm.configure do |config|
18
+ config.openai_api_key = ENV.fetch("OPENAI_API_KEY")
19
+ config.default_model = "gpt-5.5"
20
+ end
21
+
22
+ # Use through the standard provider interface
23
+ provider = Legion::Extensions::Llm::Openai::Provider.new
24
+ provider.chat(model: "gpt-5.5", messages: [{ role: "user", content: "Hello" }])
25
+ ```
8
26
 
9
27
  ## What It Provides
10
28
 
11
- - OpenAI provider discovery under the `:openai` provider family
12
- - Chat completions via `POST /v1/chat/completions`
13
- - Streaming chat completions (same endpoint, `stream: true`)
14
- - Model discovery via `GET /v1/models`
15
- - Model retrieval via `GET /v1/models/{model}`
16
- - Embeddings via `POST /v1/embeddings`
17
- - Moderation via `POST /v1/moderations`
18
- - Image generation via `POST /v1/images/generations`
19
- - Image editing via `POST /v1/images/edits`
20
- - Image variation via `POST /v1/images/variations`
21
- - Audio transcription via `POST /v1/audio/transcriptions`
22
- - Streaming token usage reporting (`stream_usage_supported?`)
23
- - Shared OpenAI-compatible request/response mapping via `Legion::Extensions::Llm::Provider::OpenAICompatible`
24
- - Normalized chat, embedding, moderation, image, and audio capability mapping for discovered models
25
- - Shared fleet/default settings via `Legion::Extensions::Llm.provider_settings`
26
- - Best-effort `llm.registry` availability event publishing for discovered models
27
- - Provider-owned fleet request handling through `Legion::Extensions::Llm::Fleet::ProviderResponder`
29
+ | Capability | Endpoint | Notes |
30
+ |---|---|---|
31
+ | Chat completions | `POST /v1/chat/completions` | Includes function calling, vision, structured output |
32
+ | Streaming chat | Same endpoint, `stream: true` | Token usage reported via `stream_usage_supported?` |
33
+ | Model listing | `GET /v1/models` | Enriched with static capability map; publishes registry events |
34
+ | Model retrieval | `GET /v1/models/{model}` | |
35
+ | Embeddings | `POST /v1/embeddings` | |
36
+ | Moderation | `POST /v1/moderations` | |
37
+ | Image generation | `POST /v1/images/generations` | |
38
+ | Image editing | `POST /v1/images/edits` | |
39
+ | Image variation | `POST /v1/images/variations` | |
40
+ | Audio transcription | `POST /v1/audio/transcriptions` | Whisper, gpt-4o-transcribe |
28
41
 
29
42
  ## Architecture
30
43
 
31
44
  ```
32
45
  Legion::Extensions::Llm::Openai
33
- |-- Provider # OpenAI provider implementation (chat, models, embeddings, etc.)
34
- | `-- Capabilities # Model family capability predicates
35
- |-- Actor::FleetWorker # Subscription actor gate for provider-owned fleet requests
36
- `-- Runners::FleetWorker # Delegates request execution to lex-llm ProviderResponder
46
+ |-- Openai # Root module settings, discovery, auto-registration
47
+ | |-- default_settings # Provider family defaults (endpoint, models, fleet)
48
+ | |-- discover_instances # Credential scanning across env, Codex, Claude, settings
49
+ | |-- normalize_instance_config # Normalizes generic keys to canonical OpenAI keys
50
+ | `-- sanitize_instance_config # Strips temporary credential fields
51
+ |
52
+ |-- Provider # OpenAI provider implementation
53
+ | |-- Capabilities # Model family predicates (chat?, streaming?, vision?, etc.)
54
+ | |-- CAPABILITY_MAP # Static capability matrix for 14 known model families
55
+ | |-- list_models # Enriches raw API response with capability metadata
56
+ | |-- retrieve_model # Fetches single model detail
57
+ | |-- chat_url, models_url, etc. # Endpoint builders
58
+ | `-- maybe_normalize_temperature # Adjusts temperature for o*/gpt-5 reasoning models
59
+ |
60
+ |-- Actor::FleetWorker # Subscription actor for fleet request consumption
61
+ | |-- enabled? # Checks if any instance has respond_to_requests: true
62
+ | `-- Delegates to lex-llm ProviderResponder
63
+ |
64
+ |-- Actor::DiscoveryRefresh # Periodic actor that refreshes the model discovery cache
65
+ | |-- time # Reads discovery_interval from settings (default 1800s)
66
+ | `-- Calls Legion::LLM::Discovery.refresh_discovered_models!
67
+ |
68
+ `-- Runners::FleetWorker # Execution entrypoint for fleet requests
69
+ `-- handle_fleet_request # Routes to lex-llm ProviderResponder.call
37
70
  ```
38
71
 
39
- Registry publishing, event envelope construction, fleet protocol handling, and fleet response/error transport live in `lex-llm`. This provider intentionally does not depend on `legion-llm` at runtime.
72
+ ### Design Boundaries
40
73
 
41
- ## Observability
74
+ - **Response normalization, request payload mapping** lives in `Lex-llm::Provider::OpenAICompatible` (mixed in)
75
+ - **Fleet responder logic** (ack/reject, response publication) lives in `Lex-llm::Fleet::ProviderResponder`
76
+ - **Registry event publishing** is delegated to `Lex-llm::RegistryPublisher`
77
+ - This extension depends **only** on `lex-llm` at runtime; it does not depend on `legion-llm`
42
78
 
43
- The provider and root extension namespace use `Legion::Logging::Helper` for:
79
+ ## Instance Discovery
44
80
 
45
- - Structured `handle_exception` calls on every rescue block
46
- - Info-level action logging for model listing, model retrieval, and registry publishing
47
- - Automatic log segment derivation and component type tagging
81
+ `Openai.discover_instances` scans 7 credential sources, deduplicates by key, and injects `default_model`:
48
82
 
49
- Fleet actor and runner code stays thin and delegates execution, ack/reject handling, and response publication to the shared `lex-llm` responder helper.
83
+ | Priority | Source | Key |
84
+ |---|---|---|
85
+ | 1 | `OPENAI_API_KEY` environment variable | `:env` |
86
+ | 2 | `CODEX_API_KEY` environment variable | `:codex_env` |
87
+ | 3 | Codex bearer token (`~/.codex/auth.json`) | `:codex` |
88
+ | 4 | Codex OpenAI key (`~/.codex/auth.json`) | `:codex_key` |
89
+ | 5 | Claude config (`openaiApiKey`) | `:claude` |
90
+ | 6 | Extension settings (`extensions.llm.openai`) | `:settings` |
91
+ | 7 | Named instances in extension settings | Named keys |
92
+
93
+ ## Configuration
94
+
95
+ ### Via Legion Settings (YAML)
96
+
97
+ ```yaml
98
+ extensions:
99
+ llm:
100
+ openai:
101
+ api_key: "sk-..."
102
+ default_model: "gpt-5.5"
103
+ endpoint: "https://api.openai.com"
104
+ discovery_interval: 1800 # Seconds between model list refresh (used by DiscoveryRefresh actor)
105
+ instances:
106
+ primary:
107
+ openai_api_key: "sk-..."
108
+ openai_api_base: "https://api.openai.com"
109
+ fleet:
110
+ enabled: true
111
+ respond_to_requests: true
112
+ capabilities:
113
+ - chat
114
+ - stream_chat
115
+ - embed
116
+ - image
117
+ ```
50
118
 
51
- ## Defaults
119
+ ### Via Ruby Configuration API
120
+
121
+ ```ruby
122
+ Legion::Extensions::Llm.configure do |config|
123
+ config.openai_api_key = ENV.fetch("OPENAI_API_KEY")
124
+ config.openai_api_base = nil # defaults to https://api.openai.com
125
+ config.openai_organization_id = nil # optional OpenAI-Organization header
126
+ config.openai_project_id = nil # optional OpenAI-Project header
127
+ config.openai_use_system_role = true # include system messages in requests
128
+ config.default_model = "gpt-5.5"
129
+ config.default_embedding_model = "text-embedding-3-small"
130
+ config.default_moderation_model = "omni-moderation-latest"
131
+ config.default_image_model = "gpt-image-1"
132
+ config.default_transcription_model = "gpt-4o-transcribe"
133
+ end
134
+ ```
135
+
136
+ ### Default Settings
52
137
 
53
138
  ```ruby
54
139
  Legion::Extensions::Llm::Openai.default_settings
@@ -57,45 +142,78 @@ Legion::Extensions::Llm::Openai.default_settings
57
142
  # instances: {
58
143
  # default: {
59
144
  # endpoint: "https://api.openai.com",
145
+ # default_model: "gpt-5.5",
60
146
  # tier: :frontier,
61
147
  # transport: :http,
62
- # credentials: { api_key: "env://OPENAI_API_KEY" },
63
- # usage: { inference: true, embedding: true, moderation: true, image: true, audio: true },
64
- # limits: { concurrency: 4 }
148
+ # credentials: {
149
+ # api_key: "env://OPENAI_API_KEY",
150
+ # organization_id: nil,
151
+ # project_id: nil
152
+ # },
153
+ # usage: {
154
+ # inference: true,
155
+ # embedding: true,
156
+ # moderation: true,
157
+ # image: true,
158
+ # audio: true
159
+ # },
160
+ # limits: { concurrency: 4 },
161
+ # fleet: {
162
+ # enabled: false,
163
+ # respond_to_requests: false,
164
+ # capabilities: [:chat, :stream_chat, :embed, :image],
165
+ # lanes: [],
166
+ # concurrency: 4,
167
+ # queue_suffix: nil
168
+ # }
65
169
  # }
66
170
  # }
67
171
  # }
68
172
  ```
69
173
 
70
- ## Configuration
174
+ ## Model Capability Map
71
175
 
72
- ```ruby
73
- Legion::Extensions::Llm.configure do |config|
74
- config.openai_api_key = ENV.fetch("OPENAI_API_KEY")
75
- config.openai_api_base = nil # defaults to https://api.openai.com
76
- config.openai_organization_id = nil # optional OpenAI-Organization header
77
- config.openai_project_id = nil # optional OpenAI-Project header
78
- config.default_model = "gpt-5.2"
79
- config.default_embedding_model = "text-embedding-3-small"
80
- config.default_moderation_model = "omni-moderation-latest"
81
- config.default_image_model = "gpt-image-1"
82
- config.default_transcription_model = "gpt-4o-transcribe"
83
- end
84
- ```
176
+ The provider maintains a static `CAPABILITY_MAP` covering 14 OpenAI model families. Each entry declares capabilities, input/output modalities, and context window size.
85
177
 
86
- ## Dependencies
178
+ | Prefix | Capabilities | Input | Output | Context |
179
+ |---|---|---|---|---|
180
+ | `gpt-4o` | completion, streaming, function_calling, vision, structured_output | text, image, audio | text | 128K |
181
+ | `gpt-4.1` | completion, streaming, function_calling, vision, structured_output | text, image | text | 1M |
182
+ | `gpt-4` | completion, streaming, function_calling, vision | text, image | text | 128K |
183
+ | `gpt-5` | completion, streaming, function_calling, vision, structured_output, reasoning | text, image | text | 1M |
184
+ | `o4` | completion, streaming, function_calling, vision, reasoning | text, image | text | 200K |
185
+ | `o3` | completion, streaming, function_calling, vision, reasoning | text, image | text | 200K |
186
+ | `o1` | completion, streaming, function_calling, vision, reasoning | text, image | text | 200K |
187
+ | `text-embedding-*` | embedding | text | embeddings | 8K |
188
+ | `omni-moderation` | moderation | text, image | moderation | - |
189
+ | `text-moderation` | moderation | text | moderation | - |
190
+ | `gpt-image` | image_generation | text, image | image | - |
191
+ | `dall-e` | image_generation | text | image | - |
192
+ | `whisper` | audio_transcription | audio | text | - |
193
+ | `tts` | audio_generation | text | audio | - |
87
194
 
88
- | Gem | Purpose |
89
- |-----|---------|
90
- | `lex-llm` (>= 0.4.3) | Shared provider contract, response normalization, fleet settings, routing, and fleet responder execution |
91
- | `legion-transport` (>= 1.4.14) | AMQP subscriptions and replies |
92
- | `legion-json` (>= 1.2.1) | JSON serialization |
93
- | `legion-logging` (>= 1.3.2) | Structured logging via Helper |
94
- | `legion-settings` (>= 1.3.14) | Configuration management |
195
+ Unknown models default to `{ capabilities: [:completion, :streaming], modalities: { input: ["text"], output: ["text"] } }`.
196
+
197
+ ## Capability Predicates
198
+
199
+ `Provider::Capabilities` provides module functions for model routing decisions:
200
+
201
+ | Method | Matches |
202
+ |---|---|
203
+ | `chat?(model)` | Any model that is not embedding, moderation, image, audio, tts, realtime, or sora |
204
+ | `streaming?(model)` | Same as `chat?` |
205
+ | `functions?(model)` | Models starting with `gpt` or `o\d` |
206
+ | `vision?(model)` | Models starting with `gpt`, `o\d`, or `omni-moderation` |
207
+ | `embeddings?(model)` | Models starting with `text-embedding-` |
208
+ | `moderation?(model)` | Models containing `moderation` |
209
+ | `images?(model)` | Models starting with `gpt-image` or `dall-e` |
210
+ | `audio_transcription?(model)` | Models matching `gpt-4o.*transcribe` or `whisper` |
95
211
 
96
212
  ## Fleet Responder
97
213
 
98
- Provider instances can opt in to consuming Legion LLM fleet requests. The provider-owned fleet actor only starts when at least one configured instance enables `respond_to_requests`, and the runner delegates execution to `Legion::Extensions::Llm::Fleet::ProviderResponder` from `lex-llm`.
214
+ Provider instances can opt in to consuming Legion LLM fleet requests via the shared `ProviderResponder`. The fleet actor starts automatically when any instance has `respond_to_requests: true`.
215
+
216
+ ### Fleet YAML Configuration
99
217
 
100
218
  ```yaml
101
219
  extensions:
@@ -113,12 +231,49 @@ extensions:
113
231
  - image
114
232
  ```
115
233
 
234
+ ### Fleet Components
235
+
236
+ | Class | Role |
237
+ |---|---|
238
+ | `Actor::FleetWorker` | Subscription actor; checks `enabled?` against discovered instances |
239
+ | `Runners::FleetWorker.handle_fleet_request` | Execution entrypoint; delegates to `ProviderResponder.call` |
240
+
241
+ ## Observability
242
+
243
+ All classes include `Legion::Logging::Helper`:
244
+
245
+ - **Structured error handling:** Every `rescue` calls `handle_exception` with operation context
246
+ - **Debug-level request telemetry:** Model listing, retrieval, fleet dispatch, discovery refresh
247
+ - **Info-level action logging:** Registry publishing, instance discovery results
248
+ - **Automatic segment derivation:** Log lines tagged with provider family and component type
249
+
250
+ ## Dependencies
251
+
252
+ | Gem | Purpose |
253
+ |-----|---------|
254
+ | `lex-llm` (>= 0.4.3) | Provider contract, OpenAICompatible mixin, fleet responder, registry publisher |
255
+ | `legion-transport` (>= 1.4.14) | AMQP subscriptions and replies |
256
+ | `legion-json` (>= 1.2.1) | JSON serialization |
257
+ | `legion-logging` (>= 1.3.2) | Structured logging |
258
+ | `legion-settings` (>= 1.3.14) | Configuration management |
259
+
260
+ ## Key Files
261
+
262
+ | File | Purpose |
263
+ |------|---------|
264
+ | `lib/legion/extensions/llm/openai.rb` | Root module, settings, instance discovery, auto-registration |
265
+ | `lib/legion/extensions/llm/openai/provider.rb` | Provider implementation, capability map, API methods |
266
+ | `lib/legion/extensions/llm/openai/actors/discovery_refresh.rb` | Periodic model cache refresh actor |
267
+ | `lib/legion/extensions/llm/openai/actors/fleet_worker.rb` | Fleet request subscription actor |
268
+ | `lib/legion/extensions/llm/openai/runners/fleet_worker.rb` | Fleet request execution runner |
269
+ | `lib/legion/extensions/llm/openai/version.rb` | Version constant |
270
+
116
271
  ## Development
117
272
 
118
273
  ```bash
119
274
  bundle install
120
- bundle exec rspec --format json --out tmp/rspec_results.json --format progress --out tmp/rspec_progress.txt
121
- bundle exec rubocop -A
275
+ bundle exec rspec
276
+ bundle exec rubocop
122
277
  ```
123
278
 
124
279
  ## License
@@ -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
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require 'legion/extensions/actors/every'
5
+ rescue LoadError => e
6
+ warn(e.message) if $VERBOSE
7
+ end
8
+
9
+ return unless defined?(Legion::Extensions::Actors::Every)
10
+
11
+ module Legion
12
+ module Extensions
13
+ module Llm
14
+ module Openai
15
+ module Actor
16
+ # Periodically refreshes the OpenAI model discovery cache.
17
+ class DiscoveryRefresh < Legion::Extensions::Actors::Every
18
+ include Legion::Logging::Helper
19
+
20
+ REFRESH_INTERVAL = 1800
21
+
22
+ def runner_class = self.class
23
+ def runner_function = 'manual'
24
+ def run_now? = true
25
+ def use_runner? = false
26
+ def check_subtask? = false
27
+ def generate_task? = false
28
+
29
+ def time
30
+ return REFRESH_INTERVAL unless defined?(Legion::Settings)
31
+
32
+ Legion::Settings.dig(:extensions, :llm, :openai, :discovery_interval) || REFRESH_INTERVAL
33
+ end
34
+
35
+ def manual
36
+ log.debug('[openai][discovery_refresh] refreshing model list')
37
+ return unless defined?(Legion::LLM::Discovery)
38
+
39
+ Legion::LLM::Discovery.refresh_discovered_models!(provider: :openai)
40
+ rescue StandardError => e
41
+ handle_exception(e, level: :warn, handled: true, operation: 'openai.actor.discovery_refresh')
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ 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.10'
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.10
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
@@ -97,9 +97,11 @@ files:
97
97
  - README.md
98
98
  - lex-llm-openai.gemspec
99
99
  - lib/legion/extensions/llm/openai.rb
100
+ - lib/legion/extensions/llm/openai/actors/discovery_refresh.rb
100
101
  - lib/legion/extensions/llm/openai/actors/fleet_worker.rb
101
102
  - lib/legion/extensions/llm/openai/provider.rb
102
103
  - lib/legion/extensions/llm/openai/runners/fleet_worker.rb
104
+ - lib/legion/extensions/llm/openai/translator.rb
103
105
  - lib/legion/extensions/llm/openai/version.rb
104
106
  homepage: https://github.com/LegionIO/lex-llm-openai
105
107
  licenses: