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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 173ab613e57543956e39d70f4a38fc865bc6b6bac4e8dfe319be9c2928810f77
4
- data.tar.gz: 46c761a838aee6c3cebad151467555cba8ab70480e952ab741874c2d8acc13e8
3
+ metadata.gz: c70b5fdd6e77e60cbe437a5698e078b7aa88fb41dca96311dcf9a58585238476
4
+ data.tar.gz: cb9dd1f8aa71c33751ddd270acb849d964ed137b6b3fdd6c96e072082bc6e488
5
5
  SHA512:
6
- metadata.gz: 0f21f7288e4d8d374ea77d96ee3110b08a260a2e06ef6fd6372357b88abb5e936d2cbeae934720a0a5e17ad431bdce7027a3cd68e4fc60460c6cb5d0f02acc0a
7
- data.tar.gz: ecf15206364c5ef7d632c0c421294deb1929e508b4287828acbee91e4a4182fb0efd5fb12cca58d6909499b2b33197070bd4c19e232bed8f25fd12f86e2dd604
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 stream(message, tools: nil, system: nil, **options, &block)
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
- perform_stream(
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.map do |content_block|
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
@@ -45,6 +45,10 @@ module LlmGateway
45
45
  @state == :busy
46
46
  end
47
47
 
48
+ def idle?
49
+ @state == :idle
50
+ end
51
+
48
52
  def validate_queue!(queue)
49
53
  queue = queue.to_sym
50
54
  raise ArgumentError, "Invalid queue mode: #{queue}" unless QUEUES.include?(queue)
@@ -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
@@ -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; end
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmGateway
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.1"
5
5
  end
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.7.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-03 00:00:00.000000000 Z
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