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.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/README.md +544 -186
  4. data/Rakefile +1 -2
  5. data/docs/migration-guide.md +135 -0
  6. data/lib/llm_gateway/adapters/adapter.rb +173 -0
  7. data/lib/llm_gateway/adapters/anthropic/acts_like_messages.rb +23 -0
  8. data/lib/llm_gateway/adapters/{claude → anthropic}/bidirectional_message_mapper.rb +31 -3
  9. data/lib/llm_gateway/adapters/{claude → anthropic}/input_mapper.rb +4 -3
  10. data/lib/llm_gateway/adapters/anthropic/messages_adapter.rb +19 -0
  11. data/lib/llm_gateway/adapters/{claude → anthropic}/output_mapper.rb +1 -1
  12. data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +110 -0
  13. data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +53 -0
  14. data/lib/llm_gateway/adapters/groq/chat_completions_adapter.rb +47 -0
  15. data/lib/llm_gateway/adapters/groq/option_mapper.rb +27 -0
  16. data/lib/llm_gateway/adapters/input_message_sanitizer.rb +93 -0
  17. data/lib/llm_gateway/adapters/openai/acts_like_chat_completions.rb +22 -0
  18. data/lib/llm_gateway/adapters/openai/acts_like_responses.rb +31 -0
  19. data/lib/llm_gateway/adapters/{open_ai → openai}/chat_completions/bidirectional_message_mapper.rb +9 -2
  20. data/lib/llm_gateway/adapters/{open_ai → openai}/chat_completions/input_mapper.rb +1 -6
  21. data/lib/llm_gateway/adapters/openai/chat_completions/input_message_sanitizer.rb +65 -0
  22. data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +39 -0
  23. data/lib/llm_gateway/adapters/{open_ai → openai}/chat_completions/output_mapper.rb +1 -1
  24. data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +242 -0
  25. data/lib/llm_gateway/adapters/openai/chat_completions_adapter.rb +20 -0
  26. data/lib/llm_gateway/adapters/{open_ai → openai}/file_output_mapper.rb +1 -1
  27. data/lib/llm_gateway/adapters/openai/prompt_cache_option_mapper.rb +39 -0
  28. data/lib/llm_gateway/adapters/{open_ai → openai}/responses/bidirectional_message_mapper.rb +52 -4
  29. data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +106 -0
  30. data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +41 -0
  31. data/lib/llm_gateway/adapters/{open_ai → openai}/responses/output_mapper.rb +1 -1
  32. data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +340 -0
  33. data/lib/llm_gateway/adapters/openai/responses_adapter.rb +20 -0
  34. data/lib/llm_gateway/adapters/openai_codex/input_mapper.rb +206 -0
  35. data/lib/llm_gateway/adapters/openai_codex/option_mapper.rb +28 -0
  36. data/lib/llm_gateway/adapters/openai_codex/responses_adapter.rb +38 -0
  37. data/lib/llm_gateway/adapters/option_mapper.rb +13 -0
  38. data/lib/llm_gateway/adapters/stream_accumulator.rb +91 -0
  39. data/lib/llm_gateway/adapters/structs.rb +145 -0
  40. data/lib/llm_gateway/base_client.rb +62 -1
  41. data/lib/llm_gateway/client.rb +45 -129
  42. data/lib/llm_gateway/clients/anthropic.rb +167 -0
  43. data/lib/llm_gateway/clients/claude_code/oauth_flow.rb +162 -0
  44. data/lib/llm_gateway/clients/claude_code/token_manager.rb +112 -0
  45. data/lib/llm_gateway/clients/groq.rb +54 -0
  46. data/lib/llm_gateway/clients/openai.rb +208 -0
  47. data/lib/llm_gateway/clients/openai_codex/oauth_flow.rb +258 -0
  48. data/lib/llm_gateway/clients/openai_codex/token_manager.rb +71 -0
  49. data/lib/llm_gateway/errors.rb +21 -0
  50. data/lib/llm_gateway/prompt.rb +12 -1
  51. data/lib/llm_gateway/provider_registry.rb +37 -0
  52. data/lib/llm_gateway/version.rb +1 -1
  53. data/lib/llm_gateway.rb +165 -14
  54. data/scripts/create_anthropic_credentials.rb +106 -0
  55. data/scripts/create_openai_codex_credentials.rb +116 -0
  56. data/scripts/generate_handoff_live_fixture.rb +169 -0
  57. data/scripts/generate_handoff_media_fixture.rb +167 -0
  58. metadata +64 -28
  59. data/lib/llm_gateway/adapters/claude/client.rb +0 -60
  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/input_mapper.rb +0 -18
  63. data/lib/llm_gateway/adapters/groq/output_mapper.rb +0 -10
  64. data/lib/llm_gateway/adapters/open_ai/client.rb +0 -80
  65. data/lib/llm_gateway/adapters/open_ai/responses/input_mapper.rb +0 -62
  66. data/sample/claude_code_clone/agent.rb +0 -65
  67. data/sample/claude_code_clone/claude_code_clone.rb +0 -40
  68. data/sample/claude_code_clone/prompt.rb +0 -79
  69. data/sample/claude_code_clone/run.rb +0 -47
  70. data/sample/claude_code_clone/tools/bash_tool.rb +0 -54
  71. data/sample/claude_code_clone/tools/edit_tool.rb +0 -61
  72. data/sample/claude_code_clone/tools/grep_tool.rb +0 -113
  73. data/sample/claude_code_clone/tools/read_tool.rb +0 -61
  74. data/sample/claude_code_clone/tools/todowrite_tool.rb +0 -98
@@ -0,0 +1,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
@@ -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.4.0"
5
5
  end