prescient 0.4.0 → 0.6.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/.dockerignore +18 -0
- data/CHANGELOG.md +42 -0
- data/Dockerfile +45 -0
- data/INTEGRATION_GUIDE.md +121 -11
- data/README.md +261 -16
- data/Rakefile +1 -1
- data/VECTOR_SEARCH_GUIDE.md +7 -3
- data/docker-compose.api.yml +21 -0
- data/examples/README.md +19 -1
- data/examples/basic_usage.rb +1 -1
- data/examples/custom_contexts.rb +6 -21
- data/examples/rest_api.ru +30 -0
- data/examples/vector_search.rb +69 -305
- data/lib/prescient/api.rb +285 -0
- data/lib/prescient/cli.rb +197 -4
- data/lib/prescient/configuration_loader.rb +437 -0
- data/lib/prescient/provider/deepseek.rb +139 -0
- data/lib/prescient/provider/gemini.rb +173 -0
- data/lib/prescient/provider/huggingface.rb +1 -0
- data/lib/prescient/provider/mistral.rb +171 -0
- data/lib/prescient/provider/openai.rb +3 -0
- data/lib/prescient/provider/xai.rb +139 -0
- data/lib/prescient/version.rb +1 -1
- data/lib/prescient.rb +119 -23
- data/schema/prescient.configuration.schema.json +153 -0
- data/sig/prescient.rbs +115 -0
- metadata +12 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'httparty'
|
|
4
|
+
|
|
5
|
+
# DeepSeek API provider adapter.
|
|
6
|
+
class Prescient::Provider::DeepSeek < Prescient::Base
|
|
7
|
+
include HTTParty
|
|
8
|
+
|
|
9
|
+
base_uri 'https://api.deepseek.com'
|
|
10
|
+
|
|
11
|
+
def initialize(**options)
|
|
12
|
+
super
|
|
13
|
+
@provider_name = 'DeepSeek'
|
|
14
|
+
self.class.default_timeout(@options[:timeout] || 60)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# DeepSeek does not currently provide an embeddings endpoint.
|
|
18
|
+
# @raise [Prescient::Error] Always, because embeddings are unsupported
|
|
19
|
+
def generate_embedding(_text, **_options)
|
|
20
|
+
raise Prescient::Error, 'DeepSeek provider does not support embeddings.'
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Generate a response through DeepSeek'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
|
+
'/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: 'deepseek',
|
|
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 DeepSeek model is available.
|
|
62
|
+
# @return [Hash] Provider health information
|
|
63
|
+
def health_check
|
|
64
|
+
handle_errors do
|
|
65
|
+
response = self.class.get('/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: 'deepseek',
|
|
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: 'deepseek',
|
|
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: 'deepseek',
|
|
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 DeepSeek API key.
|
|
102
|
+
# @return [Array<Hash>] Model descriptors
|
|
103
|
+
def list_models
|
|
104
|
+
handle_errors do
|
|
105
|
+
response = self.class.get('/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
|
+
permission: model['permission'],
|
|
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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -17,6 +17,7 @@ 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
|
|
|
@@ -82,6 +83,8 @@ class Prescient::Provider::OpenAI < Prescient::Base
|
|
|
82
83
|
|
|
83
84
|
validate_response!(response, 'text generation')
|
|
84
85
|
|
|
86
|
+
puts "response.parsed_response: #{response.parsed_response.inspect}"
|
|
87
|
+
|
|
85
88
|
content = response.parsed_response.dig('choices', 0, 'message', 'content')
|
|
86
89
|
raise Prescient::InvalidResponseError, 'No response generated' unless content
|
|
87
90
|
|
|
@@ -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