llm_gateway 0.2.0 → 0.4.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.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/README.md +565 -129
  4. data/Rakefile +8 -3
  5. data/docs/migration-guide.md +135 -0
  6. data/lib/llm_gateway/adapters/adapter.rb +173 -0
  7. data/lib/llm_gateway/adapters/anthropic/acts_like_messages.rb +23 -0
  8. data/lib/llm_gateway/adapters/anthropic/bidirectional_message_mapper.rb +111 -0
  9. data/lib/llm_gateway/adapters/{claude → anthropic}/input_mapper.rb +12 -10
  10. data/lib/llm_gateway/adapters/anthropic/messages_adapter.rb +19 -0
  11. data/lib/llm_gateway/adapters/anthropic/output_mapper.rb +50 -0
  12. data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +110 -0
  13. data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +53 -0
  14. data/lib/llm_gateway/adapters/groq/chat_completions_adapter.rb +47 -0
  15. data/lib/llm_gateway/adapters/groq/option_mapper.rb +27 -0
  16. data/lib/llm_gateway/adapters/input_message_sanitizer.rb +93 -0
  17. data/lib/llm_gateway/adapters/openai/acts_like_chat_completions.rb +22 -0
  18. data/lib/llm_gateway/adapters/openai/acts_like_responses.rb +31 -0
  19. data/lib/llm_gateway/adapters/openai/chat_completions/bidirectional_message_mapper.rb +110 -0
  20. data/lib/llm_gateway/adapters/openai/chat_completions/input_mapper.rb +105 -0
  21. data/lib/llm_gateway/adapters/openai/chat_completions/input_message_sanitizer.rb +65 -0
  22. data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +39 -0
  23. data/lib/llm_gateway/adapters/openai/chat_completions/output_mapper.rb +40 -0
  24. data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +242 -0
  25. data/lib/llm_gateway/adapters/openai/chat_completions_adapter.rb +20 -0
  26. data/lib/llm_gateway/adapters/openai/file_output_mapper.rb +25 -0
  27. data/lib/llm_gateway/adapters/openai/prompt_cache_option_mapper.rb +39 -0
  28. data/lib/llm_gateway/adapters/openai/responses/bidirectional_message_mapper.rb +120 -0
  29. data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +106 -0
  30. data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +41 -0
  31. data/lib/llm_gateway/adapters/openai/responses/output_mapper.rb +47 -0
  32. data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +340 -0
  33. data/lib/llm_gateway/adapters/openai/responses_adapter.rb +20 -0
  34. data/lib/llm_gateway/adapters/openai_codex/input_mapper.rb +206 -0
  35. data/lib/llm_gateway/adapters/openai_codex/option_mapper.rb +28 -0
  36. data/lib/llm_gateway/adapters/openai_codex/responses_adapter.rb +38 -0
  37. data/lib/llm_gateway/adapters/{open_ai/output_mapper.rb → option_mapper.rb} +5 -2
  38. data/lib/llm_gateway/adapters/stream_accumulator.rb +91 -0
  39. data/lib/llm_gateway/adapters/structs.rb +145 -0
  40. data/lib/llm_gateway/base_client.rb +97 -1
  41. data/lib/llm_gateway/client.rb +66 -54
  42. data/lib/llm_gateway/clients/anthropic.rb +167 -0
  43. data/lib/llm_gateway/clients/claude_code/oauth_flow.rb +162 -0
  44. data/lib/llm_gateway/clients/claude_code/token_manager.rb +112 -0
  45. data/lib/llm_gateway/clients/groq.rb +54 -0
  46. data/lib/llm_gateway/clients/openai.rb +208 -0
  47. data/lib/llm_gateway/clients/openai_codex/oauth_flow.rb +258 -0
  48. data/lib/llm_gateway/clients/openai_codex/token_manager.rb +71 -0
  49. data/lib/llm_gateway/errors.rb +23 -0
  50. data/lib/llm_gateway/prompt.rb +12 -1
  51. data/lib/llm_gateway/provider_registry.rb +37 -0
  52. data/lib/llm_gateway/version.rb +1 -1
  53. data/lib/llm_gateway.rb +169 -10
  54. data/scripts/create_anthropic_credentials.rb +106 -0
  55. data/scripts/create_openai_codex_credentials.rb +116 -0
  56. data/scripts/generate_handoff_live_fixture.rb +169 -0
  57. data/scripts/generate_handoff_media_fixture.rb +167 -0
  58. metadata +64 -21
  59. data/lib/llm_gateway/adapters/claude/client.rb +0 -56
  60. data/lib/llm_gateway/adapters/claude/output_mapper.rb +0 -30
  61. data/lib/llm_gateway/adapters/groq/client.rb +0 -58
  62. data/lib/llm_gateway/adapters/groq/input_mapper.rb +0 -105
  63. data/lib/llm_gateway/adapters/groq/output_mapper.rb +0 -62
  64. data/lib/llm_gateway/adapters/open_ai/client.rb +0 -59
  65. data/lib/llm_gateway/adapters/open_ai/input_mapper.rb +0 -63
  66. data/sample/claude_code_clone/agent.rb +0 -65
  67. data/sample/claude_code_clone/claude_code_clone.rb +0 -40
  68. data/sample/claude_code_clone/prompt.rb +0 -79
  69. data/sample/claude_code_clone/run.rb +0 -47
  70. data/sample/claude_code_clone/tools/bash_tool.rb +0 -54
  71. data/sample/claude_code_clone/tools/edit_tool.rb +0 -61
  72. data/sample/claude_code_clone/tools/grep_tool.rb +0 -113
  73. data/sample/claude_code_clone/tools/read_tool.rb +0 -61
  74. data/sample/claude_code_clone/tools/todowrite_tool.rb +0 -98
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "securerandom"
6
+ require "digest"
7
+ require "base64"
8
+ require "uri"
9
+ require "time"
10
+
11
+ module LlmGateway
12
+ module Clients
13
+ class OpenAI
14
+ class OAuthFlow
15
+ CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
16
+ AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"
17
+ TOKEN_URL = "https://auth.openai.com/oauth/token"
18
+ REDIRECT_URI = "http://localhost:1455/auth/callback"
19
+ SCOPE = "openid profile email offline_access"
20
+ JWT_CLAIM_PATH = "https://api.openai.com/auth"
21
+
22
+ attr_reader :client_id, :redirect_uri, :scope
23
+
24
+ def initialize(
25
+ client_id: CLIENT_ID,
26
+ redirect_uri: REDIRECT_URI,
27
+ scope: SCOPE
28
+ )
29
+ @client_id = client_id
30
+ @redirect_uri = redirect_uri
31
+ @scope = scope
32
+ end
33
+
34
+ # Step 1: Generate the authorization URL and PKCE values.
35
+ # Returns { authorization_url:, code_verifier:, state: }
36
+ def start(state: SecureRandom.hex(16))
37
+ code_verifier, code_challenge = generate_pkce
38
+
39
+ {
40
+ authorization_url: build_authorization_url(code_challenge, state),
41
+ code_verifier: code_verifier,
42
+ state: state
43
+ }
44
+ end
45
+
46
+ # Step 2: Exchange the authorization code for tokens.
47
+ # Accepts a raw code string, a full redirect URL, or code#state format.
48
+ # Returns { access_token:, refresh_token:, expires_at:, account_id: }
49
+ def exchange_code(input, code_verifier, expected_state: nil)
50
+ code = parse_authorization_input(input, expected_state)
51
+ raise ArgumentError, "Missing authorization code" unless code
52
+
53
+ uri = URI(TOKEN_URL)
54
+ http = Net::HTTP.new(uri.host, uri.port)
55
+ http.use_ssl = true
56
+ http.read_timeout = 30
57
+ http.open_timeout = 10
58
+
59
+ request = Net::HTTP::Post.new(uri)
60
+ request["Content-Type"] = "application/x-www-form-urlencoded"
61
+ request.body = URI.encode_www_form(
62
+ grant_type: "authorization_code",
63
+ client_id: @client_id,
64
+ code: code,
65
+ code_verifier: code_verifier,
66
+ redirect_uri: @redirect_uri
67
+ )
68
+
69
+ response = http.request(request)
70
+
71
+ if response.code.to_i == 200
72
+ data = JSON.parse(response.body)
73
+
74
+ unless data["access_token"] && data["refresh_token"] && data["expires_in"]
75
+ raise "Token response missing required fields: #{data.keys.join(", ")}"
76
+ end
77
+
78
+ expires_at = Time.now + data["expires_in"].to_i
79
+ account_id = self.class.extract_account_id_from_token(data["access_token"])
80
+ raise "Failed to extract account_id from access token" unless account_id
81
+
82
+ {
83
+ access_token: data["access_token"],
84
+ refresh_token: data["refresh_token"],
85
+ expires_at: expires_at,
86
+ account_id: account_id
87
+ }
88
+ else
89
+ error_body = parse_error_body(response.body)
90
+ raise "OAuth token exchange failed (#{response.code}): #{error_body["error_description"] || error_body["error"] || response.body}"
91
+ end
92
+ end
93
+
94
+ # Parse a callback URL (or query string) into { code:, state: }
95
+ def parse_callback(callback_url)
96
+ uri = URI.parse(callback_url)
97
+ params = URI.decode_www_form(uri.query.to_s).to_h
98
+ code = params["code"]
99
+ raise ArgumentError, "Callback URL is missing code parameter" if code.nil? || code.empty?
100
+
101
+ { code: code, state: params["state"] }
102
+ rescue URI::InvalidURIError => e
103
+ raise ArgumentError, "Invalid callback URL: #{e.message}"
104
+ end
105
+
106
+ # Interactive OAuth flow: print URL, wait for paste, return tokens.
107
+ # Returns { access_token:, refresh_token:, expires_at:, account_id: }
108
+ def login
109
+ flow = start
110
+
111
+ puts "Open this URL to authorize with OpenAI:"
112
+ puts flow[:authorization_url]
113
+ puts
114
+ puts "After logging in your browser will redirect to localhost (the page won't load)."
115
+ puts "Copy the full URL from your browser's address bar and paste it below."
116
+ puts
117
+ print "Paste the redirect URL (or authorization code): "
118
+
119
+ tty = File.open("/dev/tty", "r")
120
+ input = tty.gets&.strip
121
+ tty.close
122
+
123
+ raise "No authorization code provided" if input.nil? || input.empty?
124
+
125
+ exchange_code(input, flow[:code_verifier], expected_state: flow[:state])
126
+ end
127
+
128
+ # Refresh an existing access token (class method).
129
+ # Returns { access_token:, refresh_token:, expires_at:, account_id: }
130
+ def self.refresh_access_token(refresh_token, client_id: CLIENT_ID)
131
+ uri = URI(TOKEN_URL)
132
+ http = Net::HTTP.new(uri.host, uri.port)
133
+ http.use_ssl = true
134
+ http.read_timeout = 30
135
+ http.open_timeout = 10
136
+
137
+ request = Net::HTTP::Post.new(uri)
138
+ request["Content-Type"] = "application/x-www-form-urlencoded"
139
+ request.body = URI.encode_www_form(
140
+ grant_type: "refresh_token",
141
+ refresh_token: refresh_token,
142
+ client_id: client_id
143
+ )
144
+
145
+ response = http.request(request)
146
+
147
+ if response.code.to_i == 200
148
+ data = JSON.parse(response.body)
149
+
150
+ unless data["access_token"] && data["refresh_token"] && data["expires_in"]
151
+ raise "Token refresh response missing required fields"
152
+ end
153
+
154
+ expires_at = Time.now + data["expires_in"].to_i
155
+ account_id = extract_account_id_from_token(data["access_token"])
156
+
157
+ {
158
+ access_token: data["access_token"],
159
+ refresh_token: data["refresh_token"],
160
+ expires_at: expires_at,
161
+ account_id: account_id
162
+ }
163
+ else
164
+ error_body = begin
165
+ JSON.parse(response.body)
166
+ rescue StandardError
167
+ {}
168
+ end
169
+ raise "Token refresh failed (#{response.code}): #{error_body["error_description"] || error_body["error"] || response.body}"
170
+ end
171
+ end
172
+
173
+ # Extract the ChatGPT account_id from a JWT access token.
174
+ def self.extract_account_id_from_token(token)
175
+ parts = token.to_s.split(".")
176
+ return nil unless parts.length == 3
177
+
178
+ payload_b64 = parts[1]
179
+ # Re-pad to a multiple of 4 for base64 decoding
180
+ payload_b64 += "=" * ((4 - payload_b64.length % 4) % 4)
181
+ payload = JSON.parse(Base64.urlsafe_decode64(payload_b64))
182
+
183
+ auth = payload[JWT_CLAIM_PATH]
184
+ account_id = auth&.dig("chatgpt_account_id")
185
+
186
+ account_id.is_a?(String) && !account_id.empty? ? account_id : nil
187
+ rescue StandardError
188
+ nil
189
+ end
190
+
191
+ private
192
+
193
+ def generate_pkce
194
+ code_verifier = SecureRandom.urlsafe_base64(32).tr("=", "")
195
+ digest = Digest::SHA256.digest(code_verifier)
196
+ code_challenge = Base64.urlsafe_encode64(digest).tr("=", "")
197
+ [ code_verifier, code_challenge ]
198
+ end
199
+
200
+ def build_authorization_url(code_challenge, state)
201
+ params = {
202
+ response_type: "code",
203
+ client_id: @client_id,
204
+ redirect_uri: @redirect_uri,
205
+ scope: @scope,
206
+ code_challenge: code_challenge,
207
+ code_challenge_method: "S256",
208
+ state: state,
209
+ id_token_add_organizations: "true",
210
+ codex_cli_simplified_flow: "true",
211
+ originator: "llm_gateway"
212
+ }
213
+ "#{AUTHORIZE_URL}?#{URI.encode_www_form(params)}"
214
+ end
215
+
216
+ def parse_authorization_input(input, expected_state = nil)
217
+ return nil if input.nil? || input.empty?
218
+
219
+ value = input.to_s.strip
220
+
221
+ # Full URL
222
+ if value.start_with?("http://", "https://")
223
+ parsed = parse_callback(value)
224
+ if expected_state && parsed[:state] && parsed[:state] != expected_state
225
+ raise "State mismatch"
226
+ end
227
+ return parsed[:code]
228
+ end
229
+
230
+ # code#state shorthand
231
+ if value.include?("#")
232
+ code, state = value.split("#", 2)
233
+ raise "State mismatch" if expected_state && state && state != expected_state
234
+ return code
235
+ end
236
+
237
+ # Query-string fragment
238
+ if value.include?("code=")
239
+ params = URI.decode_www_form(value).to_h
240
+ if expected_state && params["state"] && params["state"] != expected_state
241
+ raise "State mismatch"
242
+ end
243
+ return params["code"]
244
+ end
245
+
246
+ # Raw code
247
+ value
248
+ end
249
+
250
+ def parse_error_body(body)
251
+ JSON.parse(body)
252
+ rescue StandardError
253
+ {}
254
+ end
255
+ end
256
+ end
257
+ end
258
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "time"
6
+
7
+ module LlmGateway
8
+ module Clients
9
+ class OpenAI
10
+ class TokenManager
11
+ attr_reader :access_token, :refresh_token, :expires_at, :account_id, :client_id
12
+ attr_accessor :on_token_refresh
13
+
14
+ def initialize(
15
+ access_token: nil,
16
+ refresh_token:,
17
+ expires_at: nil,
18
+ account_id: nil,
19
+ client_id: OAuthFlow::CLIENT_ID
20
+ )
21
+ @access_token = access_token
22
+ @refresh_token = refresh_token
23
+ @expires_at = parse_expires_at(expires_at)
24
+ @account_id = account_id
25
+ @client_id = client_id
26
+ @on_token_refresh = nil
27
+ end
28
+
29
+ def token_expired?
30
+ return true if @expires_at.nil?
31
+
32
+ Time.now >= @expires_at
33
+ end
34
+
35
+ def ensure_valid_token
36
+ refresh_access_token! if token_expired?
37
+ end
38
+
39
+ def refresh_access_token!
40
+ raise ArgumentError, "Cannot refresh token: refresh_token not provided" unless @refresh_token
41
+
42
+ result = OAuthFlow.refresh_access_token(@refresh_token, client_id: @client_id)
43
+
44
+ @access_token = result[:access_token]
45
+ @refresh_token = result[:refresh_token]
46
+ @expires_at = result[:expires_at]
47
+ @account_id = result[:account_id] if result[:account_id]
48
+
49
+ @on_token_refresh&.call(@access_token, @refresh_token, @expires_at)
50
+
51
+ @access_token
52
+ end
53
+
54
+ private
55
+
56
+ def parse_expires_at(expires)
57
+ case expires
58
+ when Time
59
+ expires
60
+ when String
61
+ Time.parse(expires)
62
+ when Integer, Float
63
+ Time.at(expires.to_i)
64
+ else
65
+ nil
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -28,6 +28,29 @@ module LlmGateway
28
28
  class UnknownError < ClientError; end
