llm_gateway 0.7.0 → 0.8.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/CHANGELOG.md +18 -0
- data/README.md +15 -0
- data/lib/llm_gateway/adapters/adapter.rb +29 -11
- data/lib/llm_gateway/adapters/normalized_stream_accumulator.rb +7 -1
- data/lib/llm_gateway/agents/harness.rb +7 -0
- data/lib/llm_gateway/agents/in_memory_session_manager.rb +4 -0
- data/lib/llm_gateway/clients/openai.rb +84 -1
- data/lib/llm_gateway/errors.rb +10 -1
- data/lib/llm_gateway/proxy/adapter.rb +49 -0
- data/lib/llm_gateway/proxy/client.rb +85 -0
- data/lib/llm_gateway/proxy/server.rb +65 -0
- data/lib/llm_gateway/version.rb +1 -1
- data/lib/llm_gateway.rb +8 -1
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c70b5fdd6e77e60cbe437a5698e078b7aa88fb41dca96311dcf9a58585238476
|
|
4
|
+
data.tar.gz: cb9dd1f8aa71c33751ddd270acb849d964ed137b6b3fdd6c96e072082bc6e488
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d21ab094dd5f600731e12541c6ba0a672eb8d0862c732ce2f5f2a415c394024ff87565dee5820e2d1dcec5ee665715d7beacc5eb5fb5a35d518e98b4c3c6c6e1
|
|
7
|
+
data.tar.gz: 97f4cabb1416beddc261fc849d23823722f9c36afb70843fcb1ef9e748e7f7eeec7f8bb4cfa0f92b25dbc864bf2c27e8751eeb9367f5c6233a906ac79d483219
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [v0.8.1](https://github.com/Hyper-Unearthing/llm_gateway/tree/v0.8.1) (2026-06-27)
|
|
4
|
+
|
|
5
|
+
[Full Changelog](https://github.com/Hyper-Unearthing/llm_gateway/compare/v0.8.0...v0.8.1)
|
|
6
|
+
|
|
7
|
+
**Merged pull requests:**
|
|
8
|
+
|
|
9
|
+
- fix: do not return empty assistant blocks [\#94](https://github.com/Hyper-Unearthing/llm_gateway/pull/94) ([billybonks](https://github.com/billybonks))
|
|
10
|
+
- feat: when codex hits a rate limit pass all the information [\#93](https://github.com/Hyper-Unearthing/llm_gateway/pull/93) ([billybonks](https://github.com/billybonks))
|
|
11
|
+
- Feat/fix proxy client [\#92](https://github.com/Hyper-Unearthing/llm_gateway/pull/92) ([billybonks](https://github.com/billybonks))
|
|
12
|
+
|
|
13
|
+
## [v0.8.0](https://github.com/Hyper-Unearthing/llm_gateway/tree/v0.8.0) (2026-06-06)
|
|
14
|
+
|
|
15
|
+
[Full Changelog](https://github.com/Hyper-Unearthing/llm_gateway/compare/v0.7.0...v0.8.0)
|
|
16
|
+
|
|
17
|
+
**Merged pull requests:**
|
|
18
|
+
|
|
19
|
+
- feat: create server proxy and corresponding client [\#89](https://github.com/Hyper-Unearthing/llm_gateway/pull/89) ([billybonks](https://github.com/billybonks))
|
|
20
|
+
|
|
3
21
|
## [v0.7.0](https://github.com/Hyper-Unearthing/llm_gateway/tree/v0.7.0) (2026-06-03)
|
|
4
22
|
|
|
5
23
|
[Full Changelog](https://github.com/Hyper-Unearthing/llm_gateway/compare/v0.6.0...v0.7.0)
|
data/README.md
CHANGED
|
@@ -916,6 +916,21 @@ puts result.content.select { |b| b.type == "text" }.map(&:text).join
|
|
|
916
916
|
|
|
917
917
|
If your app refreshes tokens in the background, rebuild the adapter (or recreate client state) with the newest `access_token` before subsequent calls.
|
|
918
918
|
|
|
919
|
+
### Codex rate-limit reset metadata
|
|
920
|
+
|
|
921
|
+
OpenAI Codex usage-limit responses include reset information on `LlmGateway::Errors::RateLimitError`:
|
|
922
|
+
|
|
923
|
+
```ruby
|
|
924
|
+
begin
|
|
925
|
+
adapter.stream("Hello from OAuth auth", model: "gpt-5.4")
|
|
926
|
+
rescue LlmGateway::Errors::RateLimitError => e
|
|
927
|
+
puts e.message # "The usage limit has been reached"
|
|
928
|
+
puts e.reset_after_seconds # primary reset window, when available
|
|
929
|
+
puts e.reset_at # Time for the primary reset, when available
|
|
930
|
+
puts e.rate_limit_info.inspect # full parsed Codex headers/body metadata
|
|
931
|
+
end
|
|
932
|
+
```
|
|
933
|
+
|
|
919
934
|
### Token refresh responsibility
|
|
920
935
|
|
|
921
936
|
#### Library’s role (llm_gateway)
|
|
@@ -5,32 +5,38 @@ require_relative "structs"
|
|
|
5
5
|
module LlmGateway
|
|
6
6
|
module Adapters
|
|
7
7
|
class Adapter
|
|
8
|
-
attr_reader :client
|
|
8
|
+
attr_reader :client, :provider_key
|
|
9
9
|
|
|
10
|
-
def initialize(client)
|
|
10
|
+
def initialize(client, provider_key: nil)
|
|
11
11
|
@client = client
|
|
12
|
+
@provider_key = provider_key
|
|
12
13
|
end
|
|
13
14
|
|
|
14
|
-
def
|
|
15
|
-
raise LlmGateway::Errors::MissingMapperForProvider, "No stream_mapper configured" unless stream_mapper
|
|
16
|
-
|
|
15
|
+
def raw_stream(message, tools: nil, system: nil, **options, &block)
|
|
17
16
|
normalized_input = map_input({
|
|
18
17
|
messages: sanitize_messages(normalize_messages(message), target_model: options[:model]),
|
|
19
18
|
tools: tools,
|
|
20
19
|
system: normalize_system(system)
|
|
21
20
|
})
|
|
22
21
|
|
|
22
|
+
perform_stream(
|
|
23
|
+
normalized_input[:messages],
|
|
24
|
+
tools: normalized_input[:tools],
|
|
25
|
+
system: normalized_input[:system],
|
|
26
|
+
**map_options(options),
|
|
27
|
+
&block
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def stream(message, tools: nil, system: nil, **options, &block)
|
|
32
|
+
raise LlmGateway::Errors::MissingMapperForProvider, "No stream_mapper configured" unless stream_mapper
|
|
33
|
+
|
|
23
34
|
mapper = stream_mapper.new(
|
|
24
35
|
provider: LlmGateway::Client.provider_id_from_client(client),
|
|
25
36
|
api: api_name
|
|
26
37
|
)
|
|
27
38
|
|
|
28
|
-
|
|
29
|
-
normalized_input[:messages],
|
|
30
|
-
tools: normalized_input[:tools],
|
|
31
|
-
system: normalized_input[:system],
|
|
32
|
-
**map_options(options)
|
|
33
|
-
) do |chunk|
|
|
39
|
+
raw_stream(message, tools: tools, system: system, **options) do |chunk|
|
|
34
40
|
mapper.map(chunk, &block)
|
|
35
41
|
end
|
|
36
42
|
|
|
@@ -89,6 +95,18 @@ module LlmGateway
|
|
|
89
95
|
client.stream(messages, tools: tools, system: system, **options, &block)
|
|
90
96
|
end
|
|
91
97
|
|
|
98
|
+
public
|
|
99
|
+
|
|
100
|
+
def stream_api_name
|
|
101
|
+
api_name
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def stream_mapper_class
|
|
105
|
+
stream_mapper
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
92
110
|
def api_name
|
|
93
111
|
self.class.name.split("::").last.gsub(/Adapter$/, "").downcase
|
|
94
112
|
end
|
|
@@ -330,7 +330,9 @@ module LlmGateway
|
|
|
330
330
|
end
|
|
331
331
|
|
|
332
332
|
def serialized_blocks
|
|
333
|
-
blocks.compact.
|
|
333
|
+
blocks.compact.filter_map do |content_block|
|
|
334
|
+
next if blank_text_block?(content_block)
|
|
335
|
+
|
|
334
336
|
if [ "tool_use", "server_tool_use" ].include?(content_block[:type])
|
|
335
337
|
next content_block.merge(input: parse_tool_input(content_block[:input]).deep_symbolize_keys)
|
|
336
338
|
end
|
|
@@ -348,6 +350,10 @@ module LlmGateway
|
|
|
348
350
|
end
|
|
349
351
|
end
|
|
350
352
|
|
|
353
|
+
def blank_text_block?(content_block)
|
|
354
|
+
content_block[:type] == "text" && content_block[:text].to_s.blank?
|
|
355
|
+
end
|
|
356
|
+
|
|
351
357
|
def parse_tool_input(input)
|
|
352
358
|
return {} if input.blank?
|
|
353
359
|
|
|
@@ -128,6 +128,11 @@ module LlmGateway
|
|
|
128
128
|
end
|
|
129
129
|
|
|
130
130
|
def enqueue_or_run_message(message, queue, &block)
|
|
131
|
+
if session_manager.idle?
|
|
132
|
+
drain_queue(:steer)
|
|
133
|
+
drain_queue(:next_turn)
|
|
134
|
+
drain_queue(:follow_up)
|
|
135
|
+
end
|
|
131
136
|
prepared_input = LlmGateway::Utils.deep_symbolize_keys(message)
|
|
132
137
|
result = session_manager.start_or_enqueue_user_message(prepared_input, queue: queue) do
|
|
133
138
|
compact_if_needed
|
|
@@ -135,6 +140,8 @@ module LlmGateway
|
|
|
135
140
|
return if result == session_manager.class::MESSAGE_QUEUED
|
|
136
141
|
|
|
137
142
|
begin
|
|
143
|
+
|
|
144
|
+
|
|
138
145
|
continue(&block)
|
|
139
146
|
|
|
140
147
|
loop do
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
3
5
|
require_relative "../base_client"
|
|
4
6
|
|
|
5
7
|
module LlmGateway
|
|
@@ -179,6 +181,87 @@ module LlmGateway
|
|
|
179
181
|
}
|
|
180
182
|
end
|
|
181
183
|
|
|
184
|
+
def rate_limit_error_options(response, error)
|
|
185
|
+
info = rate_limit_info(response, error)
|
|
186
|
+
return {} if info.empty?
|
|
187
|
+
|
|
188
|
+
{
|
|
189
|
+
reset_at: info[:primary_reset_at] || info[:reset_at],
|
|
190
|
+
reset_after_seconds: info[:primary_reset_after_seconds] || info[:reset_after_seconds],
|
|
191
|
+
rate_limit_info: info
|
|
192
|
+
}
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def rate_limit_info(response, error)
|
|
196
|
+
headers = response_headers(response)
|
|
197
|
+
codex_headers = headers.select { |key, _value| key.start_with?("x-codex-") }
|
|
198
|
+
info = {}
|
|
199
|
+
|
|
200
|
+
info[:provider] = "openai_codex" if codex_headers.any?
|
|
201
|
+
info[:error_type] = error["type"]
|
|
202
|
+
info[:plan_type] = headers["x-codex-plan-type"] || error["plan_type"]
|
|
203
|
+
info[:active_limit] = headers["x-codex-active-limit"]
|
|
204
|
+
info[:primary_used_percent] = integer_header(headers, "x-codex-primary-used-percent")
|
|
205
|
+
info[:secondary_used_percent] = integer_header(headers, "x-codex-secondary-used-percent")
|
|
206
|
+
info[:primary_window_minutes] = integer_header(headers, "x-codex-primary-window-minutes")
|
|
207
|
+
info[:secondary_window_minutes] = integer_header(headers, "x-codex-secondary-window-minutes")
|
|
208
|
+
info[:primary_over_secondary_limit_percent] = integer_header(headers, "x-codex-primary-over-secondary-limit-percent")
|
|
209
|
+
info[:primary_reset_after_seconds] = integer_header(headers, "x-codex-primary-reset-after-seconds")
|
|
210
|
+
info[:secondary_reset_after_seconds] = integer_header(headers, "x-codex-secondary-reset-after-seconds")
|
|
211
|
+
info[:primary_reset_at] = epoch_time(headers["x-codex-primary-reset-at"])
|
|
212
|
+
info[:secondary_reset_at] = epoch_time(headers["x-codex-secondary-reset-at"])
|
|
213
|
+
info[:credits_has_credits] = boolean_header(headers, "x-codex-credits-has-credits")
|
|
214
|
+
info[:credits_balance] = integer_header(headers, "x-codex-credits-balance")
|
|
215
|
+
info[:credits_unlimited] = boolean_header(headers, "x-codex-credits-unlimited")
|
|
216
|
+
info[:reset_after_seconds] = integer_value(error["resets_in_seconds"]) || retry_after_seconds(headers["retry-after"])
|
|
217
|
+
info[:reset_at] = epoch_time(error["resets_at"]) || retry_after_time(headers["retry-after"])
|
|
218
|
+
|
|
219
|
+
info.compact
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def response_headers(response)
|
|
223
|
+
headers = {}
|
|
224
|
+
response.each_header { |key, value| headers[key.downcase] = value }
|
|
225
|
+
headers
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def integer_header(headers, key)
|
|
229
|
+
integer_value(headers[key])
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def integer_value(value)
|
|
233
|
+
return nil if value.nil?
|
|
234
|
+
|
|
235
|
+
Integer(value)
|
|
236
|
+
rescue ArgumentError, TypeError
|
|
237
|
+
nil
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def boolean_header(headers, key)
|
|
241
|
+
case headers[key].to_s.downcase
|
|
242
|
+
when "true" then true
|
|
243
|
+
when "false" then false
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def epoch_time(value)
|
|
248
|
+
integer = integer_value(value)
|
|
249
|
+
Time.at(integer) if integer
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def retry_after_seconds(value)
|
|
253
|
+
integer_value(value)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def retry_after_time(value)
|
|
257
|
+
seconds = retry_after_seconds(value)
|
|
258
|
+
return Time.now + seconds if seconds
|
|
259
|
+
|
|
260
|
+
Time.httpdate(value)
|
|
261
|
+
rescue ArgumentError, TypeError
|
|
262
|
+
nil
|
|
263
|
+
end
|
|
264
|
+
|
|
182
265
|
def handle_client_specific_errors(response, error)
|
|
183
266
|
# OpenAI uses 'code' instead of 'type' for error codes
|
|
184
267
|
error_code = error["code"]
|
|
@@ -190,7 +273,7 @@ module LlmGateway
|
|
|
190
273
|
|
|
191
274
|
case response.code.to_i
|
|
192
275
|
when 429
|
|
193
|
-
raise Errors::RateLimitError.new(error_message, error_code)
|
|
276
|
+
raise Errors::RateLimitError.new(error_message, error_code, **rate_limit_error_options(response, error))
|
|
194
277
|
when 503
|
|
195
278
|
raise Errors::OverloadError.new(error_message, error_code)
|
|
196
279
|
end
|
data/lib/llm_gateway/errors.rb
CHANGED
|
@@ -19,7 +19,16 @@ module LlmGateway
|
|
|
19
19
|
class NotFoundError < ClientError; end
|
|
20
20
|
class ConflictError < ClientError; end
|
|
21
21
|
class UnprocessableEntityError < ClientError; end
|
|
22
|
-
class RateLimitError < ClientError
|
|
22
|
+
class RateLimitError < ClientError
|
|
23
|
+
attr_reader :reset_at, :reset_after_seconds, :rate_limit_info
|
|
24
|
+
|
|
25
|
+
def initialize(message = nil, code = nil, reset_at: nil, reset_after_seconds: nil, rate_limit_info: {})
|
|
26
|
+
@reset_at = reset_at
|
|
27
|
+
@reset_after_seconds = reset_after_seconds
|
|
28
|
+
@rate_limit_info = rate_limit_info
|
|
29
|
+
super(message, code)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
23
32
|
class InternalServerError < ClientError; end
|
|
24
33
|
class APIStatusError < ClientError; end
|
|
25
34
|
class APITimeoutError < ClientError; end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmGateway
|
|
4
|
+
module Proxy
|
|
5
|
+
class Adapter
|
|
6
|
+
attr_reader :client, :provider_key
|
|
7
|
+
|
|
8
|
+
def initialize(client, provider_key: nil)
|
|
9
|
+
@client = client
|
|
10
|
+
@provider_key = provider_key
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def stream(message, tools: nil, system: nil, **options, &block)
|
|
14
|
+
target_adapter = LlmGateway.build_provider(client.target_config.merge(provider: client.target_provider))
|
|
15
|
+
mapper_class = target_adapter.stream_mapper_class
|
|
16
|
+
raise LlmGateway::Errors::MissingMapperForProvider, "No stream_mapper configured" unless mapper_class
|
|
17
|
+
|
|
18
|
+
mapper = mapper_class.new(
|
|
19
|
+
provider: LlmGateway::Client.provider_id_from_client(target_adapter.client),
|
|
20
|
+
api: target_adapter.stream_api_name
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
client.stream(normalize_messages(message), tools: tools, system: normalize_system(system), **options) do |chunk|
|
|
24
|
+
mapper.map(chunk, &block)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
mapper.result
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def normalize_system(system)
|
|
33
|
+
if system.nil?
|
|
34
|
+
[]
|
|
35
|
+
elsif system.is_a?(String)
|
|
36
|
+
[ { role: "system", content: system } ]
|
|
37
|
+
elsif system.is_a?(Array)
|
|
38
|
+
system
|
|
39
|
+
else
|
|
40
|
+
raise ArgumentError, "System parameter must be a string or array, got #{system.class}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def normalize_messages(message)
|
|
45
|
+
message.is_a?(String) ? [ { role: "user", content: message } ] : message
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module LlmGateway
|
|
8
|
+
module Proxy
|
|
9
|
+
class Client
|
|
10
|
+
attr_reader :url, :target_provider, :target_config, :path
|
|
11
|
+
|
|
12
|
+
def initialize(url:, target_provider:, target_config: {}, api_key: nil, path: "/agent/llm_proxy", **_options)
|
|
13
|
+
@url = url.to_s.sub(%r{/+\z}, "")
|
|
14
|
+
@target_provider = target_provider.to_s
|
|
15
|
+
@target_config = (target_config || {}).transform_keys(&:to_sym)
|
|
16
|
+
@api_key = api_key
|
|
17
|
+
@path = path.to_s.start_with?("/") ? path.to_s : "/#{path}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def stream(messages, tools: nil, system: nil, **options, &block)
|
|
21
|
+
uri = URI("#{url}#{path}")
|
|
22
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
23
|
+
http.use_ssl = uri.scheme == "https"
|
|
24
|
+
http.read_timeout = 480
|
|
25
|
+
http.open_timeout = 10
|
|
26
|
+
|
|
27
|
+
request = Net::HTTP::Post.new(uri)
|
|
28
|
+
request["content-type"] = "application/json"
|
|
29
|
+
request["accept"] = "text/event-stream"
|
|
30
|
+
request["accept-encoding"] = "identity"
|
|
31
|
+
request["authorization"] = "Bearer #{@api_key}" if @api_key
|
|
32
|
+
request.body = {
|
|
33
|
+
provider: target_provider,
|
|
34
|
+
config: target_config,
|
|
35
|
+
messages: messages,
|
|
36
|
+
system: system,
|
|
37
|
+
tools: tools,
|
|
38
|
+
options: options
|
|
39
|
+
}.to_json
|
|
40
|
+
|
|
41
|
+
http.request(request) do |response|
|
|
42
|
+
unless response.code.to_i == 200
|
|
43
|
+
body = +""
|
|
44
|
+
response.read_body { |chunk| body << chunk }
|
|
45
|
+
raise Errors::APIStatusError.new("Proxy request failed with status #{response.code}: #{body}", nil)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
parse_sse_stream(response, &block)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def parse_sse_stream(response)
|
|
55
|
+
buffer = +""
|
|
56
|
+
response.read_body do |chunk|
|
|
57
|
+
buffer << chunk
|
|
58
|
+
while (idx = buffer.index("\n\n"))
|
|
59
|
+
raw_event = buffer.slice!(0, idx + 2)
|
|
60
|
+
event_type = nil
|
|
61
|
+
data_lines = []
|
|
62
|
+
|
|
63
|
+
raw_event.each_line do |line|
|
|
64
|
+
line = line.chomp
|
|
65
|
+
event_type = line.sub(/^event:\s*/, "") if line.start_with?("event:")
|
|
66
|
+
data_lines << line.sub(/^data:\s*/, "") if line.start_with?("data:")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
next if data_lines.empty?
|
|
70
|
+
|
|
71
|
+
data_str = data_lines.join("\n")
|
|
72
|
+
next if data_str == "[DONE]"
|
|
73
|
+
|
|
74
|
+
data = begin
|
|
75
|
+
JSON.parse(data_str).deep_symbolize_keys
|
|
76
|
+
rescue JSON::ParserError
|
|
77
|
+
{ raw: data_str }
|
|
78
|
+
end
|
|
79
|
+
yield({ event: event_type, data: data })
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LlmGateway
|
|
6
|
+
module Proxy
|
|
7
|
+
class Server
|
|
8
|
+
PATH = "/agent/llm_proxy"
|
|
9
|
+
|
|
10
|
+
def call(env)
|
|
11
|
+
return not_found unless env["REQUEST_METHOD"] == "POST" && env["PATH_INFO"] == PATH
|
|
12
|
+
|
|
13
|
+
request = JSON.parse(env["rack.input"].read).deep_symbolize_keys
|
|
14
|
+
options = request[:options] || {}
|
|
15
|
+
options = options.merge(model: request[:model]) if request.key?(:model)
|
|
16
|
+
adapter = build_adapter(request)
|
|
17
|
+
|
|
18
|
+
body = Enumerator.new do |yielder|
|
|
19
|
+
adapter.raw_stream(
|
|
20
|
+
request[:messages],
|
|
21
|
+
system: request[:system],
|
|
22
|
+
tools: request[:tools],
|
|
23
|
+
**options
|
|
24
|
+
) do |chunk|
|
|
25
|
+
yielder << encode_sse(chunk)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
[ 200, { "content-type" => "text/event-stream", "cache-control" => "no-cache" }, body ]
|
|
30
|
+
rescue KeyError, JSON::ParserError, ArgumentError => e
|
|
31
|
+
json_error(400, e.message)
|
|
32
|
+
rescue Errors::UnsupportedProvider => e
|
|
33
|
+
json_error(404, e.message)
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
json_error(500, e.message)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def build_adapter(request)
|
|
41
|
+
provider = request.fetch(:provider)
|
|
42
|
+
config = (request[:config] || {}).merge(provider: provider)
|
|
43
|
+
|
|
44
|
+
LlmGateway.build_provider(config)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def encode_sse(chunk)
|
|
48
|
+
event = chunk[:event]
|
|
49
|
+
data = chunk[:data]
|
|
50
|
+
out = +""
|
|
51
|
+
out << "event: #{event}\n" if event
|
|
52
|
+
JSON.generate(data).each_line { |line| out << "data: #{line.chomp}\n" }
|
|
53
|
+
out << "\n"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def json_error(status, message)
|
|
57
|
+
[ status, { "content-type" => "application/json" }, [ { error: message }.to_json ] ]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def not_found
|
|
61
|
+
[ 404, { "content-type" => "application/json" }, [ { error: "Not found" }.to_json ] ]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/llm_gateway/version.rb
CHANGED
data/lib/llm_gateway.rb
CHANGED
|
@@ -48,6 +48,9 @@ require_relative "llm_gateway/adapters/groq/chat_completions_adapter"
|
|
|
48
48
|
|
|
49
49
|
# Load provider registry
|
|
50
50
|
require_relative "llm_gateway/provider_registry"
|
|
51
|
+
require_relative "llm_gateway/proxy/client"
|
|
52
|
+
require_relative "llm_gateway/proxy/adapter"
|
|
53
|
+
require_relative "llm_gateway/proxy/server"
|
|
51
54
|
|
|
52
55
|
module LlmGateway
|
|
53
56
|
class Error < StandardError; end
|
|
@@ -110,7 +113,7 @@ module LlmGateway
|
|
|
110
113
|
entry = ProviderRegistry.resolve(provider_name)
|
|
111
114
|
|
|
112
115
|
client = entry[:client].new(**config)
|
|
113
|
-
entry[:adapter].new(client)
|
|
116
|
+
entry[:adapter].new(client, provider_key: provider_name)
|
|
114
117
|
end
|
|
115
118
|
|
|
116
119
|
def self.configure(configs)
|
|
@@ -160,4 +163,8 @@ module LlmGateway
|
|
|
160
163
|
ProviderRegistry.register("openai_codex",
|
|
161
164
|
client: Clients::OpenAI,
|
|
162
165
|
adapter: Adapters::OpenAICodex::ResponsesAdapter)
|
|
166
|
+
|
|
167
|
+
ProviderRegistry.register("proxy",
|
|
168
|
+
client: Proxy::Client,
|
|
169
|
+
adapter: Proxy::Adapter)
|
|
163
170
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: llm_gateway
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- billybonks
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-06-
|
|
11
|
+
date: 2026-06-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: dry-struct
|
|
@@ -97,6 +97,9 @@ files:
|
|
|
97
97
|
- lib/llm_gateway/errors.rb
|
|
98
98
|
- lib/llm_gateway/prompt.rb
|
|
99
99
|
- lib/llm_gateway/provider_registry.rb
|
|
100
|
+
- lib/llm_gateway/proxy/adapter.rb
|
|
101
|
+
- lib/llm_gateway/proxy/client.rb
|
|
102
|
+
- lib/llm_gateway/proxy/server.rb
|
|
100
103
|
- lib/llm_gateway/tool.rb
|
|
101
104
|
- lib/llm_gateway/utils.rb
|
|
102
105
|
- lib/llm_gateway/version.rb
|