llm_gateway 0.3.0 → 0.5.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/.pi/skills/live-provider-testing/SKILL.md +183 -0
  3. data/.pi/skills/options-development/SKILL.md +131 -0
  4. data/CHANGELOG.md +43 -0
  5. data/README.md +559 -185
  6. data/Rakefile +2 -2
  7. data/docs/migration-guide.md +135 -0
  8. data/lib/llm_gateway/adapters/adapter.rb +140 -0
  9. data/lib/llm_gateway/adapters/anthropic/acts_like_messages.rb +21 -0
  10. data/lib/llm_gateway/adapters/anthropic/input_mapper.rb +137 -0
  11. data/lib/llm_gateway/adapters/anthropic/messages_adapter.rb +19 -0
  12. data/lib/llm_gateway/adapters/anthropic/output_mapper.rb +17 -0
  13. data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +95 -0
  14. data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +95 -0
  15. data/lib/llm_gateway/adapters/groq/chat_completions_adapter.rb +48 -0
  16. data/lib/llm_gateway/adapters/groq/input_mapper.rb +32 -6
  17. data/lib/llm_gateway/adapters/groq/option_mapper.rb +112 -0
  18. data/lib/llm_gateway/adapters/input_message_sanitizer.rb +93 -0
  19. data/lib/llm_gateway/adapters/normalized_stream_accumulator.rb +275 -0
  20. data/lib/llm_gateway/adapters/openai/acts_like_chat_completions.rb +20 -0
  21. data/lib/llm_gateway/adapters/openai/acts_like_responses.rb +25 -0
  22. data/lib/llm_gateway/adapters/openai/chat_completions/input_mapper.rb +168 -0
  23. data/lib/llm_gateway/adapters/openai/chat_completions/input_message_sanitizer.rb +65 -0
  24. data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +129 -0
  25. data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +241 -0
  26. data/lib/llm_gateway/adapters/openai/chat_completions_adapter.rb +19 -0
  27. data/lib/llm_gateway/adapters/{open_ai → openai}/file_output_mapper.rb +1 -1
  28. data/lib/llm_gateway/adapters/openai/prompt_cache_option_mapper.rb +39 -0
  29. data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +166 -0
  30. data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +130 -0
  31. data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +150 -0
  32. data/lib/llm_gateway/adapters/openai/responses_adapter.rb +19 -0
  33. data/lib/llm_gateway/adapters/openai_codex/input_mapper.rb +206 -0
  34. data/lib/llm_gateway/adapters/openai_codex/option_mapper.rb +28 -0
  35. data/lib/llm_gateway/adapters/openai_codex/responses_adapter.rb +33 -0
  36. data/lib/llm_gateway/adapters/option_mapper.rb +13 -0
  37. data/lib/llm_gateway/adapters/stream_mapper.rb +50 -0
  38. data/lib/llm_gateway/adapters/structs.rb +145 -0
  39. data/lib/llm_gateway/base_client.rb +62 -1
  40. data/lib/llm_gateway/client.rb +18 -158
  41. data/lib/llm_gateway/clients/anthropic.rb +167 -0
  42. data/lib/llm_gateway/clients/claude_code/oauth_flow.rb +162 -0
  43. data/lib/llm_gateway/clients/claude_code/token_manager.rb +112 -0
  44. data/lib/llm_gateway/clients/groq.rb +66 -0
  45. data/lib/llm_gateway/clients/openai.rb +208 -0
  46. data/lib/llm_gateway/clients/openai_codex/oauth_flow.rb +258 -0
  47. data/lib/llm_gateway/clients/openai_codex/token_manager.rb +71 -0
  48. data/lib/llm_gateway/errors.rb +21 -0
  49. data/lib/llm_gateway/prompt.rb +12 -1
  50. data/lib/llm_gateway/provider_registry.rb +37 -0
  51. data/lib/llm_gateway/version.rb +1 -1
  52. data/lib/llm_gateway.rb +162 -17
  53. data/scripts/create_anthropic_credentials.rb +106 -0
  54. data/scripts/create_openai_codex_credentials.rb +116 -0
  55. metadata +60 -27
  56. data/lib/llm_gateway/adapters/claude/bidirectional_message_mapper.rb +0 -83
  57. data/lib/llm_gateway/adapters/claude/client.rb +0 -60
  58. data/lib/llm_gateway/adapters/claude/input_mapper.rb +0 -57
  59. data/lib/llm_gateway/adapters/claude/output_mapper.rb +0 -50
  60. data/lib/llm_gateway/adapters/groq/bidirectional_message_mapper.rb +0 -18
  61. data/lib/llm_gateway/adapters/groq/client.rb +0 -58
  62. data/lib/llm_gateway/adapters/groq/output_mapper.rb +0 -10
  63. data/lib/llm_gateway/adapters/open_ai/chat_completions/bidirectional_message_mapper.rb +0 -103
  64. data/lib/llm_gateway/adapters/open_ai/chat_completions/input_mapper.rb +0 -110
  65. data/lib/llm_gateway/adapters/open_ai/chat_completions/output_mapper.rb +0 -40
  66. data/lib/llm_gateway/adapters/open_ai/client.rb +0 -80
  67. data/lib/llm_gateway/adapters/open_ai/responses/bidirectional_message_mapper.rb +0 -72
  68. data/lib/llm_gateway/adapters/open_ai/responses/input_mapper.rb +0 -62
  69. data/lib/llm_gateway/adapters/open_ai/responses/output_mapper.rb +0 -47
  70. data/sample/claude_code_clone/agent.rb +0 -65
  71. data/sample/claude_code_clone/claude_code_clone.rb +0 -40
  72. data/sample/claude_code_clone/prompt.rb +0 -79
  73. data/sample/claude_code_clone/run.rb +0 -47
  74. data/sample/claude_code_clone/tools/bash_tool.rb +0 -54
  75. data/sample/claude_code_clone/tools/edit_tool.rb +0 -61
  76. data/sample/claude_code_clone/tools/grep_tool.rb +0 -113
  77. data/sample/claude_code_clone/tools/read_tool.rb +0 -61
  78. 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
