prescient 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.
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'httparty'
4
+
5
+ # Google Gemini API provider adapter.
6
+ class Prescient::Provider::Gemini < Prescient::Base
7
+ include HTTParty
8
+
9
+ base_uri 'https://generativelanguage.googleapis.com'
10
+
11
+ def initialize(**options)
12
+ super
13
+ @provider_name = 'Google Gemini'
14
+ self.class.default_timeout(@options[:timeout] || 60)
15
+ end
16
+
17
+ # Generate an embedding through Gemini's embedContent endpoint.
18
+ # @param text [String] Text to embed
19
+ # @return [Array<Float>] Embedding vector
20
+ def generate_embedding(text, **options)
21
+ handle_errors do
22
+ embedding_model = options[:model] || @options[:embedding_model]
23
+ response = self.class.post(
24
+ model_endpoint(embedding_model, 'embedContent'),
25
+ headers: api_headers,
26
+ body: {
27
+ content: { parts: [{ text: clean_text(text) }] },
28
+ }.to_json,
29
+ )
30
+
31
+ validate_response!(response, 'embedding generation')
32
+
33
+ embedding = response.parsed_response.dig('embedding', 'values')
34
+ raise Prescient::InvalidResponseError, 'No embedding returned' unless embedding.is_a?(Array)
35
+
36
+ expected_dimensions = @options[:embedding_dimensions]
37
+ expected_dimensions ? validate_embedding_dimensions(embedding, expected_dimensions) : embedding
38
+ end
39
+ end
40
+
41
+ # Generate a response through Gemini's generateContent endpoint.
42
+ # @param prompt [String] Prompt to send
43
+ # @param context_items [Array<Hash, String>] Optional context items
44
+ # @return [Hash] Normalized response data
45
+ def generate_response(prompt, context_items = [], **options)
46
+ handle_errors do
47
+ model = options[:model] || @options[:chat_model]
48
+ response = self.class.post(
49
+ model_endpoint(model, 'generateContent'),
50
+ headers: api_headers,
51
+ body: {
52
+ contents: [{ role: 'user', parts: [{ text: build_prompt(prompt, context_items) }] }],
53
+ generationConfig: {
54
+ maxOutputTokens: options[:max_tokens] || 2000,
55
+ temperature: options[:temperature] || 0.7,
56
+ topP: options[:top_p] || 0.9,
57
+ },
58
+ }.to_json,
59
+ )
60
+
61
+ validate_response!(response, 'text generation')
62
+
63
+ parsed_response = response.parsed_response
64
+ parts = parsed_response.dig('candidates', 0, 'content', 'parts')
65
+ content = Array(parts).filter_map { |part| part['text'] }.join
66
+ raise Prescient::InvalidResponseError, 'No response generated' if content.nil? || content.empty?
67
+
68
+ {
69
+ response: content.strip,
70
+ model: model,
71
+ provider: 'gemini',
72
+ processing_time: nil,
73
+ metadata: {
74
+ usage: parsed_response['usageMetadata'],
75
+ finish_reason: parsed_response.dig('candidates', 0, 'finishReason'),
76
+ },
77
+ }
78
+ end
79
+ end
80
+
81
+ # Check whether the configured Gemini models are available.
82
+ # @return [Hash] Provider health information
83
+ def health_check
84
+ handle_errors do
85
+ response = self.class.get('/v1beta/models', headers: api_headers)
86
+
87
+ if response.success?
88
+ models = response.parsed_response['models'] || []
89
+ embedding_model = find_model(models, @options[:embedding_model], 'embedContent')
90
+ chat_model = find_model(models, @options[:chat_model], 'generateContent')
91
+
92
+ {
93
+ status: 'healthy',
94
+ provider: 'gemini',
95
+ reachable: true,
96
+ models_available: models.map { |model| model['name'].to_s.delete_prefix('models/') },
97
+ embedding_model: { name: @options[:embedding_model], available: embedding_model },
98
+ chat_model: { name: @options[:chat_model], available: chat_model },
99
+ ready: embedding_model && chat_model,
100
+ }
101
+ else
102
+ {
103
+ status: 'unhealthy',
104
+ provider: 'gemini',
105
+ reachable: true,
106
+ error: "HTTP #{response.code}",
107
+ message: response.message,
108
+ ready: false,
109
+ }
110
+ end
111
+ end
112
+ rescue Prescient::Error => e
113
+ {
114
+ status: 'unavailable',
115
+ provider: 'gemini',
116
+ reachable: false,
117
+ error: e.class.name,
118
+ message: e.message,
119
+ ready: false,
120
+ }
121
+ end
122
+
123
+ # List models available to the configured Gemini API key.
124
+ # @return [Array<Hash>] Model descriptors
125
+ def list_models
126
+ handle_errors do
127
+ response = self.class.get('/v1beta/models', headers: api_headers)
128
+ validate_response!(response, 'model listing')
129
+
130
+ (response.parsed_response['models'] || []).map do |model|
131
+ {
132
+ name: model['name'].to_s.delete_prefix('models/'),
133
+ display_name: model['displayName'],
134
+ supported_generation_modes: model['supportedGenerationMethods'],
135
+ input_token_limit: model['inputTokenLimit'],
136
+ output_token_limit: model['outputTokenLimit'],
137
+ }.compact
138
+ end
139
+ end
140
+ end
141
+
142
+ protected
143
+
144
+ def validate_configuration!
145
+ required_options = [:api_key, :embedding_model, :chat_model]
146
+ missing_options = required_options.select { |option| @options[option].nil? }
147
+
148
+ return unless missing_options.any?
149
+
150
+ raise Prescient::Error, "Missing required options: #{missing_options.join(', ')}"
151
+ end
152
+
153
+ private
154
+
155
+ def api_headers
156
+ {
157
+ 'Content-Type' => 'application/json',
158
+ 'x-goog-api-key' => @options[:api_key],
159
+ }
160
+ end
161
+
162
+ def model_endpoint(model, operation)
163
+ model_name = model.to_s.delete_prefix('models/')
164
+ "/v1beta/models/#{model_name}:#{operation}"
165
+ end
166
+
167
+ def find_model(models, model_name, operation)
168
+ models.any? do |model|
169
+ model['name'].to_s.delete_prefix('models/') == model_name &&
170
+ model.fetch('supportedGenerationMethods', []).include?(operation)
171
+ end
172
+ end
173
+ end
@@ -29,17 +29,19 @@ class Prescient::Provider::HuggingFace < Prescient::Base
29
29
 
