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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -0
- data/README.md +565 -129
- data/Rakefile +8 -3
- 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/anthropic/bidirectional_message_mapper.rb +111 -0
- data/lib/llm_gateway/adapters/{claude → anthropic}/input_mapper.rb +12 -10
- data/lib/llm_gateway/adapters/anthropic/messages_adapter.rb +19 -0
- data/lib/llm_gateway/adapters/anthropic/output_mapper.rb +50 -0
- 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/openai/chat_completions/bidirectional_message_mapper.rb +110 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/input_mapper.rb +105 -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 +39 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/output_mapper.rb +40 -0
- 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/openai/file_output_mapper.rb +25 -0
- data/lib/llm_gateway/adapters/openai/prompt_cache_option_mapper.rb +39 -0
- data/lib/llm_gateway/adapters/openai/responses/bidirectional_message_mapper.rb +120 -0
- 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/openai/responses/output_mapper.rb +47 -0
- 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/{open_ai/output_mapper.rb → option_mapper.rb} +5 -2
- 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 +97 -1
- data/lib/llm_gateway/client.rb +66 -54
- 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 +23 -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 +169 -10
- 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 -21
- data/lib/llm_gateway/adapters/claude/client.rb +0 -56
- data/lib/llm_gateway/adapters/claude/output_mapper.rb +0 -30
- data/lib/llm_gateway/adapters/groq/client.rb +0 -58
- data/lib/llm_gateway/adapters/groq/input_mapper.rb +0 -105
- data/lib/llm_gateway/adapters/groq/output_mapper.rb +0 -62
- data/lib/llm_gateway/adapters/open_ai/client.rb +0 -59
- data/lib/llm_gateway/adapters/open_ai/input_mapper.rb +0 -63
- 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,162 @@
|
|
|
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
|
+
module ClaudeCode
|
|
14
|
+
class OAuthFlow
|
|
15
|
+
CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
|
16
|
+
TOKEN_URL = "https://api.anthropic.com/v1/oauth/token"
|
|
17
|
+
AUTH_URL = "https://claude.ai/oauth/authorize"
|
|
18
|
+
REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"
|
|
19
|
+
DEFAULT_SCOPES = "org:create_api_key user:profile user:inference"
|
|
20
|
+
|
|
21
|
+
attr_reader :client_id, :redirect_uri, :scopes
|
|
22
|
+
|
|
23
|
+
def initialize(
|
|
24
|
+
client_id: CLIENT_ID,
|
|
25
|
+
redirect_uri: REDIRECT_URI,
|
|
26
|
+
scopes: DEFAULT_SCOPES
|
|
27
|
+
)
|
|
28
|
+
@client_id = client_id
|
|
29
|
+
@redirect_uri = redirect_uri
|
|
30
|
+
@scopes = scopes
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Step 1: Generate the authorization URL for the user to visit.
|
|
34
|
+
# Returns a hash with everything needed to complete the flow later.
|
|
35
|
+
def start(state: SecureRandom.hex(16))
|
|
36
|
+
code_verifier, code_challenge = generate_pkce
|
|
37
|
+
|
|
38
|
+
auth_url = build_authorization_url(code_challenge, state)
|
|
39
|
+
|
|
40
|
+
{
|
|
41
|
+
authorization_url: auth_url,
|
|
42
|
+
code_verifier: code_verifier,
|
|
43
|
+
state: state
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Step 2: Exchange the authorization code for tokens.
|
|
48
|
+
# Accepts one of:
|
|
49
|
+
# - "code#state" (legacy format)
|
|
50
|
+
# - a raw authorization code, with state passed separately
|
|
51
|
+
# - a full callback URL containing ?code=...&state=...
|
|
52
|
+
# Returns { access_token:, refresh_token:, expires_at: }
|
|
53
|
+
def exchange_code(auth_code_or_callback, code_verifier, state: nil)
|
|
54
|
+
code, resolved_state = extract_code_and_state(auth_code_or_callback, state)
|
|
55
|
+
|
|
56
|
+
uri = URI(TOKEN_URL)
|
|
57
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
58
|
+
http.use_ssl = true
|
|
59
|
+
http.read_timeout = 30
|
|
60
|
+
http.open_timeout = 10
|
|
61
|
+
|
|
62
|
+
request = Net::HTTP::Post.new(uri)
|
|
63
|
+
request["Content-Type"] = "application/json"
|
|
64
|
+
|
|
65
|
+
request.body = {
|
|
66
|
+
grant_type: "authorization_code",
|
|
67
|
+
client_id: @client_id,
|
|
68
|
+
code: code,
|
|
69
|
+
state: resolved_state || "",
|
|
70
|
+
redirect_uri: @redirect_uri,
|
|
71
|
+
code_verifier: code_verifier
|
|
72
|
+
}.to_json
|
|
73
|
+
|
|
74
|
+
response = http.request(request)
|
|
75
|
+
|
|
76
|
+
if response.code.to_i == 200
|
|
77
|
+
data = JSON.parse(response.body)
|
|
78
|
+
|
|
79
|
+
expires_at = if data["expires_in"]
|
|
80
|
+
Time.now + data["expires_in"].to_i
|
|
81
|
+
elsif data["expires_at"]
|
|
82
|
+
Time.parse(data["expires_at"])
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
{
|
|
86
|
+
access_token: data["access_token"],
|
|
87
|
+
refresh_token: data["refresh_token"],
|
|
88
|
+
expires_at: expires_at
|
|
89
|
+
}
|
|
90
|
+
else
|
|
91
|
+
error_body = begin
|
|
92
|
+
JSON.parse(response.body)
|
|
93
|
+
rescue StandardError
|
|
94
|
+
{}
|
|
95
|
+
end
|
|
96
|
+
raise Errors::AuthenticationError.new(
|
|
97
|
+
"OAuth token exchange failed: #{error_body["error_description"] || error_body["error"] || response.body}",
|
|
98
|
+
error_body["error"]
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def parse_callback(callback_url)
|
|
104
|
+
uri = URI(callback_url)
|
|
105
|
+
code = uri.query && URI.decode_www_form(uri.query).to_h["code"]
|
|
106
|
+
state = uri.query && URI.decode_www_form(uri.query).to_h["state"]
|
|
107
|
+
|
|
108
|
+
raise ArgumentError, "Callback URL is missing code parameter" if code.nil? || code.empty?
|
|
109
|
+
|
|
110
|
+
{ code: code, state: state }
|
|
111
|
+
rescue URI::InvalidURIError => e
|
|
112
|
+
raise ArgumentError, "Invalid callback URL: #{e.message}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def extract_code_and_state(auth_code_or_callback, state)
|
|
118
|
+
value = auth_code_or_callback.to_s.strip
|
|
119
|
+
raise ArgumentError, "Authorization code is required" if value.empty?
|
|
120
|
+
|
|
121
|
+
if looks_like_url?(value)
|
|
122
|
+
callback = parse_callback(value)
|
|
123
|
+
[ callback[:code], callback[:state] || state ]
|
|
124
|
+
elsif value.include?("#")
|
|
125
|
+
code, parsed_state = value.split("#", 2)
|
|
126
|
+
[ code, parsed_state || state ]
|
|
127
|
+
else
|
|
128
|
+
[ value, state ]
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def looks_like_url?(value)
|
|
133
|
+
value.start_with?("http://", "https://")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def generate_pkce
|
|
137
|
+
code_verifier = [ SecureRandom.random_bytes(32) ].pack("m0").tr("+/", "-_").tr("=", "")
|
|
138
|
+
|
|
139
|
+
digest = Digest::SHA256.digest(code_verifier)
|
|
140
|
+
code_challenge = [ digest ].pack("m0").tr("+/", "-_").tr("=", "")
|
|
141
|
+
|
|
142
|
+
[ code_verifier, code_challenge ]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def build_authorization_url(code_challenge, state)
|
|
146
|
+
params = {
|
|
147
|
+
code: "true",
|
|
148
|
+
client_id: @client_id,
|
|
149
|
+
response_type: "code",
|
|
150
|
+
redirect_uri: @redirect_uri,
|
|
151
|
+
scope: @scopes,
|
|
152
|
+
code_challenge: code_challenge,
|
|
153
|
+
code_challenge_method: "S256",
|
|
154
|
+
state: state
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
"#{AUTH_URL}?#{URI.encode_www_form(params)}"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module LlmGateway
|
|
8
|
+
module Clients
|
|
9
|
+
module ClaudeCode
|
|
10
|
+
class TokenManager
|
|
11
|
+
TOKEN_URL = "https://api.anthropic.com/v1/oauth/token"
|
|
12
|
+
CLIENT_ID = OAuthFlow::CLIENT_ID
|
|
13
|
+
|
|
14
|
+
attr_reader :refresh_token, :expires_at, :client_id, :client_secret, :access_token
|
|
15
|
+
attr_accessor :on_token_refresh
|
|
16
|
+
|
|
17
|
+
def initialize(
|
|
18
|
+
access_token: nil,
|
|
19
|
+
refresh_token:,
|
|
20
|
+
expires_at: nil,
|
|
21
|
+
client_id: CLIENT_ID,
|
|
22
|
+
client_secret: nil
|
|
23
|
+
)
|
|
24
|
+
@access_token = access_token
|
|
25
|
+
@refresh_token = refresh_token
|
|
26
|
+
@expires_at = parse_expires_at(expires_at)
|
|
27
|
+
@client_id = client_id
|
|
28
|
+
@client_secret = client_secret
|
|
29
|
+
@on_token_refresh = nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def token_expired?
|
|
33
|
+
return true if @expires_at.nil?
|
|
34
|
+
Time.now >= @expires_at
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def ensure_valid_token
|
|
38
|
+
refresh_access_token if token_expired?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def refresh_access_token
|
|
42
|
+
raise ArgumentError, "Cannot refresh token: refresh_token not provided" unless @refresh_token
|
|
43
|
+
raise ArgumentError, "Cannot refresh token: client_id not provided" unless @client_id
|
|
44
|
+
|
|
45
|
+
uri = URI(TOKEN_URL)
|
|
46
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
47
|
+
http.use_ssl = true
|
|
48
|
+
http.read_timeout = 30
|
|
49
|
+
http.open_timeout = 10
|
|
50
|
+
|
|
51
|
+
request = Net::HTTP::Post.new(uri)
|
|
52
|
+
request["Content-Type"] = "application/json"
|
|
53
|
+
|
|
54
|
+
request_body = {
|
|
55
|
+
grant_type: "refresh_token",
|
|
56
|
+
client_id: @client_id,
|
|
57
|
+
refresh_token: @refresh_token
|
|
58
|
+
}
|
|
59
|
+
request_body[:client_secret] = @client_secret if @client_secret
|
|
60
|
+
|
|
61
|
+
request.body = request_body.to_json
|
|
62
|
+
|
|
63
|
+
response = http.request(request)
|
|
64
|
+
|
|
65
|
+
if response.code.to_i == 200
|
|
66
|
+
data = JSON.parse(response.body)
|
|
67
|
+
@access_token = data["access_token"]
|
|
68
|
+
|
|
69
|
+
if data["refresh_token"]
|
|
70
|
+
@refresh_token = data["refresh_token"]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
if data["expires_in"]
|
|
74
|
+
@expires_at = Time.now + data["expires_in"].to_i
|
|
75
|
+
elsif data["expires_at"]
|
|
76
|
+
@expires_at = Time.parse(data["expires_at"])
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
@on_token_refresh&.call(@access_token, @refresh_token, @expires_at)
|
|
80
|
+
|
|
81
|
+
@access_token
|
|
82
|
+
else
|
|
83
|
+
error_body = begin
|
|
84
|
+
JSON.parse(response.body)
|
|
85
|
+
rescue StandardError
|
|
86
|
+
{}
|
|
87
|
+
end
|
|
88
|
+
raise Errors::AuthenticationError.new(
|
|
89
|
+
"Failed to refresh token: #{error_body['error'] || response.body}",
|
|
90
|
+
error_body["error_code"]
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def parse_expires_at(expires)
|
|
98
|
+
case expires
|
|
99
|
+
when Time
|
|
100
|
+
expires
|
|
101
|
+
when String
|
|
102
|
+
Time.parse(expires)
|
|
103
|
+
when Integer
|
|
104
|
+
Time.at(expires)
|
|
105
|
+
else
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -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
|