@@ -31,6 +31,27 @@ module LlmGateway
31
31
  class UnsupportedProvider < ClientError; end
32
32
  class MissingMapperForProvider < ClientError; end
33
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
54
+
34
55
  class PromptError < BaseError; end
35
56
 
36
57
  class HallucinationError < PromptError; end
@@ -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.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/llm_gateway.rb CHANGED
@@ -8,25 +8,170 @@ 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/open_ai/client"
16
- require_relative "llm_gateway/adapters/open_ai/file_output_mapper"
17
- require_relative "llm_gateway/adapters/open_ai/chat_completions/input_mapper"
18
- require_relative "llm_gateway/adapters/open_ai/chat_completions/output_mapper"
19
- require_relative "llm_gateway/adapters/groq/client"
20
- require_relative "llm_gateway/adapters/groq/input_mapper"
21
- require_relative "llm_gateway/adapters/groq/output_mapper"
22
- require_relative "llm_gateway/adapters/open_ai/file_output_mapper"
23
- require_relative "llm_gateway/adapters/open_ai/responses/input_mapper"
24
- require_relative "llm_gateway/adapters/open_ai/responses/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
+ require_relative "llm_gateway/adapters/stream_mapper"
25
+
26
+ require_relative "llm_gateway/adapters/anthropic/input_mapper"
27
+ require_relative "llm_gateway/adapters/anthropic/output_mapper"
28
+ require_relative "llm_gateway/adapters/openai/file_output_mapper"
29
+ require_relative "llm_gateway/adapters/openai/prompt_cache_option_mapper"
30
+ require_relative "llm_gateway/adapters/openai/chat_completions/input_mapper"
31
+ require_relative "llm_gateway/adapters/openai/chat_completions/option_mapper"
32
+ require_relative "llm_gateway/adapters/openai/chat_completions/stream_mapper"
33
+ require_relative "llm_gateway/adapters/openai/file_output_mapper"
34
+ require_relative "llm_gateway/adapters/openai/responses/input_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"
25
47
 
26
48
  module LlmGateway
27
49
  class Error < StandardError; end
28
50
 