29
29
  class PromptTooLong < BadRequestError; end
30
30
  class UnsupportedModel < ClientError; end
31
+ class UnsupportedProvider < ClientError; end
32
+ class MissingMapperForProvider < ClientError; end
33
+
34
+ OVERFLOW_PATTERNS = [
35
+ /prompt is too long/i, # Anthropic
36
+ /exceeds the context window/i, # OpenAI
37
+ /reduce the length of the messages/i, # Groq
38
+ /maximum context length is \d+ tokens/i,
39
+ /context[_ ]length[_ ]exceeded/i,
40
+ /too many tokens/i,
41
+ /token limit exceeded/i,
42
+ /request too large.*tokens per min/i, # OpenAI TPM wording
43
+ /input tokens per minute/i, # Anthropic TPM wording
44
+ /reduce the prompt length/i,
45
+ /input or output tokens must be reduced/i
46
+ ].freeze
47
+
48
+ def self.context_overflow_message?(message)
49
+ text = message.to_s
50
+ return false if text.empty?
51
+
52
+ OVERFLOW_PATTERNS.any? { |pattern| pattern.match?(text) }
53
+ end
31
54
 
32
55
  class PromptError < BaseError; end
33
56
 
@@ -30,6 +30,13 @@ module LlmGateway
30
30
 
