llm_gateway 0.3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +544 -186
- data/Rakefile +1 -2
- data/docs/migration-guide.md +135 -0
- data/lib/llm_gateway/adapters/adapter.rb +173 -0
- data/lib/llm_gateway/adapters/anthropic/acts_like_messages.rb +23 -0
- data/lib/llm_gateway/adapters/{claude → anthropic}/bidirectional_message_mapper.rb +31 -3
- data/lib/llm_gateway/adapters/{claude → anthropic}/input_mapper.rb +4 -3
- data/lib/llm_gateway/adapters/anthropic/messages_adapter.rb +19 -0
- data/lib/llm_gateway/adapters/{claude → anthropic}/output_mapper.rb +1 -1
- data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +110 -0
- data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +53 -0
- data/lib/llm_gateway/adapters/groq/chat_completions_adapter.rb +47 -0
- data/lib/llm_gateway/adapters/groq/option_mapper.rb +27 -0
- data/lib/llm_gateway/adapters/input_message_sanitizer.rb +93 -0
- data/lib/llm_gateway/adapters/openai/acts_like_chat_completions.rb +22 -0
- data/lib/llm_gateway/adapters/openai/acts_like_responses.rb +31 -0
- data/lib/llm_gateway/adapters/{open_ai → openai}/chat_completions/bidirectional_message_mapper.rb +9 -2
- data/lib/llm_gateway/adapters/{open_ai → openai}/chat_completions/input_mapper.rb +1 -6
- 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 +39 -0
- data/lib/llm_gateway/adapters/{open_ai → openai}/chat_completions/output_mapper.rb +1 -1
- data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +242 -0
- data/lib/llm_gateway/adapters/openai/chat_completions_adapter.rb +20 -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/{open_ai → openai}/responses/bidirectional_message_mapper.rb +52 -4
- data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +106 -0
- data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +41 -0
- data/lib/llm_gateway/adapters/{open_ai → openai}/responses/output_mapper.rb +1 -1
- data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +340 -0
- data/lib/llm_gateway/adapters/openai/responses_adapter.rb +20 -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 +38 -0
- data/lib/llm_gateway/adapters/option_mapper.rb +13 -0
- data/lib/llm_gateway/adapters/stream_accumulator.rb +91 -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 +45 -129
- 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 +54 -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 +165 -14
- data/scripts/create_anthropic_credentials.rb +106 -0
- data/scripts/create_openai_codex_credentials.rb +116 -0
- data/scripts/generate_handoff_live_fixture.rb +169 -0
- data/scripts/generate_handoff_media_fixture.rb +167 -0
- metadata +64 -28
- data/lib/llm_gateway/adapters/claude/client.rb +0 -60
- 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/input_mapper.rb +0 -18
- data/lib/llm_gateway/adapters/groq/output_mapper.rb +0 -10
- data/lib/llm_gateway/adapters/open_ai/client.rb +0 -80
- data/lib/llm_gateway/adapters/open_ai/responses/input_mapper.rb +0 -62
- 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,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../base_client"
|
|
4
|
+
|
|
5
|
+
module LlmGateway
|
|
6
|
+
module Clients
|
|
7
|
+
class Groq < BaseClient
|
|
8
|
+
def initialize(model_key: "openai/gpt-oss-20b", api_key: ENV["GROQ_API_KEY"])
|
|
9
|
+
@base_endpoint = "https://api.groq.com/openai/v1"
|
|
10
|
+
super(model_key: model_key, api_key: api_key)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def chat(messages, tools: nil, system: [], **options)
|
|
14
|
+
body = {
|
|
15
|
+
model: model_key,
|
|
16
|
+
messages: system + messages,
|
|
17
|
+
tools: tools
|
|
18
|
+
}
|
|
19
|
+
body.merge!(options)
|
|
20
|
+
|
|
21
|
+
post("chat/completions", body)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def build_headers
|
|
27
|
+
{
|
|
28
|
+
"content-type" => "application/json",
|
|
29
|
+
"Authorization" => "Bearer #{api_key}"
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def handle_client_specific_errors(response, error)
|
|
34
|
+
# Groq likely uses 'code' like OpenAI since it's OpenAI-compatible
|
|
35
|
+
error_code = error["code"]
|
|
36
|
+
error_message = error["message"]
|
|
37
|
+
|
|
38
|
+
if Errors.context_overflow_message?(error_message)
|
|
39
|
+
raise Errors::PromptTooLong.new(error_message, error["type"])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
case response.code.to_i
|
|
43
|
+
when 429
|
|
44
|
+
raise Errors::RateLimitError.new(error["type"], error_code) if error_code == "rate_limit_exceeded"
|
|
45
|
+
|
|
46
|
+
raise Errors::OverloadError.new(error_message, error_code)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# If we get here, we didn't handle it specifically
|
|
50
|
+
raise Errors::APIStatusError.new(error_message, error_code)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../base_client"
|
|
4
|
+
|
|
5
|
+
module LlmGateway
|
|
6
|
+
module Clients
|
|
7
|
+
class OpenAI < BaseClient
|
|
8
|
+
CODEX_BASE_ENDPOINT = "https://chatgpt.com/backend-api/codex"
|
|
9
|
+
|
|
10
|
+
attr_reader :account_id
|
|
11
|
+
|
|
12
|
+
def initialize(model_key: "gpt-4o", api_key: ENV["OPENAI_API_KEY"], account_id: nil)
|
|
13
|
+
@base_endpoint = "https://api.openai.com/v1"
|
|
14
|
+
@account_id = account_id
|
|
15
|
+
super(model_key: model_key, api_key: api_key)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def chat(messages, tools: nil, system: [], **options)
|
|
19
|
+
body = {
|
|
20
|
+
model: model_key,
|
|
21
|
+
messages: system + messages
|
|
22
|
+
}
|
|
23
|
+
body[:tools] = tools if tools
|
|
24
|
+
body.merge!(options)
|
|
25
|
+
|
|
26
|
+
post("chat/completions", body)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def stream(messages, tools: nil, system: [], **options, &block)
|
|
30
|
+
body = {
|
|
31
|
+
model: model_key,
|
|
32
|
+
messages: system + messages
|
|
33
|
+
}
|
|
34
|
+
body[:tools] = tools if tools
|
|
35
|
+
body.merge!(options)
|
|
36
|
+
body[:stream_options] = (body[:stream_options] || {}).merge(include_usage: true)
|
|
37
|
+
|
|
38
|
+
post_stream("chat/completions", body, &block)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def responses(messages, tools: nil, system: [], **options)
|
|
42
|
+
body = {
|
|
43
|
+
model: model_key,
|
|
44
|
+
input: messages.flatten
|
|
45
|
+
}
|
|
46
|
+
body[:instructions] = system[0][:content] if system.any?
|
|
47
|
+
body[:tools] = tools if tools
|
|
48
|
+
body.merge!(options)
|
|
49
|
+
|
|
50
|
+
post("responses", body)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def stream_responses(messages, tools: nil, system: [], **options, &block)
|
|
54
|
+
body = {
|
|
55
|
+
model: model_key,
|
|
56
|
+
input: messages.flatten
|
|
57
|
+
}
|
|
58
|
+
body[:instructions] = system[0][:content] if system.any?
|
|
59
|
+
body[:tools] = tools if tools
|
|
60
|
+
body.merge!(options)
|
|
61
|
+
|
|
62
|
+
post_stream("responses", body, &block)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def get_oauth_access_token(access_token:, refresh_token:, expires_at:, account_id: nil, &block)
|
|
66
|
+
token_manager = LlmGateway::Clients::OpenAI::TokenManager.new(
|
|
67
|
+
access_token: access_token,
|
|
68
|
+
refresh_token: refresh_token,
|
|
69
|
+
expires_at: expires_at,
|
|
70
|
+
account_id: account_id
|
|
71
|
+
)
|
|
72
|
+
token_manager.on_token_refresh = block if block_given?
|
|
73
|
+
token_manager.ensure_valid_token
|
|
74
|
+
token_manager.access_token
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def chat_codex(messages, tools: nil, system: [], account_id: nil, **options)
|
|
78
|
+
body = build_codex_body(messages, system, tools, **options)
|
|
79
|
+
|
|
80
|
+
completed_response = nil
|
|
81
|
+
post_codex_stream("responses", body, account_id: account_id) do |raw_sse|
|
|
82
|
+
if raw_sse[:event] == "response.completed"
|
|
83
|
+
completed_response = raw_sse.dig(:data, :response)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
completed_response
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def stream_codex(messages, tools: nil, system: [], account_id: nil, **options, &block)
|
|
91
|
+
body = build_codex_body(messages, system, tools, **options)
|
|
92
|
+
post_codex_stream("responses", body, account_id: account_id, &block)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def download_file(file_id)
|
|
96
|
+
get("files/#{file_id}/content")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def generate_embeddings(input)
|
|
100
|
+
body = {
|
|
101
|
+
input:,
|
|
102
|
+
model: model_key
|
|
103
|
+
}
|
|
104
|
+
post("embeddings", body)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def upload_file(filename, content, mime_type = "application/octet-stream", purpose: "user_data")
|
|
108
|
+
post_file("files", content, filename, purpose: purpose, mime_type: mime_type)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def build_codex_body(messages, system, tools, **options)
|
|
114
|
+
instructions = Array(system).filter_map { |s| s.is_a?(Hash) ? s[:content] : s }.join("\n")
|
|
115
|
+
instructions = "You are a helpful assistant." if instructions.empty?
|
|
116
|
+
|
|
117
|
+
body = {
|
|
118
|
+
model: model_key,
|
|
119
|
+
instructions: instructions,
|
|
120
|
+
input: messages,
|
|
121
|
+
store: false,
|
|
122
|
+
include: [ "reasoning.encrypted_content" ],
|
|
123
|
+
stream: true
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
body[:tools] = tools if tools
|
|
127
|
+
body.merge!(options)
|
|
128
|
+
|
|
129
|
+
body
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def codex_headers(account_id: nil, **options)
|
|
133
|
+
effective_account_id = account_id || @account_id
|
|
134
|
+
|
|
135
|
+
headers = {
|
|
136
|
+
"content-type" => "application/json",
|
|
137
|
+
"Authorization" => "Bearer #{api_key}",
|
|
138
|
+
"OpenAI-Beta" => "responses=experimental"
|
|
139
|
+
}
|
|
140
|
+
headers["chatgpt-account-id"] = effective_account_id if effective_account_id
|
|
141
|
+
headers
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def post_codex_stream(url_part, body = nil, account_id: nil, &block)
|
|
145
|
+
endpoint = "#{CODEX_BASE_ENDPOINT}/#{url_part.sub(%r{^/}, "")}"
|
|
146
|
+
uri = URI(endpoint)
|
|
147
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
148
|
+
http.use_ssl = true
|
|
149
|
+
http.read_timeout = 480
|
|
150
|
+
http.open_timeout = 10
|
|
151
|
+
|
|
152
|
+
body.merge!(stream: true)
|
|
153
|
+
request = Net::HTTP::Post.new(uri)
|
|
154
|
+
codex_headers(account_id: account_id).each { |key, value| request[key] = value }
|
|
155
|
+
prompt_cache_key = body.delete(:prompt_cache_key)
|
|
156
|
+
request[:session_id] = prompt_cache_key if prompt_cache_key
|
|
157
|
+
|
|
158
|
+
request.body = body.to_json if body
|
|
159
|
+
|
|
160
|
+
http.request(request) do |response|
|
|
161
|
+
unless response.code.to_i == 200
|
|
162
|
+
full_body = +""
|
|
163
|
+
response.read_body { |chunk| full_body << chunk }
|
|
164
|
+
response.instance_variable_set(:@body, full_body)
|
|
165
|
+
response.instance_variable_set(:@read, true)
|
|
166
|
+
handle_error(response)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
parse_sse_stream(response, &block)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def build_headers
|
|
174
|
+
{
|
|
175
|
+
"content-type" => "application/json",
|
|
176
|
+
"Authorization" => "Bearer #{api_key}"
|
|
177
|
+
}
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def handle_client_specific_errors(response, error)
|
|
181
|
+
# OpenAI uses 'code' instead of 'type' for error codes
|
|
182
|
+
error_code = error["code"]
|
|
183
|
+
error_message = error["message"]
|
|
184
|
+
|
|
185
|
+
if Errors.context_overflow_message?(error_message)
|
|
186
|
+
raise Errors::PromptTooLong.new(error_message, error_code)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
case response.code.to_i
|
|
190
|
+
when 429
|
|
191
|
+
raise Errors::RateLimitError.new(error_message, error_code)
|
|
192
|
+
when 503
|
|
193
|
+
raise Errors::OverloadError.new(error_message, error_code)
|
|
194
|
+
end
|
|
195
|
+
# If we get here, we didn't handle it specifically
|
|
196
|
+
fallback_body = response.body.to_s.strip
|
|
197
|
+
fallback_message = if fallback_body.empty?
|
|
198
|
+
"OpenAI request failed with status #{response.code}"
|
|
199
|
+
else
|
|
200
|
+
"OpenAI request failed with status #{response.code}: #{fallback_body}"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
message = error["message"] || fallback_message
|
|
204
|
+
raise Errors::APIStatusError.new(message, error_code)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -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