prescient 0.0.0 → 0.2.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,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prescient
4
+ # Client class for interacting with AI providers
5
+ #
6
+ # The Client provides a high-level interface for working with AI providers,
7
+ # handling error recovery, retries, and method delegation. It acts as a
8
+ # facade over the configured providers.
9
+ #
10
+ # @example Basic usage
11
+ # client = Prescient::Client.new(:openai)
12
+ # response = client.generate_response("Hello, world!")
13
+ # embedding = client.generate_embedding("Text to embed")
14
+ #
15
+ # @example Using default provider
16
+ # client = Prescient::Client.new # Uses configured default
17
+ # puts client.provider_name # => :ollama (or configured default)
18
+ #
19
+ # @author Claude Code
20
+ # @since 1.0.0
21
+ class Client
22
+ # @return [Symbol] The name of the provider being used
23
+ attr_reader :provider_name
24
+
25
+ # @return [Base] The underlying provider instance
26
+ attr_reader :provider
27
+
28
+ # Initialize a new client with the specified provider
29
+ #
30
+ # @param provider_name [Symbol, nil] Name of provider to use, or nil for default
31
+ # @param enable_fallback [Boolean] Whether to enable automatic fallback to other providers
32
+ # @raise [Prescient::Error] If the specified provider is not configured
33
+ def initialize(provider_name = nil, enable_fallback: true)
34
+ @provider_name = provider_name || Prescient.configuration.default_provider
35
+ @provider = Prescient.configuration.provider(@provider_name)
36
+ @enable_fallback = enable_fallback
37
+
38
+ raise Prescient::Error, "Provider not found: #{@provider_name}" unless @provider
39
+ end
40
+
41
+ # Generate embeddings for the given text
42
+ #
43
+ # Delegates to the underlying provider with automatic retry logic
44
+ # for transient failures. If fallback is enabled, tries other providers
45
+ # on persistent failures.
46
+ #
47
+ # @param text [String] The text to generate embeddings for
48
+ # @param options [Hash] Provider-specific options
49
+ # @return [Array<Float>] Array of embedding values
50
+ # @raise [Prescient::Error] If embedding generation fails on all providers
51
+ def generate_embedding(text, **options)
52
+ if @enable_fallback
53
+ with_fallback_handling(:generate_embedding, text, **options)
54
+ else
55
+ with_error_handling do
56
+ @provider.generate_embedding(text, **options)
57
+ end
58
+ end
59
+ end
60
+
61
+ # Generate text response for the given prompt
62
+ #
63
+ # Delegates to the underlying provider with automatic retry logic
64
+ # for transient failures. Supports optional context items for RAG.
65
+ # If fallback is enabled, tries other providers on persistent failures.
66
+ #
67
+ # @param prompt [String] The prompt to generate a response for
68
+ # @param context_items [Array<Hash, String>] Optional context items
69
+ # @param options [Hash] Provider-specific generation options
70
+ # @option options [Float] :temperature Sampling temperature (0.0-2.0)
71
+ # @option options [Integer] :max_tokens Maximum tokens to generate
72
+ # @option options [Float] :top_p Nucleus sampling parameter
73
+ # @return [Hash] Response hash with :response, :model, :provider keys
74
+ # @raise [Prescient::Error] If response generation fails on all providers
75
+ def generate_response(prompt, context_items = [], **options)
76
+ if @enable_fallback
77
+ with_fallback_handling(:generate_response, prompt, context_items, **options)
78
+ else
79
+ with_error_handling do
80
+ @provider.generate_response(prompt, context_items, **options)
81
+ end
82
+ end
83
+ end
84
+
85
+ # Check the health status of the provider
86
+ #
87
+ # @return [Hash] Health status information
88
+ def health_check
89
+ @provider.health_check
90
+ end
91
+
92
+ # Check if the provider is currently available
93
+ #
94
+ # @return [Boolean] true if provider is healthy and available
95
+ def available?
96
+ @provider.available?
97
+ end
98
+
99
+ # Get comprehensive information about the provider
100
+ #
101
+ # Returns details about the provider including its availability
102
+ # and configuration options (with sensitive data removed).
103
+ #
104
+ # @return [Hash] Provider information including :name, :class, :available, :options
105
+ def provider_info
106
+ {
107
+ name: @provider_name,
108
+ class: @provider.class.name.split('::').last,
109
+ available: available?,
110
+ options: sanitize_options(@provider.options),
111
+ }
112
+ end
113
+
114
+ def method_missing(method_name, ...)
115
+ @provider.respond_to?(method_name) ? @provider.send(method_name, ...) : super
116
+ end
117
+
118
+ def respond_to_missing?(method_name, include_private = false)
119
+ @provider.respond_to?(method_name, include_private) || super
120
+ end
121
+
122
+ private
123
+
124
+ # TODO: configurable keys to sanitize
125
+ def sanitize_options(options)
126
+ sensitive_keys = [:api_key, :password, :token, :secret]
127
+ options.reject { |key, _| sensitive_keys.include?(key.to_sym) }
128
+ end
129
+
130
+ def with_error_handling
131
+ retries = 0
132
+ begin
133
+ yield
134
+ rescue Prescient::RateLimitError => e
135
+ raise e unless retries < Prescient.configuration.retry_attempts
136
+
137
+ retries += 1
138
+ sleep(Prescient.configuration.retry_delay * retries)
139
+ retry
140
+ rescue Prescient::ConnectionError => e
141
+ raise e unless retries < Prescient.configuration.retry_attempts
142
+
143
+ retries += 1
144
+ sleep(Prescient.configuration.retry_delay)
145
+ retry
146
+ end
147
+ end
148
+
149
+ def with_fallback_handling(method_name, *args, **options)
150
+ last_error = nil
151
+
152
+ providers_to_try.each_with_index do |provider_name, index|
153
+ # Use existing provider instance for primary provider, create new ones for fallbacks
154
+ provider = if index.zero? && provider_name == @provider_name
155
+ @provider
156
+ else
157
+ Prescient.configuration.provider(provider_name)
158
+ end
159
+ next unless provider
160
+
161
+ # Check if provider is available before trying
162
+ next unless provider.available?
163
+
164
+ # Use retry logic for each provider
165
+ return with_error_handling do
166
+ provider.send(method_name, *args, **options)
167
+ end
168
+ rescue Prescient::Error => e
169
+ last_error = e
170
+ # Log the error and continue to next provider
171
+ next
172
+ end
173
+
174
+ # If we get here, all providers failed
175
+ raise last_error || Prescient::Error.new("No available providers for #{method_name}")
176
+ end
177
+
178
+ def providers_to_try
179
+ providers = [@provider_name]
180
+
181
+ # Add configured fallback providers
182
+ fallback_providers = Prescient.configuration.fallback_providers
183
+ if fallback_providers && !fallback_providers.empty?
184
+ providers += fallback_providers.reject { |p| p == @provider_name }
185
+ else
186
+ # If no explicit fallbacks configured, try all available providers
187
+ available = Prescient.configuration.available_providers
188
+ providers += available.reject { |p| p == @provider_name }
189
+ end
190
+
191
+ providers.uniq
192
+ end
193
+ end
194
+
195
+ # Convenience methods for quick access
196
+ def self.client(provider_name = nil, enable_fallback: true)
197
+ Client.new(provider_name, enable_fallback: enable_fallback)
198
+ end
199
+
200
+ def self.generate_embedding(text, provider: nil, enable_fallback: true, **options)
201
+ client(provider, enable_fallback: enable_fallback).generate_embedding(text, **options)
202
+ end
203
+
204
+ def self.generate_response(prompt, context_items = [], provider: nil, enable_fallback: true, **options)
205
+ client(provider, enable_fallback: enable_fallback).generate_response(prompt, context_items, **options)
206
+ end
207
+
208
+ def self.health_check(provider: nil)
209
+ client(provider, enable_fallback: false).health_check
210
+ end
211
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'httparty'
4
+
5
+ class Prescient::Provider::Anthropic < Prescient::Base
6
+ include HTTParty
7
+
8
+ base_uri 'https://api.anthropic.com'
9
+
10
+ def initialize(**options)
11
+ super
12
+ self.class.default_timeout(@options[:timeout] || 60)
13
+ end
14
+
15
+ def generate_embedding(_text, **_options)
16
+ # Anthropic doesn't provide embedding API, raise error
17
+ raise Prescient::Error,
18
+ 'Anthropic provider does not support embeddings. Use OpenAI or HuggingFace for embeddings.'
19
+ end
20
+
21
+ def generate_response(prompt, context_items = [], **options)
22
+ handle_errors do
23
+ formatted_prompt = build_prompt(prompt, context_items)
24
+
25
+ response = self.class.post('/v1/messages',
26
+ headers: {
27
+ 'Content-Type' => 'application/json',
28
+ 'x-api-key' => @options[:api_key],
29
+ 'anthropic-version' => '2023-06-01',
30
+ },
31
+ body: {
32
+ model: @options[:model],
33
+ max_tokens: options[:max_tokens] || 2000,
34
+ temperature: options[:temperature] || 0.7,
35
+ messages: [
36
+ {
37
+ role: 'user',
38
+ content: formatted_prompt,
39
+ },
40
+ ],
41
+ }.to_json)
42
+
43
+ validate_response!(response, 'text generation')
44
+
45
+ content = response.parsed_response.dig('content', 0, 'text')
46
+ raise Prescient::InvalidResponseError, 'No response generated' unless content
47
+
48
+ {
49
+ response: content.strip,
50
+ model: @options[:model],
51
+ provider: 'anthropic',
52
+ processing_time: nil,
53
+ metadata: {
54
+ usage: response.parsed_response['usage'],
55
+ },
56
+ }
57
+ end
58
+ end
59
+
60
+ def health_check
61
+ 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)
79
+
80
+ if response.success?
81
+ {
82
+ status: 'healthy',
83
+ provider: 'anthropic',
84
+ model: @options[:model],
85
+ ready: true,
86
+ }
87
+ else
88
+ {
89
+ status: 'unhealthy',
90
+ provider: 'anthropic',
91
+ error: "HTTP #{response.code}",
92
+ message: response.message,
93
+ }
94
+ end
95
+ end
96
+ rescue Prescient::ConnectionError => e
97
+ {
98
+ status: 'unavailable',
99
+ provider: 'anthropic',
100
+ error: e.class.name,
101
+ message: e.message,
102
+ }
103
+ end
104
+
105
+ 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
+ ]
112
+ end
113
+
114
+ protected
115
+
116
+ def validate_configuration!
117
+ required_options = [:api_key, :model]
118
+ missing_options = required_options.select { |opt| @options[opt].nil? }
119
+
120
+ return unless missing_options.any?
121
+
122
+ raise Prescient::Error, "Missing required options: #{missing_options.join(', ')}"
123
+ end
124
+
125
+ private
126
+
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
145
+ end
146
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'httparty'
4
+
5
+ class Prescient::Provider::HuggingFace < Prescient::Base
6
+ include HTTParty
7
+
8
+ base_uri 'https://api-inference.huggingface.co'
9
+
10
+ EMBEDDING_DIMENSIONS = {
11
+ 'sentence-transformers/all-MiniLM-L6-v2' => 384,
12
+ 'sentence-transformers/all-mpnet-base-v2' => 768,
13
+ 'sentence-transformers/all-roberta-large-v1' => 1024,
14
+ }.freeze
15
+
16
+ def initialize(**options)
17
+ super
18
+ self.class.default_timeout(@options[:timeout] || 60)
19
+ end
20
+
21
+ def generate_embedding(text, **_options)
22
+ handle_errors do
23
+ clean_text_input = clean_text(text)
24
+
25
+ response = self.class.post("/pipeline/feature-extraction/#{@options[:embedding_model]}",
26
+ headers: {
27
+ 'Content-Type' => 'application/json',
28
+ 'Authorization' => "Bearer #{@options[:api_key]}",
29
+ },
30
+ body: {
31
+ inputs: clean_text_input,
32
+ options: {
33
+ wait_for_model: true,
34
+ },
35
+ }.to_json)
36
+
37
+ validate_response!(response, 'embedding generation')
38
+
39
+ # HuggingFace returns embeddings as nested arrays, get the first one
40
+ embedding_data = response.parsed_response
41
+ embedding_data = embedding_data.first if embedding_data.is_a?(Array) && embedding_data.first.is_a?(Array)
42
+
43
+ raise Prescient::InvalidResponseError, 'No embedding returned' unless embedding_data.is_a?(Array)
44
+
45
+ expected_dimensions = EMBEDDING_DIMENSIONS[@options[:embedding_model]] || 384
46
+ normalize_embedding(embedding_data, expected_dimensions)
47
+ end
48
+ end
49
+
50
+ def generate_response(prompt, context_items = [], **options)
51
+ handle_errors do
52
+ formatted_prompt = build_prompt(prompt, context_items)
53
+
54
+ response = self.class.post("/models/#{@options[:chat_model]}",
55
+ headers: {
56
+ 'Content-Type' => 'application/json',
57
+ 'Authorization' => "Bearer #{@options[:api_key]}",
58
+ },
59
+ 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
+ },
70
+ }.to_json)
71
+
72
+ validate_response!(response, 'text generation')
73
+
74
+ # HuggingFace returns different formats depending on the model
75
+ generated_text = nil
76
+ 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
+
84
+ raise Prescient::InvalidResponseError, 'No response generated' unless generated_text
85
+
86
+ {
87
+ response: generated_text.strip,
88
+ model: @options[:chat_model],
89
+ provider: 'huggingface',
90
+ processing_time: nil,
91
+ metadata: {},
92
+ }
93
+ end
94
+ end
95
+
96
+ def health_check
97
+ 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)
114
+
115
+ embedding_healthy = embedding_response.success?
116
+ chat_healthy = chat_response.success?
117
+
118
+ {
119
+ status: embedding_healthy && chat_healthy ? 'healthy' : 'partial',
120
+ provider: 'huggingface',
121
+ embedding_model: {
122
+ name: @options[:embedding_model],
123
+ available: embedding_healthy,
124
+ },
125
+ chat_model: {
126
+ name: @options[:chat_model],
127
+ available: chat_healthy,
128
+ },
129
+ ready: embedding_healthy && chat_healthy,
130
+ }
131
+ end
132
+ rescue Prescient::ConnectionError => e
133
+ {
134
+ status: 'unavailable',
135
+ provider: 'huggingface',
136
+ error: e.class.name,
137
+ message: e.message,
138
+ }
139
+ end
140
+
141
+ def list_models
142
+ # HuggingFace doesn't provide a simple API to list all models
143
+ # Return the configured models
144
+ [
145
+ {
146
+ name: @options[:embedding_model],
147
+ type: 'embedding',
148
+ dimensions: EMBEDDING_DIMENSIONS[@options[:embedding_model]],
149
+ },
150
+ {
151
+ name: @options[:chat_model],
152
+ type: 'text-generation',
153
+ },
154
+ ]
155
+ end
156
+
157
+ protected
158
+
159
+ def validate_configuration!
160
+ missing_options = [:api_key, :embedding_model, :chat_model].select { |opt| @options[opt].nil? }
161
+ return unless missing_options.any?
162
+
163
+ raise Prescient::Error, "Missing required options: #{missing_options.join(', ')}"
164
+ end
165
+
166
+ private
167
+
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
181
+ # HuggingFace model loading
182
+ error_body = begin
183
+ response.parsed_response
184
+ rescue StandardError
185
+ response.body
186
+ end
187
+ if error_body.is_a?(Hash) && error_body['error']&.include?('loading')
188
+ raise Prescient::Error, 'Model is loading, please try again later'
189
+ 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
+ end
199
+ end
200
+ end