31
31
  def initialize(model)
32
32
  @model = model
33
+ @connection = if model.is_a?(String)
34
+ LlmGateway.configured_clients.values.find do |client|
35
+ client.client.model_key == model
36
+ end
37
+ else
38
+ model
39
+ end
33
40
  end
34
41
 
35
42
  def run
@@ -55,7 +62,11 @@ module LlmGateway
55
62
  end
56
63
 
57
64
  def post
58
- LlmGateway::Client.chat(model, prompt, tools: tools, system: system_prompt)
65
+ if @connection
66
+ @connection.chat(prompt, tools: tools, system: system_prompt)
67
+ else
68
+ LlmGateway::Client.chat(model, prompt, tools: tools, system: system_prompt)
69
+ end
59
70
  end
60
71
 
61
72
  def tools
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmGateway
4
+ class ProviderRegistry
5
+ class << self
6
+ def register(name, client:, adapter:)
7
+ registry[name.to_s] = { client: client, adapter: adapter }
8
+ end
9
+
10
+ def resolve(name)
11
+ name = name.to_s
12
+ entry = registry[name]
13
+ raise Errors::UnsupportedProvider, "Unknown provider: #{name}" unless entry
14
+
15
+ entry
16
+ end
17
+
18
+ def registered?(name)
19
+ registry.key?(name.to_s)
20
+ end
21
+
22
+ def providers
23
+ registry.keys
24
+ end
25
+
26
+ def reset!
27
+ @registry = {}
28
+ end
29
+
30
+ private
31
+
32
+ def registry
33
+ @registry ||= {}
34
+ end
35
+ end
36
+ end
37
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmGateway
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/llm_gateway.rb CHANGED
@@ -8,17 +8,176 @@ require_relative "llm_gateway/client"
8
8
  require_relative "llm_gateway/prompt"
