prescient 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.
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ # PostgreSQL pgvector integration.
6
+ #
7
+ # The integration accepts a PG-compatible connection object, so applications
8
+ # choose and manage their own PostgreSQL driver and connection lifecycle.
9
+ # Stores provider embeddings and performs nearest-neighbor searches.
10
+ class Prescient::Pgvector::Store
11
+ # @return [Hash<Symbol, String>] Supported pgvector distance operators
12
+ METRICS = {
13
+ cosine: '<=>',
14
+ euclidean: '<->',
15
+ inner_product: '<#>',
16
+ }.freeze
17
+
18
+ # @return [Integer] Required vector dimensions
19
+ attr_reader :dimensions
20
+
21
+ # @return [String] Embeddings table name
22
+ attr_reader :table
23
+
24
+ # @param connection [Object] PG-compatible object responding to `exec`
25
+ # and `exec_params`
26
+ # @param dimensions [Integer] Required dimensions for every embedding
27
+ # @param table [String, Symbol] Safe PostgreSQL table identifier
28
+ def initialize(connection:, dimensions:, table: 'prescient_embeddings')
29
+ @connection = connection
30
+ @dimensions = validate_dimensions(dimensions)
31
+ @table = validate_table(table)
32
+ end
33
+
34
+ # Create the pgvector extension and the embeddings table.
35
+ #
36
+ # @return [void]
37
+ def install!
38
+ @connection.exec('CREATE EXTENSION IF NOT EXISTS vector')
39
+ @connection.exec(<<~SQL)
40
+ CREATE TABLE IF NOT EXISTS #{table} (
41
+ id text PRIMARY KEY,
42
+ provider text NOT NULL,
43
+ model text NOT NULL,
44
+ dimensions integer NOT NULL CHECK (dimensions = #{dimensions}),
45
+ embedding vector(#{dimensions}) NOT NULL,
46
+ content text,
47
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
48
+ created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
49
+ updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
50
+ )
51
+ SQL
52
+ end
53
+
54
+ # Create an HNSW index for the selected distance metric.
55
+ #
56
+ # @param metric [Symbol] `:cosine`, `:euclidean`, or `:inner_product`
57
+ # @return [void]
58
+ def create_index!(metric: :cosine)
59
+ metric_name = validate_metric(metric)
60
+ @connection.exec(<<~SQL)
61
+ CREATE INDEX IF NOT EXISTS #{table}_#{metric_name}_embedding_idx
62
+ ON #{table} USING hnsw (embedding #{metric_operator_class(metric_name)})
63
+ SQL
64
+ end
65
+
66
+ # Insert or replace an embedding record.
67
+ #
68
+ # @return [Hash] Stored record metadata
69
+ def upsert(id:, embedding:, provider:, model:, content: nil, metadata: {})
70
+ vector = serialize_vector(embedding)
71
+ parameters = [id.to_s, provider.to_s, model.to_s, dimensions, vector, content, JSON.generate(metadata)]
72
+ result = @connection.exec_params(<<~SQL, parameters)
73
+ INSERT INTO #{table} (id, provider, model, dimensions, embedding, content, metadata)
74
+ VALUES ($1, $2, $3, $4, $5::vector, $6, $7::jsonb)
75
+ ON CONFLICT (id) DO UPDATE SET
76
+ provider = EXCLUDED.provider,
77
+ model = EXCLUDED.model,
78
+ dimensions = EXCLUDED.dimensions,
79
+ embedding = EXCLUDED.embedding,
80
+ content = EXCLUDED.content,
81
+ metadata = EXCLUDED.metadata,
82
+ updated_at = CURRENT_TIMESTAMP
83
+ RETURNING id, provider, model, dimensions, content, metadata
84
+ SQL
85
+
86
+ record_from(result.first)
87
+ end
88
+
89
+ # Find the nearest stored embeddings.
90
+ #
91
+ # @param embedding [Array<Numeric>] Query vector
92
+ # @param limit [Integer] Maximum result count
93
+ # @param metric [Symbol] Distance metric
94
+ # @param provider [String, Symbol, nil] Optional provider filter
95
+ # @param model [String, nil] Optional model filter
96
+ # @return [Array<Hash>] Records ordered by ascending distance
97
+ def search(embedding:, limit: 10, metric: :cosine, provider: nil, model: nil)
98
+ vector = serialize_vector(embedding)
99
+ limit = validate_limit(limit)
100
+ metric = validate_metric(metric)
101
+ filters, parameters = search_filters(provider, model)
102
+ result = @connection.exec_params(search_query(metric, filters), [vector, limit, *parameters])
103
+
104
+ result.map { |row| record_from(row) }
105
+ end
106
+
107
+ private
108
+
109
+ def validate_dimensions(value)
110
+ return value if value.is_a?(Integer) && value.positive?
111
+
112
+ raise ArgumentError, 'dimensions must be a positive integer'
113
+ end
114
+
115
+ def validate_table(value)
116
+ table = value.to_s
117
+ return table if /\A[a-z_][a-z0-9_]*\z/.match?(table)
118
+
119
+ raise ArgumentError, 'table must be a lowercase PostgreSQL identifier'
120
+ end
121
+
122
+ def serialize_vector(embedding)
123
+ unless embedding.is_a?(Array) && embedding.length == dimensions
124
+ raise Prescient::InvalidVectorError, "embedding must contain exactly #{dimensions} values"
125
+ end
126
+
127
+ values = embedding.map { |value| Float(value) }
128
+ raise Prescient::InvalidVectorError, 'embedding values must be finite' unless values.all?(&:finite?)
129
+
130
+ "[#{values.join(',')}]"
131
+ rescue ArgumentError, TypeError
132
+ raise Prescient::InvalidVectorError, 'embedding values must be numeric'
133
+ end
134
+
135
+ def validate_limit(value)
136
+ return value if value.is_a?(Integer) && value.positive?
137
+
138
+ raise ArgumentError, 'limit must be a positive integer'
139
+ end
140
+
141
+ def validate_metric(value)
142
+ return value if METRICS.key?(value)
143
+
144
+ raise ArgumentError, "unsupported distance metric: #{value}"
145
+ end
146
+
147
+ def metric_operator_class(metric)
148
+ {
149
+ cosine: 'vector_cosine_ops',
150
+ euclidean: 'vector_l2_ops',
151
+ inner_product: 'vector_ip_ops',
152
+ }.fetch(metric)
153
+ end
154
+
155
+ def search_filters(provider, model)
156
+ filters = []
157
+ parameters = []
158
+ if provider
159
+ filters << "provider = $#{parameters.length + 3}"
160
+ parameters << provider.to_s
161
+ end
162
+ if model
163
+ filters << "model = $#{parameters.length + 3}"
164
+ parameters << model
165
+ end
166
+
167
+ [filters, parameters]
168
+ end
169
+
170
+ def search_query(metric, filters)
171
+ where = filters.empty? ? '' : "WHERE #{filters.join(' AND ')}"
172
+
173
+ <<~SQL
174
+ SELECT id, provider, model, dimensions, content, metadata,
175
+ embedding #{METRICS.fetch(metric)} $1::vector AS distance
176
+ FROM #{table}
177
+ #{where}
178
+ ORDER BY embedding #{METRICS.fetch(metric)} $1::vector
179
+ LIMIT $2
180
+ SQL
181
+ end
182
+
183
+ def record_from(row)
184
+ {
185
+ id: row.fetch('id'),
186
+ provider: row.fetch('provider'),
187
+ model: row.fetch('model'),
188
+ dimensions: Integer(row.fetch('dimensions')),
189
+ content: row.fetch('content'),
190
+ metadata: JSON.parse(row.fetch('metadata')),
191
+ distance: row['distance']&.to_f,
192
+ }
193
+ end
194
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'httparty'
4
4
 
5
+ # Anthropic Messages API provider adapter.
5
6
  class Prescient::Provider::Anthropic < Prescient::Base
6
7
  include HTTParty
7
8
 
@@ -12,12 +13,19 @@ class Prescient::Provider::Anthropic < Prescient::Base
12
13
  self.class.default_timeout(@options[:timeout] || 60)
13
14
  end
14
15
 
16
+ # Anthropic does not provide embeddings through this adapter.
17
+ #
18
+ # @raise [Prescient::Error] Always, because Anthropic embeddings are not supported
15
19
  def generate_embedding(_text, **_options)
16
20
  # Anthropic doesn't provide embedding API, raise error
17
21
  raise Prescient::Error,
18
22
  'Anthropic provider does not support embeddings. Use OpenAI or HuggingFace for embeddings.'
19
23
  end
20
24
 
25
+ # Generate a response using Anthropic's Messages API.
26
+ # @param prompt [String] Prompt to send
27
+ # @param context_items [Array<Hash, String>] Optional context items
28
+ # @return [Hash] Normalized response data
21
29
  def generate_response(prompt, context_items = [], **options)
22
30
  handle_errors do
23
31
  formatted_prompt = build_prompt(prompt, context_items)
@@ -29,7 +37,7 @@ class Prescient::Provider::Anthropic < Prescient::Base
29
37
  'anthropic-version' => '2023-06-01',
30
38
  },