30
30
  def initialize(**options)
31
31
  super
32
+ @provider_name = 'Hugging Face'
32
33
  self.class.default_timeout(@options[:timeout] || 60)
33
34
  end
34
35
 
35
36
  # Generate an embedding through Hugging Face feature extraction.
36
37
  # @param text [String] Text to embed
37
38
  # @return [Array<Float>] Embedding vector
38
- def generate_embedding(text, **_options)
39
+ def generate_embedding(text, **options)
39
40
  handle_errors do
40
41
  clean_text_input = clean_text(text)
41
42
 
42
- response = self.class.post(FEATURE_EXTRACTION_PATH % { model: @options[:embedding_model] },
43
+ embedding_model = options[:model] || @options[:embedding_model]
44
+ response = self.class.post(FEATURE_EXTRACTION_PATH % { model: embedding_model },
43
45
  headers: {
44
46
  'Content-Type' => 'application/json',
45
47
  'Authorization' => "Bearer #{@options[:api_key]}",
@@ -54,10 +56,10 @@ class Prescient::Provider::HuggingFace < Prescient::Base
54
56
 
55
57
  raise Prescient::InvalidResponseError, 'No embedding returned' unless embedding_data.is_a?(Array)
56
58
 
57
- expected_dimensions = EMBEDDING_DIMENSIONS[@options[:embedding_model]] || @options[:embedding_dimensions]
59
+ expected_dimensions = EMBEDDING_DIMENSIONS[embedding_model] || @options[:embedding_dimensions]
58
60
  unless expected_dimensions
59
61
  raise Prescient::Error,
60
- "Embedding dimensions are required for model #{@options[:embedding_model]}"
62
+ "Embedding dimensions are required for model #{embedding_model}"
61
63
  end
62
64
 
63
65
  validate_embedding_dimensions(embedding_data, expected_dimensions)
@@ -78,7 +80,7 @@ class Prescient::Provider::HuggingFace < Prescient::Base
78
80
  'Authorization' => "Bearer #{@options[:api_key]}",
79
81
  },
80
82
  body: {
81
- model: @options[:chat_model],
83
+ model: options[:model] || @options[:chat_model],
82
84
  messages: [{ role: 'user', content: formatted_prompt }],
83
85
  max_tokens: options[:max_tokens] || 2000,
84
86
  temperature: options[:temperature] || 0.7,
@@ -93,7 +95,7 @@ class Prescient::Provider::HuggingFace < Prescient::Base
93
95
 
94
96
  {
95
97
  response: generated_text.strip,
96
- model: @options[:chat_model],
98
+ model: options[:model] || @options[:chat_model],
97
99
  provider: 'huggingface',
98
100
  processing_time: nil,
99
101
  metadata: {
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'httparty'
4
+
5
+ # Mistral AI API provider adapter.
6
+ class Prescient::Provider::Mistral < Prescient::Base
7
+ include HTTParty
8
+
9
+ base_uri 'https://api.mistral.ai'
10
+
11
+ def initialize(**options)
12
+ super
13
+ self.class.default_timeout(@options[:timeout] || 60)
14
+ end
15
+
16
+ # Generate an embedding through Mistral's embeddings API.
17
+ # @param text [String] Text to embed
18
+ # @return [Array<Float>] Embedding vector
19
+ def generate_embedding(text, **options)
20
+ handle_errors do
21
+ embedding_model = options[:model] || @options[:embedding_model]
22
+ response = self.class.post(
23
+ '/v1/embeddings',
24
+ headers: api_headers,
25
+ body: {
26
+ model: embedding_model,
27
+ input: clean_text(text),
28
+ }.to_json,
29
+ )
30
+
31
+ validate_response!(response, 'embedding generation')
32
+
33
+ embedding = response.parsed_response.dig('data', 0, 'embedding')
34
+ raise Prescient::InvalidResponseError, 'No embedding returned' unless embedding.is_a?(Array)
35
+
36
+ expected_dimensions = @options[:embedding_dimensions]
37
+ expected_dimensions ? validate_embedding_dimensions(embedding, expected_dimensions) : embedding
38
+ end
39
+ end
40
+
41
+ # Generate a response through Mistral's chat completions API.
42
+ # @param prompt [String] Prompt to send
43
+ # @param context_items [Array<Hash, String>] Optional context items
44
+ # @return [Hash] Normalized response data
45
+ def generate_response(prompt, context_items = [], **options)
46
+ handle_errors do
47
+ model = options[:model] || @options[:chat_model]
48
+ response = self.class.post(
49
+ '/v1/chat/completions',
50
+ headers: api_headers,
51
+ body: {
52
+ model: model,
53
+ messages: [{ role: 'user', content: build_prompt(prompt, context_items) }],
54
+ max_tokens: options[:max_tokens] || 2000,
55
+ temperature: options[:temperature] || 0.7,
56
+ top_p: options[:top_p] || 0.9,
57
+ }.to_json,
58
+ )
59
+
60
+ validate_response!(response, 'text generation')
61
+
62
+ parsed_response = response.parsed_response
63
+ content = normalize_content(parsed_response.dig('choices', 0, 'message', 'content'))
64
+ raise Prescient::InvalidResponseError, 'No response generated' if content.nil? || content.empty?
65
+
66
+ {
67
+ response: content.strip,
68
+ model: model,
69
+ provider: 'mistral',
70
+ processing_time: nil,
71
+ metadata: {
72
+ usage: parsed_response['usage'],
73
+ finish_reason: parsed_response.dig('choices', 0, 'finish_reason'),
74
+ },
75
+ }
76
+ end
77
+ end
78
+
79
+ # Check whether the configured Mistral models are available.
80
+ # @return [Hash] Provider health information
81
+ def health_check
82
+ handle_errors do
83
+ response = self.class.get('/v1/models', headers: api_headers)
84
+
85
+ if response.success?
86
+ models = response.parsed_response['data'] || []
87
+ embedding_available = model_available?(models, @options[:embedding_model])
88
+ chat_available = model_available?(models, @options[:chat_model])
89
+
90
+ {
91
+ status: 'healthy',
92
+ provider: 'mistral',
93
+ reachable: true,
94
+ models_available: models.map { |model| model['id'] },
95
+ embedding_model: { name: @options[:embedding_model], available: embedding_available },
96
+ chat_model: { name: @options[:chat_model], available: chat_available },
97
+ ready: embedding_available && chat_available,
98
+ }
99
+ else
100
+ {
101
+ status: 'unhealthy',
102
+ provider: 'mistral',
103
+ reachable: true,
104
+ error: "HTTP #{response.code}",
105
+ message: response.message,
106
+ ready: false,
107
+ }
108
+ end
109
+ end
110
+ rescue Prescient::Error => e
111
+ {
112
+ status: 'unavailable',
113
+ provider: 'mistral',
114
+ reachable: false,
115
+ error: e.class.name,
116
+ message: e.message,
117
+ ready: false,
118
+ }
119
+ end
120
+
121
+ # List models available to the configured Mistral API key.
122
+ # @return [Array<Hash>] Model descriptors
123
+ def list_models
124
+ handle_errors do
125
+ response = self.class.get('/v1/models', headers: api_headers)
126
+ validate_response!(response, 'model listing')
127
+
128
+ (response.parsed_response['data'] || []).map do |model|
129
+ {
130
+ name: model['id'],
131
+ object: model['object'],
132
+ created: model['created'],
133
+ owned_by: model['owned_by'],
134
+ capabilities: model['capabilities'],
135
+ max_context_length: model['max_context_length'],
136
+ }.compact
137
+ end
138
+ end
139
+ end
140
+
141
+ protected
142
+
143
+ def validate_configuration!
144
+ required_options = [:api_key, :embedding_model, :chat_model]
145
+ missing_options = required_options.select { |option| @options[option].nil? }
146
+
147
+ return unless missing_options.any?
148
+
149
+ raise Prescient::Error, "Missing required options: #{missing_options.join(', ')}"
150
+ end
151
+
152
+ private
153
+
154
+ def api_headers
155
+ {
156
+ 'Content-Type' => 'application/json',
157
+ 'Authorization' => "Bearer #{@options[:api_key]}",
158
+ }
159
+ end
160
+
161
+ def model_available?(models, model_name)
162
+ models.any? { |model| model['id'] == model_name }
163
+ end
164
+
165
+ def normalize_content(content)
166
+ return content if content.is_a?(String)
167
+ return unless content.is_a?(Array)
168
+
169
+ content.filter_map { |part| part['text'] if part.is_a?(Hash) }.join
170
+ end
171
+ end
@@ -21,7 +21,7 @@ class Prescient::Provider::Ollama < Prescient::Base
21
21
  root_key: 'embeddings',
22
22
  headers: { 'Content-Type' => 'application/json' },
23
23
  body: {
24
- model: @options[:embedding_model],
24
+ model: options[:model] || @options[:embedding_model],
25
25
  input: clean_text(text),
26
26
  }.to_json)
27
27
 
@@ -55,7 +55,7 @@ class Prescient::Provider::Ollama < Prescient::Base
55
55
 
56
56
  {
57
57
  response: generated_text.strip,
58
- model: @options[:chat_model],
58
+ model: options[:model] || @options[:chat_model],
59
59
  provider: 'ollama',
60
60
  processing_time: response.parsed_response['total_duration']&./(1_000_000_000.0),
61
61
  metadata: {
@@ -158,7 +158,7 @@ class Prescient::Provider::Ollama < Prescient::Base
158
158
  { root_key: 'response',
159
159
  headers: { 'Content-Type' => 'application/json' },
160
160
  body: {
161
- model: @options[:chat_model],
161
+ model: options[:model] || @options[:chat_model],
162
162
  prompt: formatted_prompt,
163
163
  stream: false,
164
164
  options: {
@@ -17,23 +17,25 @@ class Prescient::Provider::OpenAI < Prescient::Base
17
17
 
18
18
  def initialize(**options)
19
19
  super
20
+ @provider_name = 'OpenAI'
20
21
  self.class.default_timeout(@options[:timeout] || 60)
21
22
  end
22
23
 
23
24
  # Generate an embedding through the OpenAI embeddings API.
24
25
  # @param text [String] Text to embed
25
26
  # @return [Array<Float>] Embedding vector
26
- def generate_embedding(text, **_options)
27
+ def generate_embedding(text, **options)
27
28
  handle_errors do
28
29
  clean_text_input = clean_text(text)
29
30
 
31
+ embedding_model = options[:model] || @options[:embedding_model]
30
32
  response = self.class.post('/v1/embeddings',
31
33
  headers: {
32
34
  'Content-Type' => 'application/json',
33
35
  'Authorization' => "Bearer #{@options[:api_key]}",
34
36
  },
35
37
  body: {
36
- model: @options[:embedding_model],
38
+ model: embedding_model,
37
39
  input: clean_text_input,
38
40
  encoding_format: 'float',
39
41
  }.to_json)
@@ -43,10 +45,10 @@ class Prescient::Provider::OpenAI < Prescient::Base
43
45
  embedding_data = response.parsed_response.dig('data', 0, 'embedding')
44
46
  raise Prescient::InvalidResponseError, 'No embedding returned' unless embedding_data
45
47
 
46
- expected_dimensions = EMBEDDING_DIMENSIONS[@options[:embedding_model]] || @options[:embedding_dimensions]
48
+ expected_dimensions = EMBEDDING_DIMENSIONS[embedding_model] || @options[:embedding_dimensions]
47
49
  unless expected_dimensions
48
50
  raise Prescient::Error,
49
- "Embedding dimensions are required for model #{@options[:embedding_model]}"
51
+ "Embedding dimensions are required for model #{embedding_model}"
50
52
  end
51
53
 
52
54
  validate_embedding_dimensions(embedding_data, expected_dimensions)
@@ -67,7 +69,7 @@ class Prescient::Provider::OpenAI < Prescient::Base
67
69
  'Authorization' => "Bearer #{@options[:api_key]}",
68
70
  },
69
71
  body: {
70
- model: @options[:chat_model],
72
+ model: options[:model] || @options[:chat_model],
71
73
  messages: [
72
74
  {
73
75
  role: 'user',
@@ -81,12 +83,14 @@ class Prescient::Provider::OpenAI < Prescient::Base
81
83
 
82
84
  validate_response!(response, 'text generation')
83
85
 
86
+ puts "response.parsed_response: #{response.parsed_response.inspect}"
87
+
84
88
  content = response.parsed_response.dig('choices', 0, 'message', 'content')
85
89
  raise Prescient::InvalidResponseError, 'No response generated' unless content
86
90
 
87
91
  {
88
92
  response: content.strip,
89
- model: @options[:chat_model],
93
+ model: options[:model] || @options[:chat_model],
90
94
  provider: 'openai',
91
95
  processing_time: nil,
92
96
  metadata: {
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'httparty'
4
+
5
+ # xAI API provider adapter.
6
+ class Prescient::Provider::XAI < Prescient::Base
7
+ include HTTParty
8
+
9
+ base_uri 'https://api.x.ai'
10
+
11
+ def initialize(**options)
12
+ super
13
+ @provider_name = 'xAI'
14
+ self.class.default_timeout(@options[:timeout] || 60)
15
+ end
16
+
17
+ # xAI does not expose a standard embeddings API for this adapter.
18
+ # @raise [Prescient::Error] Always, because embeddings are unsupported
19
+ def generate_embedding(_text, **_options)
20
+ raise Prescient::Error, 'xAI provider does not support embeddings.'
21
+ end
22
+
23
+ # Generate a response through xAI's OpenAI-compatible chat API.
24
+ # @param prompt [String] Prompt to send
25
+ # @param context_items [Array<Hash, String>] Optional context items
26
+ # @return [Hash] Normalized response data
27
+ def generate_response(prompt, context_items = [], **options)
28
+ handle_errors do
29
+ model = options[:model] || @options[:chat_model]
30
+ response = self.class.post(
31
+ '/v1/chat/completions',
32
+ headers: api_headers,
33
+ body: {
34
+ model: model,
35
+ messages: [{ role: 'user', content: build_prompt(prompt, context_items) }],
36
+ max_tokens: options[:max_tokens] || 2000,
37
+ temperature: options[:temperature] || 0.7,
38
+ top_p: options[:top_p] || 0.9,
39
+ }.to_json,
40
+ )
41
+
42
+ validate_response!(response, 'text generation')
43
+
44
+ parsed_response = response.parsed_response
45
+ content = parsed_response.dig('choices', 0, 'message', 'content')
46
+ raise Prescient::InvalidResponseError, 'No response generated' unless content.is_a?(String) && !content.empty?
47
+
48
+ {
49
+ response: content.strip,
50
+ model: model,
51
+ provider: 'xai',
52
+ processing_time: nil,
53
+ metadata: {
54
+ usage: parsed_response['usage'],
55
+ finish_reason: parsed_response.dig('choices', 0, 'finish_reason'),
56
+ },
57
+ }
58
+ end
59
+ end
60
+
61
+ # Check whether the configured xAI model is available.
62
+ # @return [Hash] Provider health information
63
+ def health_check
64
+ handle_errors do
65
+ response = self.class.get('/v1/models', headers: api_headers)
66
+
67
+ if response.success?
68
+ models = response.parsed_response['data'] || []
69
+ model_available = models.any? { |model| model['id'] == @options[:chat_model] }
70
+
71
+ {
72
+ status: 'healthy',
73
+ provider: 'xai',
74
+ reachable: true,
75
+ models_available: models.map { |model| model['id'] },
76
+ chat_model: { name: @options[:chat_model], available: model_available },
77
+ ready: model_available,
78
+ }
79
+ else
80
+ {
81
+ status: 'unhealthy',
82
+ provider: 'xai',
83
+ reachable: true,
84
+ error: "HTTP #{response.code}",
85
+ message: response.message,
86
+ ready: false,
87
+ }
88
+ end
89
+ end
90
+ rescue Prescient::Error => e
91
+ {
92
+ status: 'unavailable',
93
+ provider: 'xai',
94
+ reachable: false,
95
+ error: e.class.name,
96
+ message: e.message,
97
+ ready: false,
98
+ }
99
+ end
100
+
101
+ # List models available to the configured xAI API key.
102
+ # @return [Array<Hash>] Model descriptors
103
+ def list_models
104
+ handle_errors do
105
+ response = self.class.get('/v1/models', headers: api_headers)
106
+ validate_response!(response, 'model listing')
107
+
108
+ (response.parsed_response['data'] || []).map do |model|
109
+ {
110
+ name: model['id'],
111
+ object: model['object'],
112
+ created: model['created'],
113
+ owned_by: model['owned_by'],
114
+ context_length: model['context_length'],
115
+ }.compact
116
+ end
117
+ end
118
+ end
119
+
120
+ protected
121
+
122
+ def validate_configuration!
123
+ required_options = [:api_key, :chat_model]
124
+ missing_options = required_options.select { |option| @options[option].nil? }
125
+
126
+ return unless missing_options.any?
127
+
128
+ raise Prescient::Error, "Missing required options: #{missing_options.join(', ')}"
129
+ end
130
+
131
+ private
132
+
133
+ def api_headers
134
+ {
135
+ 'Content-Type' => 'application/json',
136
+ 'Authorization' => "Bearer #{@options[:api_key]}",
137
+ }
138
+ end
139
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Prescient
4
4
  # Current Prescient gem version.
5
- VERSION = '0.3.0'
5
+ VERSION = '0.5.0'
6
6
  end