9
9
  require_relative "llm_gateway/tool"
10
10
 
11
- # Load adapters - order matters for inheritance
12
- require_relative "llm_gateway/adapters/claude/client"
13
- require_relative "llm_gateway/adapters/claude/input_mapper"
14
- require_relative "llm_gateway/adapters/claude/output_mapper"
15
- require_relative "llm_gateway/adapters/groq/client"
16
- require_relative "llm_gateway/adapters/groq/input_mapper"
17
- require_relative "llm_gateway/adapters/groq/output_mapper"
18
- require_relative "llm_gateway/adapters/open_ai/client"
19
- require_relative "llm_gateway/adapters/open_ai/input_mapper"
20
- require_relative "llm_gateway/adapters/open_ai/output_mapper"
11
+ # Load clients - order matters for inheritance
12
+ require_relative "llm_gateway/clients/anthropic"
13
+ require_relative "llm_gateway/clients/claude_code/oauth_flow"
14
+ require_relative "llm_gateway/clients/claude_code/token_manager"
15
+ require_relative "llm_gateway/clients/openai"
16
+ require_relative "llm_gateway/clients/openai_codex/oauth_flow"
17
+ require_relative "llm_gateway/clients/openai_codex/token_manager"
18
+ require_relative "llm_gateway/clients/groq"
19
+
20
+ # Load adapters
21
+ require_relative "llm_gateway/adapters/option_mapper"
22
+ require_relative "llm_gateway/adapters/anthropic_option_mapper"
23
+ require_relative "llm_gateway/adapters/structs"
24
+
25
+ require_relative "llm_gateway/adapters/anthropic/input_mapper"
26
+ require_relative "llm_gateway/adapters/anthropic/output_mapper"
27
+ require_relative "llm_gateway/adapters/openai/file_output_mapper"
28
+ require_relative "llm_gateway/adapters/openai/prompt_cache_option_mapper"
29
+ require_relative "llm_gateway/adapters/openai/chat_completions/input_mapper"
30
+ require_relative "llm_gateway/adapters/openai/chat_completions/output_mapper"
31
+ require_relative "llm_gateway/adapters/openai/chat_completions/option_mapper"
32
+ require_relative "llm_gateway/adapters/openai/file_output_mapper"
33
+ require_relative "llm_gateway/adapters/openai/responses/input_mapper"
34
+ require_relative "llm_gateway/adapters/openai/responses/output_mapper"
35
+ require_relative "llm_gateway/adapters/openai/responses/option_mapper"
36
+
37
+ # Load adapter classes
38
+ require_relative "llm_gateway/adapters/adapter"
39
+ require_relative "llm_gateway/adapters/anthropic/messages_adapter"
40
+ require_relative "llm_gateway/adapters/openai/chat_completions_adapter"
41
+ require_relative "llm_gateway/adapters/openai/responses_adapter"
42
+ require_relative "llm_gateway/adapters/openai_codex/responses_adapter"
43
+ require_relative "llm_gateway/adapters/groq/chat_completions_adapter"
44
+
45
+ # Load provider registry
46
+ require_relative "llm_gateway/provider_registry"
21
47
 