29
- # Direction constants for message mappers
30
- DIRECTION_IN = :in
31
- DIRECTION_OUT = :out
51
+ # Backward-compatible aliases for renamed clients/adapters
52
+ module Clients
53
+ Claude = Anthropic
54
+ OpenAi = OpenAI
55
+ end
56
+
57
+ module Adapters
58
+ module Claude
59
+ Client = LlmGateway::Clients::Anthropic
60
+ MessagesAdapter = LlmGateway::Adapters::Anthropic::MessagesAdapter
61
+ InputMapper = LlmGateway::Adapters::Anthropic::InputMapper
62
+ StreamMapper = LlmGateway::Adapters::Anthropic::StreamMapper
63
+ FileOutputMapper = LlmGateway::Adapters::Anthropic::FileOutputMapper
64
+ end
65
+
66
+ module Anthropic
67
+ Client = LlmGateway::Clients::Anthropic
68
+ end
69
+
70
+ module OpenAI
71
+ Client = LlmGateway::Clients::OpenAI
72
+ end
73
+
74
+ module OpenAi
75
+ Client = LlmGateway::Clients::OpenAI
76
+ ChatCompletionsAdapter = LlmGateway::Adapters::OpenAI::ChatCompletionsAdapter
77
+ ResponsesAdapter = LlmGateway::Adapters::OpenAI::ResponsesAdapter
78
+ PromptCacheOptionMapper = LlmGateway::Adapters::OpenAI::PromptCacheOptionMapper
79
+ FileOutputMapper = LlmGateway::Adapters::OpenAI::FileOutputMapper
80
+ ChatCompletions = LlmGateway::Adapters::OpenAI::ChatCompletions
81
+ Responses = LlmGateway::Adapters::OpenAI::Responses
82
+ end
83
+
84
+ module OpenAICodex
85
+ Client = LlmGateway::Clients::OpenAI
86
+ end
87
+
88
+ module OpenAiCodex
89
+ Client = LlmGateway::Clients::OpenAI
90
+ ResponsesAdapter = LlmGateway::Adapters::OpenAICodex::ResponsesAdapter
91
+ InputMapper = LlmGateway::Adapters::OpenAICodex::InputMapper
92
+ OptionMapper = LlmGateway::Adapters::OpenAICodex::OptionMapper
93
+ end
94
+
95
+ module Groq
96
+ Client = LlmGateway::Clients::Groq
97
+ end
98
+ end
99
+
100
+ def self.build_provider(config)
101
+ config = config.transform_keys(&:to_sym)
102
+ provider_name = config.delete(:provider)
103
+ entry = ProviderRegistry.resolve(provider_name)
104
+
105
+ client = entry[:client].new(**config)
106
+ entry[:adapter].new(client)
107
+ end
108
+
109
+ def self.configure(configs)
110
+ @configured_clients ||= {}
111
+
112
+ configs.each do |entry|
113
+ name = entry[:name] || entry["name"]
114
+ config = entry[:config] || entry["config"]
115
+
116
+ raise ArgumentError, "Each config entry must have a :name" unless name
117
+
118
+ client = build_provider(config)
119
+ @configured_clients[name.to_sym] = client
120
+
121
+ define_singleton_method(name.to_sym) { @configured_clients[name.to_sym] }
122
+ end
123
+ end
124
+
125
+ def self.configured_clients
126
+ @configured_clients ||= {}
127
+ end
128
+
129
+ def self.reset_configuration!
130
+ @configured_clients&.each_key do |name|
131
+ singleton_class.remove_method(name) if respond_to?(name)
132
+ end
133
+ @configured_clients = {}
134
+ end
135
+
136
+ # Register built-in providers (canonical keys)
137
+ ProviderRegistry.register("anthropic_messages",
138
+ client: Clients::Anthropic,
139
+ adapter: Adapters::Anthropic::MessagesAdapter)
140
+
141
+ ProviderRegistry.register("openai_completions",
142
+ client: Clients::OpenAI,
143
+ adapter: Adapters::OpenAI::ChatCompletionsAdapter)
144
+
145
+ ProviderRegistry.register("openai_responses",
146
+ client: Clients::OpenAI,
147
+ adapter: Adapters::OpenAI::ResponsesAdapter)
148
+
149
+ ProviderRegistry.register("groq_completions",
150
+ client: Clients::Groq,
151
+ adapter: Adapters::Groq::ChatCompletionsAdapter)
152
+
153
+ ProviderRegistry.register("openai_codex",
154
+ client: Clients::OpenAI,
155
+ adapter: Adapters::OpenAICodex::ResponsesAdapter)
156
+
157
+ # Backward-compatible aliases (deprecated)
158
+ ProviderRegistry.register("anthropic_apikey_messages",
159
+ client: Clients::Anthropic,
160
+ adapter: Adapters::Anthropic::MessagesAdapter)
161
+
162
+ ProviderRegistry.register("openai_apikey_completions",
163
+ client: Clients::OpenAI,
164
+ adapter: Adapters::OpenAI::ChatCompletionsAdapter)
165
+
166
+ ProviderRegistry.register("openai_apikey_responses",
167
+ client: Clients::OpenAI,
168
+ adapter: Adapters::OpenAI::ResponsesAdapter)
169
+
170
+ ProviderRegistry.register("groq_apikey_completions",
171
+ client: Clients::Groq,
172
+ adapter: Adapters::Groq::ChatCompletionsAdapter)
173
+
174
+ ProviderRegistry.register("openai_oauth_codex",
175
+ client: Clients::OpenAI,
176
+ adapter: Adapters::OpenAICodex::ResponsesAdapter)
32
177
  end