llm_gateway 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/.pi/skills/live-provider-testing/SKILL.md +183 -0
  3. data/.pi/skills/options-development/SKILL.md +131 -0
  4. data/CHANGELOG.md +43 -0
  5. data/README.md +559 -185
  6. data/Rakefile +2 -2
  7. data/docs/migration-guide.md +135 -0
  8. data/lib/llm_gateway/adapters/adapter.rb +140 -0
  9. data/lib/llm_gateway/adapters/anthropic/acts_like_messages.rb +21 -0
  10. data/lib/llm_gateway/adapters/anthropic/input_mapper.rb +137 -0
  11. data/lib/llm_gateway/adapters/anthropic/messages_adapter.rb +19 -0
  12. data/lib/llm_gateway/adapters/anthropic/output_mapper.rb +17 -0
  13. data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +95 -0
  14. data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +95 -0
  15. data/lib/llm_gateway/adapters/groq/chat_completions_adapter.rb +48 -0
  16. data/lib/llm_gateway/adapters/groq/input_mapper.rb +32 -6
  17. data/lib/llm_gateway/adapters/groq/option_mapper.rb +112 -0
  18. data/lib/llm_gateway/adapters/input_message_sanitizer.rb +93 -0
  19. data/lib/llm_gateway/adapters/normalized_stream_accumulator.rb +275 -0
  20. data/lib/llm_gateway/adapters/openai/acts_like_chat_completions.rb +20 -0
  21. data/lib/llm_gateway/adapters/openai/acts_like_responses.rb +25 -0
  22. data/lib/llm_gateway/adapters/openai/chat_completions/input_mapper.rb +168 -0
  23. data/lib/llm_gateway/adapters/openai/chat_completions/input_message_sanitizer.rb +65 -0
  24. data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +129 -0
  25. data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +241 -0
  26. data/lib/llm_gateway/adapters/openai/chat_completions_adapter.rb +19 -0
  27. data/lib/llm_gateway/adapters/{open_ai → openai}/file_output_mapper.rb +1 -1
  28. data/lib/llm_gateway/adapters/openai/prompt_cache_option_mapper.rb +39 -0
  29. data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +166 -0
  30. data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +130 -0
  31. data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +150 -0
  32. data/lib/llm_gateway/adapters/openai/responses_adapter.rb +19 -0
  33. data/lib/llm_gateway/adapters/openai_codex/input_mapper.rb +206 -0
  34. data/lib/llm_gateway/adapters/openai_codex/option_mapper.rb +28 -0
  35. data/lib/llm_gateway/adapters/openai_codex/responses_adapter.rb +33 -0
  36. data/lib/llm_gateway/adapters/option_mapper.rb +13 -0
  37. data/lib/llm_gateway/adapters/stream_mapper.rb +50 -0
  38. data/lib/llm_gateway/adapters/structs.rb +145 -0
  39. data/lib/llm_gateway/base_client.rb +62 -1
  40. data/lib/llm_gateway/client.rb +18 -158
  41. data/lib/llm_gateway/clients/anthropic.rb +167 -0
  42. data/lib/llm_gateway/clients/claude_code/oauth_flow.rb +162 -0
  43. data/lib/llm_gateway/clients/claude_code/token_manager.rb +112 -0
  44. data/lib/llm_gateway/clients/groq.rb +66 -0
  45. data/lib/llm_gateway/clients/openai.rb +208 -0
  46. data/lib/llm_gateway/clients/openai_codex/oauth_flow.rb +258 -0
  47. data/lib/llm_gateway/clients/openai_codex/token_manager.rb +71 -0
  48. data/lib/llm_gateway/errors.rb +21 -0
  49. data/lib/llm_gateway/prompt.rb +12 -1
  50. data/lib/llm_gateway/provider_registry.rb +37 -0
  51. data/lib/llm_gateway/version.rb +1 -1
  52. data/lib/llm_gateway.rb +162 -17
  53. data/scripts/create_anthropic_credentials.rb +106 -0
  54. data/scripts/create_openai_codex_credentials.rb +116 -0
  55. metadata +60 -27
  56. data/lib/llm_gateway/adapters/claude/bidirectional_message_mapper.rb +0 -83
  57. data/lib/llm_gateway/adapters/claude/client.rb +0 -60
  58. data/lib/llm_gateway/adapters/claude/input_mapper.rb +0 -57
  59. data/lib/llm_gateway/adapters/claude/output_mapper.rb +0 -50
  60. data/lib/llm_gateway/adapters/groq/bidirectional_message_mapper.rb +0 -18
  61. data/lib/llm_gateway/adapters/groq/client.rb +0 -58
  62. data/lib/llm_gateway/adapters/groq/output_mapper.rb +0 -10
  63. data/lib/llm_gateway/adapters/open_ai/chat_completions/bidirectional_message_mapper.rb +0 -103
  64. data/lib/llm_gateway/adapters/open_ai/chat_completions/input_mapper.rb +0 -110
  65. data/lib/llm_gateway/adapters/open_ai/chat_completions/output_mapper.rb +0 -40
  66. data/lib/llm_gateway/adapters/open_ai/client.rb +0 -80
  67. data/lib/llm_gateway/adapters/open_ai/responses/bidirectional_message_mapper.rb +0 -72
  68. data/lib/llm_gateway/adapters/open_ai/responses/input_mapper.rb +0 -62
  69. data/lib/llm_gateway/adapters/open_ai/responses/output_mapper.rb +0 -47
  70. data/sample/claude_code_clone/agent.rb +0 -65
  71. data/sample/claude_code_clone/claude_code_clone.rb +0 -40
  72. data/sample/claude_code_clone/prompt.rb +0 -79
  73. data/sample/claude_code_clone/run.rb +0 -47
  74. data/sample/claude_code_clone/tools/bash_tool.rb +0 -54
  75. data/sample/claude_code_clone/tools/edit_tool.rb +0 -61
  76. data/sample/claude_code_clone/tools/grep_tool.rb +0 -113
  77. data/sample/claude_code_clone/tools/read_tool.rb +0 -61
  78. data/sample/claude_code_clone/tools/todowrite_tool.rb +0 -98
@@ -0,0 +1,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,66 @@
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-120b", 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
+ def stream(messages, tools: nil, system: [], **options, &block)
25
+ body = {
26
+ model: model_key,
27
+ messages: system + messages,
28
+ tools: tools,
29
+ stream_options: { include_usage: true }
30
+ }
31
+ body.merge!(options)
32
+
33
+ post_stream("chat/completions", body, &block)
34
+ end
35
+
36
+ private
37
+
38
+ def build_headers
39
+ {
40
+ "content-type" => "application/json",
41
+ "Authorization" => "Bearer #{api_key}"
42
+ }
43
+ end
44
+
45
+ def handle_client_specific_errors(response, error)
46
+ # Groq likely uses 'code' like OpenAI since it's OpenAI-compatible
47
+ error_code = error["code"]
48
+ error_message = error["message"]
49
+
50
+ if Errors.context_overflow_message?(error_message)
51
+ raise Errors::PromptTooLong.new(error_message, error["type"])
52
+ end
53
+
54
+ case response.code.to_i
55
+ when 429
56
+ raise Errors::RateLimitError.new(error["type"], error_code) if error_code == "rate_limit_exceeded"
57
+
58
+ raise Errors::OverloadError.new(error_message, error_code)
59
+ end
60
+
61
+ # If we get here, we didn't handle it specifically
62
+ raise Errors::APIStatusError.new(error_message, error_code)
63
+ end
64
+ end
65
+ end
66
+ 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