diff --git a/lib/ruby_llm/providers/openrouter.rb b/lib/ruby_llm/providers/openrouter.rb index e8056b027..e80282085 100644 --- a/lib/ruby_llm/providers/openrouter.rb +++ b/lib/ruby_llm/providers/openrouter.rb @@ -16,6 +16,35 @@ def headers } end + def parse_error(response) + return if response.body.empty? + + body = try_parse_json(response.body) + case body + when Hash + parse_error_part_message body + when Array + body.map do |part| + parse_error_part_message part + end.join('. ') + else + body + end + end + + private + + def parse_error_part_message(part) + message = part.dig('error', 'message') + raw = try_parse_json(part.dig('error', 'metadata', 'raw')) + return message unless raw.is_a?(Hash) + + raw_message = raw.dig('error', 'message') + return [message, raw_message].join(' - ') if raw_message + + message + end + class << self def configuration_requirements %i[openrouter_api_key] diff --git a/spec/fixtures/vcr_cassettes/providers_openrouter_parse_error_with_openrouter_openai_o3-mini_raises_detailed_error.yml b/spec/fixtures/vcr_cassettes/providers_openrouter_parse_error_with_openrouter_openai_o3-mini_raises_detailed_error.yml new file mode 100644 index 000000000..6acc84a86 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/providers_openrouter_parse_error_with_openrouter_openai_o3-mini_raises_detailed_error.yml @@ -0,0 +1,61 @@ +--- +http_interactions: +- request: + method: post + uri: https://openrouter.ai/api/v1/chat/completions + body: + encoding: UTF-8 + string: '{"model":"openai/o3-mini","messages":[{"role":"user","content":"Hello"}],"stream":false}' + headers: + User-Agent: + - Faraday v2.13.1 + Authorization: + - Bearer sk-or-v1-mocked-key + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 403 + message: Forbidden + headers: + Date: + - Sun, 28 Sep 2025 03:38:43 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cf-Ray: + - "" + Cf-Cache-Status: + - DYNAMIC + Access-Control-Allow-Origin: + - "*" + Vary: + - Accept-Encoding + Permissions-Policy: + - payment=(self "https://checkout.stripe.com" "https://connect-js.stripe.com" + "https://js.stripe.com" "https://*.js.stripe.com" "https://hooks.stripe.com") + - payment=(self "https://checkout.stripe.com" "https://connect-js.stripe.com" + "https://js.stripe.com" "https://*.js.stripe.com" "https://hooks.stripe.com") + Referrer-Policy: + - no-referrer, strict-origin-when-cross-origin + - no-referrer, strict-origin-when-cross-origin + X-Cloud-Trace-Context: + - dd540491e0534821af69fdd9d8a36c52 + X-Content-Type-Options: + - nosniff + - nosniff + Server: + - cloudflare + body: + encoding: ASCII-8BIT + string: '{"error":{"message":"Provider returned error","code":403,"metadata":{"raw":"{\"error\":{\"code\":\"unsupported_country_region_territory\",\"message\":\"Country, + region, or territory not supported\",\"param\":null,\"type\":\"request_forbidden\"}}","provider_name":"OpenAI"}},"user_id":"user_mocked_a"}' + recorded_at: Sun, 28 Sep 2025 03:38:43 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/ruby_llm/providers/openrouter/parse_error_spec.rb b/spec/ruby_llm/providers/openrouter/parse_error_spec.rb new file mode 100644 index 000000000..aacaa10eb --- /dev/null +++ b/spec/ruby_llm/providers/openrouter/parse_error_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe RubyLLM::Providers::OpenRouter do # rubocop:disable RSpec/SpecFilePathFormat + describe '#parse_error' do + model = 'openai/o3-mini' + provider = 'openrouter' + context "with #{provider}/#{model}" do + let(:chat) { RubyLLM.chat(model: model, provider: provider) } + + before do + RubyLLM.config.openrouter_api_key = 'sk-or-v1-sk-or-v1-mocked-key' + end + + it 'raises detailed error' do + expect { chat.ask('Hello') }.to raise_error do |error| + expect(error).to be_a(RubyLLM::Error) + expect(error.message).to eq 'Provider returned error - Country, region, or territory not supported' + end + end + end + end +end