22
48
  module LlmGateway
23
49
  class Error < StandardError; end
50
+
51
+ # Direction constants for message mappers
52
+ DIRECTION_IN = :in
53
+ DIRECTION_OUT = :out
54
+
55
+ # Backward-compatible aliases for renamed clients/adapters
56
+ module Clients
57
+ Claude = Anthropic
58
+ OpenAi = OpenAI
59
+ end
60
+
61
+ module Adapters
62
+ module Claude
63
+ Client = LlmGateway::Clients::Anthropic
64
+ MessagesAdapter = LlmGateway::Adapters::Anthropic::MessagesAdapter
65
+ InputMapper = LlmGateway::Adapters::Anthropic::InputMapper
66
+ OutputMapper = LlmGateway::Adapters::Anthropic::OutputMapper
67
+ StreamMapper = LlmGateway::Adapters::Anthropic::StreamMapper
68
+ BidirectionalMessageMapper = LlmGateway::Adapters::Anthropic::BidirectionalMessageMapper
69
+ FileOutputMapper = LlmGateway::Adapters::Anthropic::FileOutputMapper
70
+ end
71
+
72
+ module Anthropic
73
+ Client = LlmGateway::Clients::Anthropic
74
+ end
75
+
76
+ module OpenAI
77
+ Client = LlmGateway::Clients::OpenAI
78
+ end
79
+
80
+ module OpenAi
81
+ Client = LlmGateway::Clients::OpenAI
82
+ ChatCompletionsAdapter = LlmGateway::Adapters::OpenAI::ChatCompletionsAdapter
83
+ ResponsesAdapter = LlmGateway::Adapters::OpenAI::ResponsesAdapter
84
+ PromptCacheOptionMapper = LlmGateway::Adapters::OpenAI::PromptCacheOptionMapper
85
+ FileOutputMapper = LlmGateway::Adapters::OpenAI::FileOutputMapper
86
+ ChatCompletions = LlmGateway::Adapters::OpenAI::ChatCompletions
87
+ Responses = LlmGateway::Adapters::OpenAI::Responses
88
+ end
89
+
90
+ module OpenAICodex
91
+ Client = LlmGateway::Clients::OpenAI
92
+ end
93
+
94
+ module OpenAiCodex
95
+ Client = LlmGateway::Clients::OpenAI
96
+ ResponsesAdapter = LlmGateway::Adapters::OpenAICodex::ResponsesAdapter
97
+ InputMapper = LlmGateway::Adapters::OpenAICodex::InputMapper
98
+ OptionMapper = LlmGateway::Adapters::OpenAICodex::OptionMapper
99
+ end
100
+
101
+ module Groq
102
+ Client = LlmGateway::Clients::Groq
103
+ end
104
+ end
105
+
106
+ def self.build_provider(config)
107
+ config = config.transform_keys(&:to_sym)
108
+ provider_name = config.delete(:provider)
109
+ entry = ProviderRegistry.resolve(provider_name)
110
+
111
+ client = entry[:client].new(**config)
112
+ entry[:adapter].new(client)
113
+ end
114
+
115
+ def self.configure(configs)
116
+ @configured_clients ||= {}
117
+
118
+ configs.each do |entry|
119
+ name = entry[:name] || entry["name"]
120
+ config = entry[:config] || entry["config"]
121
+
122
+ raise ArgumentError, "Each config entry must have a :name" unless name
123
+
124
+ client = build_provider(config)
125
+ @configured_clients[name.to_sym] = client
126
+
127
+ define_singleton_method(name.to_sym) { @configured_clients[name.to_sym] }
128
+ end
129
+ end
130
+
131
+ def self.configured_clients
132
+ @configured_clients ||= {}
133
+ end
134
+
135
+ def self.reset_configuration!
136
+ @configured_clients&.each_key do |name|
137
+ singleton_class.remove_method(name) if respond_to?(name)
138
+ end
139
+ @configured_clients = {}
140
+ end
141
+
142
+ # Register built-in providers (canonical keys)
143
+ ProviderRegistry.register("anthropic_messages",
144
+ client: Clients::Anthropic,
145
+ adapter: Adapters::Anthropic::MessagesAdapter)
146
+
147
+ ProviderRegistry.register("openai_completions",
148
+ client: Clients::OpenAI,
149
+ adapter: Adapters::OpenAI::ChatCompletionsAdapter)
150
+
151
+ ProviderRegistry.register("openai_responses",
152
+ client: Clients::OpenAI,
153
+ adapter: Adapters::OpenAI::ResponsesAdapter)
154
+
155
+ ProviderRegistry.register("groq_completions",
156
+ client: Clients::Groq,
157
+ adapter: Adapters::Groq::ChatCompletionsAdapter)
158
+
159
+ ProviderRegistry.register("openai_codex",
160
+ client: Clients::OpenAI,
161
+ adapter: Adapters::OpenAICodex::ResponsesAdapter)
162
+
163
+ # Backward-compatible aliases (deprecated)
164
+ ProviderRegistry.register("anthropic_apikey_messages",
165
+ client: Clients::Anthropic,
166
+ adapter: Adapters::Anthropic::MessagesAdapter)
167
+
168
+ ProviderRegistry.register("openai_apikey_completions",
169
+ client: Clients::OpenAI,
170
+ adapter: Adapters::OpenAI::ChatCompletionsAdapter)
171
+
172
+ ProviderRegistry.register("openai_apikey_responses",
173
+ client: Clients::OpenAI,
174
+ adapter: Adapters::OpenAI::ResponsesAdapter)
175
+
176
+ ProviderRegistry.register("groq_apikey_completions",
177
+ client: Clients::Groq,
178
+ adapter: Adapters::Groq::ChatCompletionsAdapter)
179
+
180
+ ProviderRegistry.register("openai_oauth_codex",
181
+ client: Clients::OpenAI,
182
+ adapter: Adapters::OpenAICodex::ResponsesAdapter)
24
183
  end