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 +4 -4
- data/.rubocop.yml +4 -2
- data/CHANGELOG.md +13 -0
- data/Gemfile +1 -2
- data/README.md +214 -59
- data/lex-llm-openai.gemspec +1 -1
- data/lib/legion/extensions/llm/openai/actors/discovery_refresh.rb +48 -0
- 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 +5 -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,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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|--
|
|
34
|
-
|
|
|
35
|
-
|--
|
|
36
|
-
|
|
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
|
-
|
|
72
|
+
### Design Boundaries
|
|
40
73
|
|
|
41
|
-
|
|
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
|
-
|
|
79
|
+
## Instance Discovery
|
|
44
80
|
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
63
|
-
#
|
|
64
|
-
#
|
|
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
|
-
##
|
|
174
|
+
## Model Capability Map
|
|
71
175
|
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
|
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
|
|
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
|
|
121
|
-
bundle exec rubocop
|
|
275
|
+
bundle exec rspec
|
|
276
|
+
bundle exec rubocop
|
|
122
277
|
```
|
|
123
278
|
|
|
124
279
|
## License
|
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
|
|
@@ -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
|
|
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
|
|
@@ -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:
|