31
39
  body: {
32
- model: @options[:model],
40
+ model: options[:model] || @options[:model],
33
41
  max_tokens: options[:max_tokens] || 2000,
34
42
  temperature: options[:temperature] || 0.7,
35
43
  messages: [
@@ -47,7 +55,7 @@ class Prescient::Provider::Anthropic < Prescient::Base
47
55
 
48
56
  {
49
57
  response: content.strip,
50
- model: @options[:model],
58
+ model: options[:model] || @options[:model],
51
59
  provider: 'anthropic',
52
60
  processing_time: nil,
53
61
  metadata: {
@@ -57,58 +65,63 @@ class Prescient::Provider::Anthropic < Prescient::Base
57
65
  end
58
66
  end
59
67
 
68
+ # Check Anthropic API availability using the non-generating `/v1/models` endpoint.
69
+ #
70
+ # `reachable` indicates the API answered successfully. `ready` indicates that
71
+ # the configured model appears in the returned model list.
72
+ #
73
+ # @return [Hash] Provider health information
60
74
  def health_check
61
75
  handle_errors do
62
- # Test with a simple message
63
- response = self.class.post('/v1/messages',
64
- headers: {
65
- 'Content-Type' => 'application/json',
66
- 'x-api-key' => @options[:api_key],
67
- 'anthropic-version' => '2023-06-01',
68
- },
69
- body: {
70
- model: @options[:model],
71
- max_tokens: 10,
72
- messages: [
73
- {
74
- role: 'user',
75
- content: 'Test',
76
- },
77
- ],
78
- }.to_json)
76
+ response = self.class.get('/v1/models', headers: api_headers)
79
77
 
80
78
  if response.success?
79
+ models = response.parsed_response['data'] || []
80
+ model_available = models.any? { |model| model['id'] == @options[:model] }
81
81
  {
82
- status: 'healthy',
83
- provider: 'anthropic',
84
- model: @options[:model],
85
- ready: true,
82
+ status: 'healthy',
83
+ provider: 'anthropic',
84
+ reachable: true,
85
+ models_available: models.map { |model| model['id'] },
86
+ model: { name: @options[:model], available: model_available },
87
+ ready: model_available,
86
88
  }
87
89
  else
88
90
  {
89
- status: 'unhealthy',
90
- provider: 'anthropic',
91
- error: "HTTP #{response.code}",
92
- message: response.message,
91
+ status: 'unhealthy', provider: 'anthropic', reachable: true,
92
+ error: "HTTP #{response.code}", message: response.message, ready: false
93
93
  }
94
94
  end
95
95
  end
96
- rescue Prescient::ConnectionError => e
96
+ rescue Prescient::Error => e
97
97
  {
98
- status: 'unavailable',
99
- provider: 'anthropic',
100
- error: e.class.name,
101
- message: e.message,
98
+ status: 'unavailable',
99
+ provider: 'anthropic',
100
+ reachable: false,
101
+ error: e.class.name,
102
+ message: e.message,
103
+ ready: false,
102
104
  }
103
105
  end
104
106
 
107
+ # Return models available to the configured Anthropic account.
108
+ # @return [Array<Hash>] Model descriptors
105
109
  def list_models
106
- # Anthropic doesn't provide a models list API
107
- [
108
- { name: 'claude-3-haiku-20240307', type: 'text' },
109
- { name: 'claude-3-sonnet-20240229', type: 'text' },
110
- { name: 'claude-3-opus-20240229', type: 'text' },
111
- ]
110
+ handle_errors do
111
+ response = self.class.get('/v1/models', headers: api_headers)
112
+ validate_response!(response, 'model listing')
113
+
114
+ (response.parsed_response['data'] || []).map do |model|
115
+ {
116
+ name: model['id'],
117
+ type: 'text',
118
+ display_name: model['display_name'],
119
+ created_at: model['created_at'],
120
+ max_input_tokens: model['max_input_tokens'],
121
+ max_tokens: model['max_tokens'],
122
+ }.compact
123
+ end
124
+ end
112
125
  end
113
126
 
114
127
  protected
@@ -124,23 +137,11 @@ class Prescient::Provider::Anthropic < Prescient::Base
124
137
 
125
138
  private
126
139
 
127
- def validate_response!(response, operation)
128
- return if response.success?
129
-
130
- case response.code
131
- when 400
132
- raise Prescient::Error, "Bad request for #{operation}: #{response.body}"
133
- when 401
134
- raise Prescient::AuthenticationError, "Authentication failed for #{operation}"
135
- when 403
136
- raise Prescient::AuthenticationError, "Forbidden access for #{operation}"
137
- when 429
138
- raise Prescient::RateLimitError, "Rate limit exceeded for #{operation}"
139
- when 500..599
140
- raise Prescient::Error, "Anthropic server error during #{operation}: #{response.body}"
141
- else
142
- raise Prescient::Error,
143
- "Anthropic request failed for #{operation}: HTTP #{response.code} - #{response.message}"
144
- end
140
+ def api_headers
141
+ {
142
+ 'Content-Type' => 'application/json',
143
+ 'x-api-key' => @options[:api_key],
144
+ 'anthropic-version' => '2023-06-01',
145
+ }
145
146
  end
146
147
  end
@@ -2,11 +2,25 @@
2
2
 
3
3
  require 'httparty'
4
4
 
5
+ # Hugging Face router-backed Inference Providers API adapter.
5
6
  class Prescient::Provider::HuggingFace < Prescient::Base
6
7
  include HTTParty
7
8
 
8
- base_uri 'https://api-inference.huggingface.co'
9
+ base_uri 'https://router.huggingface.co'
9
10
 
11
+ # Router path for the Hugging Face feature-extraction provider.
12
+ # @return [String] Feature-extraction endpoint template
13
+ FEATURE_EXTRACTION_PATH = '/hf-inference/models/%<model>s/pipeline/feature-extraction'
14
+
15
+ # OpenAI-compatible router path for Hugging Face chat completions.
16
+ # @return [String] Chat-completions endpoint path
17
+ CHAT_COMPLETIONS_PATH = '/v1/chat/completions'
18
+
19
+ # OpenAI-compatible router path for listing available chat models.
20
+ # @return [String] Model-list endpoint path
21
+ MODEL_LIST_PATH = '/v1/models'
22
+
23
+ # Known embedding dimensions for commonly used models.
10
24
  EMBEDDING_DIMENSIONS = {
11
25
  'sentence-transformers/all-MiniLM-L6-v2' => 384,
12
26
  'sentence-transformers/all-mpnet-base-v2' => 768,
@@ -18,21 +32,20 @@ class Prescient::Provider::HuggingFace < Prescient::Base
18
32
  self.class.default_timeout(@options[:timeout] || 60)
19
33
  end
20
34
 
21
- def generate_embedding(text, **_options)
35
+ # Generate an embedding through Hugging Face feature extraction.
36
+ # @param text [String] Text to embed
37
+ # @return [Array<Float>] Embedding vector
38
+ def generate_embedding(text, **options)
22
39
  handle_errors do
23
40
  clean_text_input = clean_text(text)
24
41
 
25
- response = self.class.post("/pipeline/feature-extraction/#{@options[:embedding_model]}",
42
+ embedding_model = options[:model] || @options[:embedding_model]
43
+ response = self.class.post(FEATURE_EXTRACTION_PATH % { model: embedding_model },
26
44
  headers: {
27
45
  'Content-Type' => 'application/json',
28
46
  'Authorization' => "Bearer #{@options[:api_key]}",
29
47
  },
30
- body: {
31
- inputs: clean_text_input,
32
- options: {
33
- wait_for_model: true,
34
- },
35
- }.to_json)
48
+ body: { inputs: clean_text_input }.to_json)
36
49
 
37
50
  validate_response!(response, 'embedding generation')
38
51
 
@@ -42,82 +55,79 @@ class Prescient::Provider::HuggingFace < Prescient::Base
42
55
 
43
56
  raise Prescient::InvalidResponseError, 'No embedding returned' unless embedding_data.is_a?(Array)
44
57
 
45
- expected_dimensions = EMBEDDING_DIMENSIONS[@options[:embedding_model]] || 384
46
- normalize_embedding(embedding_data, expected_dimensions)
58
+ expected_dimensions = EMBEDDING_DIMENSIONS[embedding_model] || @options[:embedding_dimensions]
59
+ unless expected_dimensions
60
+ raise Prescient::Error,
61
+ "Embedding dimensions are required for model #{embedding_model}"
62
+ end
63
+
64
+ validate_embedding_dimensions(embedding_data, expected_dimensions)
47
65
  end
48
66
  end
49
67
 
68
+ # Generate text through a Hugging Face text-generation model.
69
+ # @param prompt [String] Prompt to send
70
+ # @param context_items [Array<Hash, String>] Optional context items
71
+ # @return [Hash] Normalized response data
50
72
  def generate_response(prompt, context_items = [], **options)
51
73
  handle_errors do
52
74
  formatted_prompt = build_prompt(prompt, context_items)
53
75
 
54
- response = self.class.post("/models/#{@options[:chat_model]}",
76
+ response = self.class.post(CHAT_COMPLETIONS_PATH,
55
77
  headers: {
56
78
  'Content-Type' => 'application/json',
57
79
  'Authorization' => "Bearer #{@options[:api_key]}",
58
80
  },
59
81
  body: {
60
- inputs: formatted_prompt,
61
- parameters: {
62
- max_new_tokens: options[:max_tokens] || 2000,
63
- temperature: options[:temperature] || 0.7,
64
- top_p: options[:top_p] || 0.9,
65
- return_full_text: false,
66
- },
67
- options: {
68
- wait_for_model: true,
69
- },
82
+ model: options[:model] || @options[:chat_model],
83
+ messages: [{ role: 'user', content: formatted_prompt }],
84
+ max_tokens: options[:max_tokens] || 2000,
85
+ temperature: options[:temperature] || 0.7,
86
+ top_p: options[:top_p] || 0.9,
70
87
  }.to_json)
71
88
 
72
89
  validate_response!(response, 'text generation')
73
90
 
74
- # HuggingFace returns different formats depending on the model
75
- generated_text = nil
76
91
  parsed_response = response.parsed_response
77
-
78
- if parsed_response.is_a?(Array) && parsed_response.first.is_a?(Hash)
79
- generated_text = parsed_response.first['generated_text']
80
- elsif parsed_response.is_a?(Hash)
81
- generated_text = parsed_response['generated_text'] || parsed_response['text']
82
- end
83
-
92
+ generated_text = parsed_response.dig('choices', 0, 'message', 'content') if parsed_response.is_a?(Hash)
84
93
  raise Prescient::InvalidResponseError, 'No response generated' unless generated_text
85
94
 
86
95
  {
87
96
  response: generated_text.strip,
88
- model: @options[:chat_model],
97
+ model: options[:model] || @options[:chat_model],
89
98
  provider: 'huggingface',
90
99
  processing_time: nil,
91
- metadata: {},
100
+ metadata: {
101
+ usage: parsed_response['usage'],
102
+ finish_reason: parsed_response.dig('choices', 0, 'finish_reason'),
103
+ },
92
104
  }
93
105
  end
94
106
  end
95
107
 
108
+ # Check availability of the configured embedding and text models.
109
+ #
110
+ # The embedding model is checked against the model metadata API on
111
+ # `huggingface.co`, while the chat model is checked against the router's
112
+ # OpenAI-compatible `/v1/models` listing. `ready` requires both checks to
113
+ # succeed.
114
+ #
115
+ # @return [Hash] Provider health information
96
116
  def health_check
97
117
  handle_errors do
98
- # Test embedding model
99
- embedding_response = self.class.post("/pipeline/feature-extraction/#{@options[:embedding_model]}",
100
- headers: {
101
- 'Authorization' => "Bearer #{@options[:api_key]}",
102
- },
103
- body: { inputs: 'test' }.to_json)
104
-
105
- # Test chat model
106
- chat_response = self.class.post("/models/#{@options[:chat_model]}",
107
- headers: {
108
- 'Authorization' => "Bearer #{@options[:api_key]}",
109
- },
110
- body: {
111
- inputs: 'test',
112
- parameters: { max_new_tokens: 5 },
113
- }.to_json)
118
+ embedding_response = self.class.get("https://huggingface.co/api/models/#{@options[:embedding_model]}",
119
+ headers: { 'Authorization' => "Bearer #{@options[:api_key]}" })
120
+ chat_response = self.class.get(MODEL_LIST_PATH,
121
+ headers: { 'Authorization' => "Bearer #{@options[:api_key]}" })
114
122
 
115
123
  embedding_healthy = embedding_response.success?
116
- chat_healthy = chat_response.success?
124
+ chat_models = chat_response.parsed_response['data'] || []
125
+ chat_healthy = chat_response.success? && chat_models.any? { |model| model['id'] == @options[:chat_model] }
117
126
 
118
127
  {
119
128
  status: embedding_healthy && chat_healthy ? 'healthy' : 'partial',
120
129
  provider: 'huggingface',
130
+ reachable: true,
121
131
  embedding_model: {
122
132
  name: @options[:embedding_model],
123
133
  available: embedding_healthy,
@@ -129,15 +139,23 @@ class Prescient::Provider::HuggingFace < Prescient::Base
129
139
  ready: embedding_healthy && chat_healthy,
130
140
  }
131
141
  end
132
- rescue Prescient::ConnectionError => e
142
+ rescue Prescient::Error => e
133
143
  {
134
- status: 'unavailable',
135
- provider: 'huggingface',
136
- error: e.class.name,
137
- message: e.message,
144
+ status: 'unavailable',
145
+ provider: 'huggingface',
146
+ reachable: false,
147
+ error: e.class.name,
148
+ message: e.message,
149
+ ready: false,
138
150
  }
139
151
  end
140
152
 
153
+ # Return the configured Hugging Face models.
154
+ #
155
+ # This method does not query the Hugging Face APIs. It reflects the current
156
+ # adapter configuration only.
157
+ #
158
+ # @return [Array<Hash>] Model descriptors
141
159
  def list_models
142
160
  # HuggingFace doesn't provide a simple API to list all models
143
161
  # Return the configured models
@@ -165,36 +183,19 @@ class Prescient::Provider::HuggingFace < Prescient::Base
165
183
 
166
184
  private
167
185
 
168
- def validate_response!(response, operation)
169
- return if response.success?
170
-
171
- case response.code
172
- when 400
173
- raise Prescient::Error, "Bad request for #{operation}: #{response.body}"
174
- when 401
175
- raise Prescient::AuthenticationError, "Authentication failed for #{operation}"
176
- when 403
177
- raise Prescient::AuthenticationError, "Forbidden access for #{operation}"
178
- when 429
179
- raise Prescient::RateLimitError, "Rate limit exceeded for #{operation}"
180
- when 503
186
+ def provider_error(message, response, operation:, provider: nil, error_class: Prescient::ProviderError)
187
+ if response.code == 503
181
188
  # HuggingFace model loading
182
189
  error_body = begin
183
190
  response.parsed_response
184
191
  rescue StandardError
185
- response.body
192
+ nil
186
193
  end
187
194
  if error_body.is_a?(Hash) && error_body['error']&.include?('loading')
188
- raise Prescient::Error, 'Model is loading, please try again later'
195
+ message = 'Model is loading, please try again later'
189
196
  end
190
-
191
- raise Prescient::Error, "HuggingFace service unavailable for #{operation}"
192
-
193
- when 500..599
194
- raise Prescient::Error, "HuggingFace server error during #{operation}: #{response.body}"
195
- else
196
- raise Prescient::Error,
197
- "HuggingFace request failed for #{operation}: HTTP #{response.code} - #{response.message}"
198
197
  end
198
+
199
+ super
199
200
  end
200
201
  end