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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -0
- data/INTEGRATION_GUIDE.md +12 -1
- data/README.md +263 -14
- data/Rakefile +1 -1
- data/VECTOR_SEARCH_GUIDE.md +7 -3
- data/examples/README.md +2 -1
- data/examples/basic_usage.rb +1 -1
- data/examples/custom_contexts.rb +6 -21
- data/examples/vector_search.rb +69 -305
- data/exe/prescient +7 -0
- data/lib/prescient/cli.rb +479 -0
- data/lib/prescient/client.rb +16 -4
- data/lib/prescient/configuration_loader.rb +437 -0
- data/lib/prescient/provider/anthropic.rb +2 -2
- data/lib/prescient/provider/deepseek.rb +139 -0
- data/lib/prescient/provider/gemini.rb +173 -0
- data/lib/prescient/provider/huggingface.rb +8 -6
- data/lib/prescient/provider/mistral.rb +171 -0
- data/lib/prescient/provider/ollama.rb +3 -3
- data/lib/prescient/provider/openai.rb +10 -6
- data/lib/prescient/provider/xai.rb +139 -0
- data/lib/prescient/version.rb +1 -1
- data/lib/prescient.rb +117 -22
- data/schema/prescient.configuration.schema.json +153 -0
- data/sig/prescient.rbs +119 -2
- metadata +11 -2
|
@@ -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, **
|
|
39
|
+
def generate_embedding(text, **options)
|
|
39
40
|
handle_errors do
|
|
40
41
|
clean_text_input = clean_text(text)
|
|
41
42
|
|
|
42
|
-
|
|
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[
|
|
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 #{
|
|
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, **
|
|
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:
|
|
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[
|
|
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 #{
|
|
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
|
data/lib/prescient/version.rb
CHANGED