llm_gateway 0.7.0 → 0.8.0

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: 5cd3b8b829adb3e5793ac604aa50c40bdd94546a5c2fc0b90125ca9a75efe4c2
4
+ data.tar.gz: 28e7211450893a23c2116d1cf96010fe5dd359c6203ecbde6802dda89f06f153
5
5
  SHA512:
6
- metadata.gz: 0f21f7288e4d8d374ea77d96ee3110b08a260a2e06ef6fd6372357b88abb5e936d2cbeae934720a0a5e17ad431bdce7027a3cd68e4fc60460c6cb5d0f02acc0a
7
- data.tar.gz: ecf15206364c5ef7d632c0c421294deb1929e508b4287828acbee91e4a4182fb0efd5fb12cca58d6909499b2b33197070bd4c19e232bed8f25fd12f86e2dd604
6
+ metadata.gz: f51eb7792fd0886403c9d3fec068dcd8f00d50c35df747e6735b470ccc170e8129bc8fe5ef18b8cd117a763e95b8cf9bc76c54364ba5c8b5f284f3e0b5ef69a5
7
+ data.tar.gz: c0de665f7e70b60814c8ea8185b34205de309736d4c578fee1d48de4990d76b4d437c1c1b74672d7f57c67b115e244e421f4b17882ea34cb875dcd5ed31dec59
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [v0.8.0](https://github.com/Hyper-Unearthing/llm_gateway/tree/v0.8.0) (2026-06-06)
4
+
5
+ [Full Changelog](https://github.com/Hyper-Unearthing/llm_gateway/compare/v0.7.0...v0.8.0)
6
+
7
+ **Merged pull requests:**
8
+
9
+ - feat: create server proxy and corresponding client [\#89](https://github.com/Hyper-Unearthing/llm_gateway/pull/89) ([billybonks](https://github.com/billybonks))
10
+
3
11
  ## [v0.7.0](https://github.com/Hyper-Unearthing/llm_gateway/tree/v0.7.0) (2026-06-03)
4
12
 
5
13
  [Full Changelog](https://github.com/Hyper-Unearthing/llm_gateway/compare/v0.6.0...v0.7.0)
@@ -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
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmGateway
4
+ module Proxy
5
+ class Adapter
6
+ attr_reader :client
7
+
8
+ def initialize(client)
9
+ @client = client
10
+ end
11
+
12
+ def stream(message, tools: nil, system: nil, **options, &block)
13
+ target_adapter = LlmGateway.build_provider(client.target_config.merge(provider: client.target_provider))
14
+ mapper_class = target_adapter.stream_mapper_class
15
+ raise LlmGateway::Errors::MissingMapperForProvider, "No stream_mapper configured" unless mapper_class
16
+
17
+ mapper = mapper_class.new(
18
+ provider: LlmGateway::Client.provider_id_from_client(target_adapter.client),
19
+ api: target_adapter.stream_api_name
20
+ )
21
+
22
+ client.stream(normalize_messages(message), tools: tools, system: normalize_system(system), **options) do |chunk|
23
+ mapper.map(chunk, &block)
24
+ end
25
+
26
+ mapper.result
27
+ end
28
+
29
+ private
30
+
31
+ def normalize_system(system)
32
+ if system.nil?
33
+ []
34
+ elsif system.is_a?(String)
35
+ [ { role: "system", content: system } ]
36
+ elsif system.is_a?(Array)
37
+ system
38
+ else
39
+ raise ArgumentError, "System parameter must be a string or array, got #{system.class}"
40
+ end
41
+ end
42
+
43
+ def normalize_messages(message)
44
+ message.is_a?(String) ? [ { role: "user", content: message } ] : message
45
+ end
46
+ end
47
+ end
48
+ 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.0"
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.0
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-06 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