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.
- checksums.yaml +4 -4
- data/.pi/skills/live-provider-testing/SKILL.md +183 -0
- data/.pi/skills/options-development/SKILL.md +131 -0
- data/CHANGELOG.md +43 -0
- data/README.md +559 -185
- data/Rakefile +2 -2
- data/docs/migration-guide.md +135 -0
- data/lib/llm_gateway/adapters/adapter.rb +140 -0
- data/lib/llm_gateway/adapters/anthropic/acts_like_messages.rb +21 -0
- data/lib/llm_gateway/adapters/anthropic/input_mapper.rb +137 -0
- data/lib/llm_gateway/adapters/anthropic/messages_adapter.rb +19 -0
- data/lib/llm_gateway/adapters/anthropic/output_mapper.rb +17 -0
- data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +95 -0
- data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +95 -0
- data/lib/llm_gateway/adapters/groq/chat_completions_adapter.rb +48 -0
- data/lib/llm_gateway/adapters/groq/input_mapper.rb +32 -6
- data/lib/llm_gateway/adapters/groq/option_mapper.rb +112 -0
- data/lib/llm_gateway/adapters/input_message_sanitizer.rb +93 -0
- data/lib/llm_gateway/adapters/normalized_stream_accumulator.rb +275 -0
- data/lib/llm_gateway/adapters/openai/acts_like_chat_completions.rb +20 -0
- data/lib/llm_gateway/adapters/openai/acts_like_responses.rb +25 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/input_mapper.rb +168 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/input_message_sanitizer.rb +65 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +129 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +241 -0
- data/lib/llm_gateway/adapters/openai/chat_completions_adapter.rb +19 -0
- data/lib/llm_gateway/adapters/{open_ai → openai}/file_output_mapper.rb +1 -1
- data/lib/llm_gateway/adapters/openai/prompt_cache_option_mapper.rb +39 -0
- data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +166 -0
- data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +130 -0
- data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +150 -0
- data/lib/llm_gateway/adapters/openai/responses_adapter.rb +19 -0
- data/lib/llm_gateway/adapters/openai_codex/input_mapper.rb +206 -0
- data/lib/llm_gateway/adapters/openai_codex/option_mapper.rb +28 -0
- data/lib/llm_gateway/adapters/openai_codex/responses_adapter.rb +33 -0
- data/lib/llm_gateway/adapters/option_mapper.rb +13 -0
- data/lib/llm_gateway/adapters/stream_mapper.rb +50 -0
- data/lib/llm_gateway/adapters/structs.rb +145 -0
- data/lib/llm_gateway/base_client.rb +62 -1
- data/lib/llm_gateway/client.rb +18 -158
- data/lib/llm_gateway/clients/anthropic.rb +167 -0
- data/lib/llm_gateway/clients/claude_code/oauth_flow.rb +162 -0
- data/lib/llm_gateway/clients/claude_code/token_manager.rb +112 -0
- data/lib/llm_gateway/clients/groq.rb +66 -0
- data/lib/llm_gateway/clients/openai.rb +208 -0
- data/lib/llm_gateway/clients/openai_codex/oauth_flow.rb +258 -0
- data/lib/llm_gateway/clients/openai_codex/token_manager.rb +71 -0
- data/lib/llm_gateway/errors.rb +21 -0
- data/lib/llm_gateway/prompt.rb +12 -1
- data/lib/llm_gateway/provider_registry.rb +37 -0
- data/lib/llm_gateway/version.rb +1 -1
- data/lib/llm_gateway.rb +162 -17
- data/scripts/create_anthropic_credentials.rb +106 -0
- data/scripts/create_openai_codex_credentials.rb +116 -0
- metadata +60 -27
- data/lib/llm_gateway/adapters/claude/bidirectional_message_mapper.rb +0 -83
- data/lib/llm_gateway/adapters/claude/client.rb +0 -60
- data/lib/llm_gateway/adapters/claude/input_mapper.rb +0 -57
- data/lib/llm_gateway/adapters/claude/output_mapper.rb +0 -50
- data/lib/llm_gateway/adapters/groq/bidirectional_message_mapper.rb +0 -18
- data/lib/llm_gateway/adapters/groq/client.rb +0 -58
- data/lib/llm_gateway/adapters/groq/output_mapper.rb +0 -10
- data/lib/llm_gateway/adapters/open_ai/chat_completions/bidirectional_message_mapper.rb +0 -103
- data/lib/llm_gateway/adapters/open_ai/chat_completions/input_mapper.rb +0 -110
- data/lib/llm_gateway/adapters/open_ai/chat_completions/output_mapper.rb +0 -40
- data/lib/llm_gateway/adapters/open_ai/client.rb +0 -80
- data/lib/llm_gateway/adapters/open_ai/responses/bidirectional_message_mapper.rb +0 -72
- data/lib/llm_gateway/adapters/open_ai/responses/input_mapper.rb +0 -62
- data/lib/llm_gateway/adapters/open_ai/responses/output_mapper.rb +0 -47
- data/sample/claude_code_clone/agent.rb +0 -65
- data/sample/claude_code_clone/claude_code_clone.rb +0 -40
- data/sample/claude_code_clone/prompt.rb +0 -79
- data/sample/claude_code_clone/run.rb +0 -47
- data/sample/claude_code_clone/tools/bash_tool.rb +0 -54
- data/sample/claude_code_clone/tools/edit_tool.rb +0 -61
- data/sample/claude_code_clone/tools/grep_tool.rb +0 -113
- data/sample/claude_code_clone/tools/read_tool.rb +0 -61
- 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
|
data/lib/llm_gateway/errors.rb
CHANGED
|
@@ -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
|
data/lib/llm_gateway/prompt.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/llm_gateway/version.rb
CHANGED
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
|
|
12
|
-
require_relative "llm_gateway/
|
|
13
|
-
require_relative "llm_gateway/
|
|
14
|
-
require_relative "llm_gateway/
|
|
15
|
-
require_relative "llm_gateway/
|
|
16
|
-
require_relative "llm_gateway/
|
|
17
|
-
require_relative "llm_gateway/
|
|
18
|
-
require_relative "llm_gateway/
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
require_relative "llm_gateway/adapters/
|
|
22
|
-
require_relative "llm_gateway/adapters/
|
|
23
|
-
require_relative "llm_gateway/adapters/
|
|
24
|
-
require_relative "llm_gateway/adapters/
|
|
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
|
-
#
|
|
30
|
-
|
|
31
|
-
|
|
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
|