prescient 0.5.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 +20 -0
- data/Dockerfile +45 -0
- data/INTEGRATION_GUIDE.md +110 -11
- data/README.md +84 -4
- data/docker-compose.api.yml +21 -0
- data/examples/README.md +17 -0
- data/examples/rest_api.ru +30 -0
- data/lib/prescient/api.rb +285 -0
- data/lib/prescient/cli.rb +1 -0
- data/lib/prescient/version.rb +1 -1
- data/lib/prescient.rb +3 -1
- data/sig/prescient.rbs +10 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 167f50351b78b35d1ae63d3487285db48c07799ad015b5d49f46dd3c451afa49
|
|
4
|
+
data.tar.gz: 91e1895635015120c75b0b461fea017d8ec7c4b5ebd81f1d2b0c86ba37690e1d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6cdf4ed9b05525692898cdd88153f092c23f5caac1d422962bdc768055bb225e960e45a9fb877adb17a7c686e8bf608db09dea1b3e24ebc15767071255910285
|
|
7
|
+
data.tar.gz: ecec27b8c215dfe5fb92e00f68fd6a5ae66c8284342525c9ae635214318032fc4970a72d2944679e455915f2ba8de8425a07706530ca6c309bedc5d5d4ff114a
|
data/.dockerignore
ADDED
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## [0.6.0] - 2025-08-15
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added a dependency-free Rack-compatible REST API with generation, embeddings,
|
|
10
|
+
bounded batch embeddings, provider/model discovery, capabilities, health,
|
|
11
|
+
liveness, readiness, version, request IDs, authentication hooks, and JSON
|
|
12
|
+
error envelopes.
|
|
13
|
+
- Added a small Rack example that lists the REST API endpoints and delegates
|
|
14
|
+
requests to `Prescient::API`.
|
|
15
|
+
- Added an optional `rack_example` bundle group with Rack, Rackup, and Puma for
|
|
16
|
+
running the example application without adding web-server dependencies to
|
|
17
|
+
the library.
|
|
18
|
+
- Added a non-root, healthchecked Docker image and Compose example for the REST
|
|
19
|
+
API, with optional GHCR publication on version tags.
|
|
20
|
+
- Documented mounting `Prescient::API` in Rails routes with its endpoint
|
|
21
|
+
catalog and authentication example.
|
|
22
|
+
- Made the CLI and REST API optional lazy-loaded entry points so library users
|
|
23
|
+
requiring only `prescient` do not load either interface eagerly.
|
|
24
|
+
|
|
5
25
|
## [0.5.0] - 2025-08-14
|
|
6
26
|
|
|
7
27
|
### Added
|
data/Dockerfile
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# syntax=docker/dockerfile:1
|
|
2
|
+
|
|
3
|
+
FROM ruby:3.3-alpine AS builder
|
|
4
|
+
|
|
5
|
+
WORKDIR /app
|
|
6
|
+
|
|
7
|
+
RUN apk add --no-cache build-base git
|
|
8
|
+
|
|
9
|
+
ENV BUNDLE_WITH=rack_example \
|
|
10
|
+
BUNDLE_WITHOUT=development:test \
|
|
11
|
+
BUNDLE_PATH=/usr/local/bundle
|
|
12
|
+
|
|
13
|
+
COPY Gemfile prescient.gemspec ./
|
|
14
|
+
COPY lib ./lib
|
|
15
|
+
|
|
16
|
+
RUN bundle install --jobs 4 --retry 3
|
|
17
|
+
|
|
18
|
+
FROM ruby:3.3-alpine
|
|
19
|
+
|
|
20
|
+
WORKDIR /app
|
|
21
|
+
|
|
22
|
+
RUN apk add --no-cache curl tzdata && \
|
|
23
|
+
addgroup -S -g 1000 prescient && \
|
|
24
|
+
adduser -S -u 1000 -G prescient prescient
|
|
25
|
+
|
|
26
|
+
ENV BUNDLE_WITH=rack_example \
|
|
27
|
+
BUNDLE_WITHOUT=development:test \
|
|
28
|
+
BUNDLE_PATH=/usr/local/bundle \
|
|
29
|
+
GEM_HOME=/usr/local/bundle/ruby/3.3.0 \
|
|
30
|
+
GEM_PATH=/usr/local/bundle/ruby/3.3.0 \
|
|
31
|
+
PATH=/usr/local/bundle/ruby/3.3.0/bin:/usr/local/bundle/bin:$PATH \
|
|
32
|
+
RACK_ENV=production
|
|
33
|
+
|
|
34
|
+
COPY --from=builder /usr/local/bundle /usr/local/bundle
|
|
35
|
+
COPY --chown=prescient:prescient lib ./lib
|
|
36
|
+
COPY --chown=prescient:prescient examples/rest_api.ru ./examples/rest_api.ru
|
|
37
|
+
|
|
38
|
+
USER prescient
|
|
39
|
+
|
|
40
|
+
EXPOSE 9292
|
|
41
|
+
|
|
42
|
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
|
43
|
+
CMD curl --fail --silent http://127.0.0.1:9292/healthz || exit 1
|
|
44
|
+
|
|
45
|
+
CMD ["rackup", "-s", "puma", "-o", "0.0.0.0", "-p", "9292", "/app/examples/rest_api.ru"]
|
data/INTEGRATION_GUIDE.md
CHANGED
|
@@ -11,9 +11,7 @@ and [examples guide](examples/README.md).
|
|
|
11
11
|
|
|
12
12
|
```ruby
|
|
13
13
|
# Add to your Gemfile
|
|
14
|
-
gem 'prescient',
|
|
15
|
-
# OR when published:
|
|
16
|
-
# gem 'prescient', '~> 0.5.0'
|
|
14
|
+
gem 'prescient', '~> 0.6.0'
|
|
17
15
|
```
|
|
18
16
|
|
|
19
17
|
### 2. Replace Existing AI Service
|
|
@@ -111,6 +109,40 @@ Prescient.configure do |config|
|
|
|
111
109
|
chat_model: ENV.fetch('HUGGINGFACE_CHAT_MODEL', 'google/gemma-2-2b-it')
|
|
112
110
|
)
|
|
113
111
|
end
|
|
112
|
+
|
|
113
|
+
# Google Gemini
|
|
114
|
+
if ENV['GEMINI_API_KEY'].present?
|
|
115
|
+
config.add_provider(:gemini, Prescient::Provider::Gemini,
|
|
116
|
+
api_key: ENV['GEMINI_API_KEY'],
|
|
117
|
+
embedding_model: ENV.fetch('GEMINI_EMBEDDING_MODEL', 'gemini-embedding-001'),
|
|
118
|
+
chat_model: ENV.fetch('GEMINI_CHAT_MODEL', 'gemini-2.5-flash')
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Mistral
|
|
123
|
+
if ENV['MISTRAL_API_KEY'].present?
|
|
124
|
+
config.add_provider(:mistral, Prescient::Provider::Mistral,
|
|
125
|
+
api_key: ENV['MISTRAL_API_KEY'],
|
|
126
|
+
embedding_model: ENV.fetch('MISTRAL_EMBEDDING_MODEL', 'mistral-embed'),
|
|
127
|
+
chat_model: ENV.fetch('MISTRAL_CHAT_MODEL', 'mistral-large-latest')
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# DeepSeek supports generation, but not embeddings.
|
|
132
|
+
if ENV['DEEPSEEK_API_KEY'].present?
|
|
133
|
+
config.add_provider(:deepseek, Prescient::Provider::DeepSeek,
|
|
134
|
+
api_key: ENV['DEEPSEEK_API_KEY'],
|
|
135
|
+
chat_model: ENV.fetch('DEEPSEEK_CHAT_MODEL', 'deepseek-v4-flash')
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# xAI supports generation, but not embeddings.
|
|
140
|
+
if ENV['XAI_API_KEY'].present?
|
|
141
|
+
config.add_provider(:xai, Prescient::Provider::XAI,
|
|
142
|
+
api_key: ENV['XAI_API_KEY'],
|
|
143
|
+
chat_model: ENV.fetch('XAI_CHAT_MODEL', 'grok-4.5')
|
|
144
|
+
)
|
|
145
|
+
end
|
|
114
146
|
end
|
|
115
147
|
|
|
116
148
|
# Set default provider for Rails
|
|
@@ -128,7 +160,56 @@ Configuration precedence is CLI overrides, environment defaults and
|
|
|
128
160
|
references, YAML values, then built-in defaults. The generated
|
|
129
161
|
`prescient config example` file includes the current JSON Schema URL.
|
|
130
162
|
|
|
131
|
-
### 4.
|
|
163
|
+
### 4. Mount the REST API
|
|
164
|
+
|
|
165
|
+
`Prescient::API` is Rack-compatible and can be mounted directly in a Rails
|
|
166
|
+
route set. The API keeps provider execution on `Prescient::Client` and exposes
|
|
167
|
+
only generic operations:
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
# config/routes.rb
|
|
171
|
+
prescient_api = Prescient::API.new(
|
|
172
|
+
authentication: lambda { |env|
|
|
173
|
+
expected_token = ENV.fetch('PRESCIENT_API_TOKEN', nil)
|
|
174
|
+
expected_token && env['HTTP_AUTHORIZATION'] == "Bearer #{expected_token}"
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
mount prescient_api => '/prescient', as: :prescient_api
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
This makes the following routes available under `/prescient`:
|
|
182
|
+
|
|
183
|
+
| Method | Path | Purpose |
|
|
184
|
+
| --- | --- | --- |
|
|
185
|
+
| `GET` | `/healthz` | Liveness check |
|
|
186
|
+
| `GET` | `/readyz` | Readiness check |
|
|
187
|
+
| `GET` | `/v1/version` | Library and API versions |
|
|
188
|
+
| `GET` | `/v1/providers` | Configured providers |
|
|
189
|
+
| `GET` | `/v1/models` | Available models, optionally filtered by provider |
|
|
190
|
+
| `GET` | `/v1/capabilities` | Provider capabilities |
|
|
191
|
+
| `GET` | `/v1/health` | Provider health |
|
|
192
|
+
| `POST` | `/v1/generate` | Text generation |
|
|
193
|
+
| `POST` | `/v1/embeddings` | Single embedding |
|
|
194
|
+
| `POST` | `/v1/embeddings/batch` | Bounded batch embeddings |
|
|
195
|
+
|
|
196
|
+
For example:
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
curl http://localhost:3000/prescient/healthz
|
|
200
|
+
|
|
201
|
+
curl -X POST http://localhost:3000/prescient/v1/generate \
|
|
202
|
+
-H "Authorization: Bearer ${PRESCIENT_API_TOKEN}" \
|
|
203
|
+
-H 'Content-Type: application/json' \
|
|
204
|
+
-d '{"prompt":"Explain Ruby fibers"}'
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Responses include a request ID. Request bodies are size-limited, batch inputs
|
|
208
|
+
are bounded, and JSON errors do not expose raw provider response bodies. Keep
|
|
209
|
+
the API behind the application’s normal TLS, authentication, rate-limiting,
|
|
210
|
+
and observability controls.
|
|
211
|
+
|
|
212
|
+
### 5. Update Environment Variables
|
|
132
213
|
|
|
133
214
|
```bash
|
|
134
215
|
# .env or environment configuration
|
|
@@ -151,9 +232,27 @@ ANTHROPIC_MODEL=claude-sonnet-4-20250514
|
|
|
151
232
|
HUGGINGFACE_API_KEY=your_huggingface_api_key
|
|
152
233
|
HUGGINGFACE_EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
|
|
153
234
|
HUGGINGFACE_CHAT_MODEL=google/gemma-2-2b-it
|
|
235
|
+
|
|
236
|
+
# Google Gemini
|
|
237
|
+
GEMINI_API_KEY=your_gemini_api_key
|
|
238
|
+
GEMINI_EMBEDDING_MODEL=gemini-embedding-001
|
|
239
|
+
GEMINI_CHAT_MODEL=gemini-2.5-flash
|
|
240
|
+
|
|
241
|
+
# Mistral
|
|
242
|
+
MISTRAL_API_KEY=your_mistral_api_key
|
|
243
|
+
MISTRAL_EMBEDDING_MODEL=mistral-embed
|
|
244
|
+
MISTRAL_CHAT_MODEL=mistral-large-latest
|
|
245
|
+
|
|
246
|
+
# DeepSeek (generation only)
|
|
247
|
+
DEEPSEEK_API_KEY=your_deepseek_api_key
|
|
248
|
+
DEEPSEEK_CHAT_MODEL=deepseek-v4-flash
|
|
249
|
+
|
|
250
|
+
# xAI (generation only)
|
|
251
|
+
XAI_API_KEY=your_xai_api_key
|
|
252
|
+
XAI_CHAT_MODEL=grok-4.5
|
|
154
253
|
```
|
|
155
254
|
|
|
156
|
-
###
|
|
255
|
+
### 6. Update Controllers
|
|
157
256
|
|
|
158
257
|
**Before:**
|
|
159
258
|
|
|
@@ -199,7 +298,7 @@ class Api::V1::AiQueriesController < ApplicationController
|
|
|
199
298
|
end
|
|
200
299
|
```
|
|
201
300
|
|
|
202
|
-
###
|
|
301
|
+
### 7. Health Check Integration
|
|
203
302
|
|
|
204
303
|
```ruby
|
|
205
304
|
# app/controllers/api/v1/system/health_controller.rb
|
|
@@ -232,7 +331,7 @@ class Api::V1::System::HealthController < ApplicationController
|
|
|
232
331
|
}
|
|
233
332
|
|
|
234
333
|
# Check backup providers
|
|
235
|
-
backup_providers = [
|
|
334
|
+
backup_providers = %i[openai anthropic huggingface gemini mistral deepseek xai] - [primary_provider]
|
|
236
335
|
providers[:backups] = backup_providers.map do |provider|
|
|
237
336
|
{
|
|
238
337
|
name: provider,
|
|
@@ -252,7 +351,7 @@ class Api::V1::System::HealthController < ApplicationController
|
|
|
252
351
|
end
|
|
253
352
|
```
|
|
254
353
|
|
|
255
|
-
###
|
|
354
|
+
### 8. Migration Strategy
|
|
256
355
|
|
|
257
356
|
1. **Phase 1: Side-by-side deployment**
|
|
258
357
|
|
|
@@ -271,7 +370,7 @@ end
|
|
|
271
370
|
- Update all controllers to use AIService
|
|
272
371
|
- Clean up unused code
|
|
273
372
|
|
|
274
|
-
###
|
|
373
|
+
### 9. Testing Updates
|
|
275
374
|
|
|
276
375
|
```ruby
|
|
277
376
|
# test/services/ai_service_test.rb
|
|
@@ -296,7 +395,7 @@ class AIServiceTest < ActiveSupport::TestCase
|
|
|
296
395
|
end
|
|
297
396
|
```
|
|
298
397
|
|
|
299
|
-
###
|
|
398
|
+
### 10. Monitoring and Logging
|
|
300
399
|
|
|
301
400
|
```ruby
|
|
302
401
|
# config/initializers/prescient_monitoring.rb
|
|
@@ -320,7 +419,7 @@ end
|
|
|
320
419
|
PrescientMonitoring.setup! if Rails.env.production?
|
|
321
420
|
```
|
|
322
421
|
|
|
323
|
-
###
|
|
422
|
+
### 11. Performance Optimization
|
|
324
423
|
|
|
325
424
|
```ruby
|
|
326
425
|
# app/services/ai_service.rb (enhanced)
|
data/README.md
CHANGED
|
@@ -223,6 +223,62 @@ The generated file points YAML language servers at the latest schema on the
|
|
|
223
223
|
main branch. Pin the schema URL to a release tag when reproducible tooling is
|
|
224
224
|
required.
|
|
225
225
|
|
|
226
|
+
## REST API
|
|
227
|
+
|
|
228
|
+
`Prescient::API` is a dependency-free Rack-compatible application. Mount it in
|
|
229
|
+
the web server of your choice without making HTTP a requirement for library
|
|
230
|
+
users. The API and CLI files are loaded lazily; `Prescient::API` autoloads on
|
|
231
|
+
first reference, or you can require it explicitly:
|
|
232
|
+
|
|
233
|
+
```ruby
|
|
234
|
+
require 'prescient'
|
|
235
|
+
require 'prescient/api'
|
|
236
|
+
|
|
237
|
+
run Prescient::API.new(
|
|
238
|
+
authentication: ->(env) { env['HTTP_AUTHORIZATION'] == "Bearer #{ENV['PRESCIENT_API_TOKEN']}" }
|
|
239
|
+
)
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Available endpoints include:
|
|
243
|
+
|
|
244
|
+
- **`POST /v1/generate`**
|
|
245
|
+
- **`POST /v1/embeddings`**
|
|
246
|
+
- **`POST /v1/embeddings/batch`**
|
|
247
|
+
- **`GET /v1/providers`**
|
|
248
|
+
- **`GET /v1/models`**
|
|
249
|
+
- **`GET /v1/capabilities`**
|
|
250
|
+
- **`GET /v1/health`**
|
|
251
|
+
- **`GET /v1/version`**
|
|
252
|
+
- **`GET /healthz`**
|
|
253
|
+
- **`GET /readyz`**
|
|
254
|
+
|
|
255
|
+
Responses include:
|
|
256
|
+
|
|
257
|
+
- a request ID
|
|
258
|
+
- a generic JSON error envelope that never exposes raw provider response bodies.
|
|
259
|
+
|
|
260
|
+
## Docker
|
|
261
|
+
|
|
262
|
+
Build and run the REST API image as a non-root container:
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
docker build -t prescient:local .
|
|
266
|
+
docker run --rm -p 9292:9292 \
|
|
267
|
+
-e PRESCIENT_API_TOKEN=change-me \
|
|
268
|
+
prescient:local
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
The image exposes port `9292`, includes a `/healthz` healthcheck, supports a
|
|
272
|
+
read-only filesystem, and does not bundle PostgreSQL, Redis, or a worker. The
|
|
273
|
+
Compose example provides the same setup:
|
|
274
|
+
|
|
275
|
+
```bash
|
|
276
|
+
PRESCIENT_API_TOKEN=change-me docker compose -f docker-compose.api.yml up --build
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
Tagged releases publish to GHCR as:
|
|
280
|
+
`ghcr.io/kanutocd/prescient:<version>`.
|
|
281
|
+
|
|
226
282
|
## Configuration
|
|
227
283
|
|
|
228
284
|
### Environment Variables
|
|
@@ -978,6 +1034,30 @@ puts info[:options] # => { ... } (excluding sensitive data)
|
|
|
978
1034
|
- Research-friendly
|
|
979
1035
|
- Free tier available
|
|
980
1036
|
|
|
1037
|
+
### Google Gemini
|
|
1038
|
+
|
|
1039
|
+
- Text generation and embeddings
|
|
1040
|
+
- Google AI API integration
|
|
1041
|
+
- Model discovery through the Gemini models endpoint
|
|
1042
|
+
|
|
1043
|
+
### Mistral
|
|
1044
|
+
|
|
1045
|
+
- Text generation and embeddings
|
|
1046
|
+
- OpenAI-compatible API style
|
|
1047
|
+
- Model discovery through the Mistral models endpoint
|
|
1048
|
+
|
|
1049
|
+
### DeepSeek
|
|
1050
|
+
|
|
1051
|
+
- Text generation
|
|
1052
|
+
- OpenAI-compatible API style
|
|
1053
|
+
- No embedding support
|
|
1054
|
+
|
|
1055
|
+
### xAI
|
|
1056
|
+
|
|
1057
|
+
- Text generation
|
|
1058
|
+
- OpenAI-compatible API style
|
|
1059
|
+
- No embedding support
|
|
1060
|
+
|
|
981
1061
|
## Docker Setup (Recommended for Ollama)
|
|
982
1062
|
|
|
983
1063
|
The easiest way to get started with Prescient and Ollama is using Docker Compose:
|
|
@@ -1210,10 +1290,10 @@ OPENAI_API_KEY=... \
|
|
|
1210
1290
|
bundle exec ruby -Itest test/prescient/live_provider_smoke_test.rb
|
|
1211
1291
|
```
|
|
1212
1292
|
|
|
1213
|
-
Supported provider names are `ollama`, `anthropic`, `openai`,
|
|
1214
|
-
`
|
|
1215
|
-
overrides are honored. These tests are never
|
|
1216
|
-
variables are set.
|
|
1293
|
+
Supported provider names are `ollama`, `anthropic`, `openai`, `huggingface`,
|
|
1294
|
+
`gemini`, `mistral`, `deepseek`, and `xai`. The corresponding provider
|
|
1295
|
+
environment variables and model overrides are honored. These tests are never
|
|
1296
|
+
live unless both opt-in variables are set.
|
|
1217
1297
|
|
|
1218
1298
|
### RBS and Steep
|
|
1219
1299
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
services:
|
|
2
|
+
prescient-api:
|
|
3
|
+
build:
|
|
4
|
+
context: .
|
|
5
|
+
dockerfile: Dockerfile
|
|
6
|
+
image: prescient:local
|
|
7
|
+
ports:
|
|
8
|
+
- "9292:9292"
|
|
9
|
+
environment:
|
|
10
|
+
PRESCIENT_API_TOKEN: ${PRESCIENT_API_TOKEN:?set PRESCIENT_API_TOKEN}
|
|
11
|
+
OLLAMA_URL: ${OLLAMA_URL:-http://host.docker.internal:11434}
|
|
12
|
+
read_only: true
|
|
13
|
+
tmpfs:
|
|
14
|
+
- /tmp
|
|
15
|
+
restart: unless-stopped
|
|
16
|
+
healthcheck:
|
|
17
|
+
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:9292/healthz"]
|
|
18
|
+
interval: 30s
|
|
19
|
+
timeout: 5s
|
|
20
|
+
retries: 3
|
|
21
|
+
start_period: 10s
|
data/examples/README.md
CHANGED
|
@@ -17,6 +17,23 @@ bundle install
|
|
|
17
17
|
and embedding field selection.
|
|
18
18
|
- `vector_search.rb` — `Prescient::Pgvector::Store` PostgreSQL/pgvector storage
|
|
19
19
|
and similarity search.
|
|
20
|
+
- `rest_api.ru` — a tiny Rack-compatible application that mounts
|
|
21
|
+
`Prescient::API` and lists its endpoints at `/`.
|
|
22
|
+
|
|
23
|
+
Run the REST API example with a Rack server such as `rackup`:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
BUNDLE_WITH=rack_example bundle install
|
|
27
|
+
PRESCIENT_API_TOKEN=change-me BUNDLE_WITH=rack_example \
|
|
28
|
+
bundle exec rackup -s puma examples/rest_api.ru
|
|
29
|
+
curl http://localhost:9292/
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Running `bundle exec ruby examples/rest_api.ru` directly prints the same
|
|
33
|
+
endpoint catalog without starting a server.
|
|
34
|
+
|
|
35
|
+
The example does not add Rack as a Prescient runtime dependency; it only uses
|
|
36
|
+
the Rack-compatible `call` interface provided by `Prescient::API`.
|
|
20
37
|
|
|
21
38
|
The first three examples use Ollama by default. Start Ollama and pull the
|
|
22
39
|
current local models before running them:
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative '../lib/prescient'
|
|
5
|
+
|
|
6
|
+
api = Prescient::API.new(
|
|
7
|
+
authentication: ->(env) {
|
|
8
|
+
expected = ENV.fetch('PRESCIENT_API_TOKEN', nil)
|
|
9
|
+
expected && env['HTTP_AUTHORIZATION'] == "Bearer #{expected}"
|
|
10
|
+
},
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
endpoints = Prescient::API::ROUTES.keys.map { |method, path|
|
|
14
|
+
{ method: method, path: path }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
app = ->(env) {
|
|
18
|
+
if env['REQUEST_METHOD'] == 'GET' && env['PATH_INFO'] == '/'
|
|
19
|
+
payload = JSON.generate({ name: 'Prescient API', endpoints: endpoints })
|
|
20
|
+
[200, { 'content-type' => 'application/json', 'content-length' => payload.bytesize.to_s }, [payload]]
|
|
21
|
+
else
|
|
22
|
+
api.call(env)
|
|
23
|
+
end
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if respond_to?(:run, true)
|
|
27
|
+
run app
|
|
28
|
+
else
|
|
29
|
+
puts JSON.pretty_generate({ name: 'Prescient API', endpoints: endpoints })
|
|
30
|
+
end
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require 'stringio'
|
|
6
|
+
require 'uri'
|
|
7
|
+
require_relative '../prescient'
|
|
8
|
+
|
|
9
|
+
# Dependency-free Rack-compatible HTTP application for Prescient operations.
|
|
10
|
+
#
|
|
11
|
+
# The application exposes only generic Prescient operations. It does not
|
|
12
|
+
# expose provider-specific methods, credentials, or raw provider responses.
|
|
13
|
+
class Prescient::API
|
|
14
|
+
# @return [Integer] Default maximum request body size in bytes
|
|
15
|
+
DEFAULT_MAX_BODY_BYTES = 1_048_576
|
|
16
|
+
# @return [Integer] Maximum number of inputs accepted by batch embeddings
|
|
17
|
+
MAX_BATCH_SIZE = 32
|
|
18
|
+
# @return [String] HTTP API version
|
|
19
|
+
API_VERSION = '1'
|
|
20
|
+
# @return [Hash<Array<String>, Symbol>] Generic HTTP route handlers
|
|
21
|
+
ROUTES = {
|
|
22
|
+
['GET', '/healthz'] => :healthz_response,
|
|
23
|
+
['GET', '/readyz'] => :readiness_response,
|
|
24
|
+
['GET', '/v1/version'] => :version_response,
|
|
25
|
+
['GET', '/v1/providers'] => :providers_response,
|
|
26
|
+
['GET', '/v1/models'] => :models_response,
|
|
27
|
+
['GET', '/v1/capabilities'] => :capabilities_response,
|
|
28
|
+
['GET', '/v1/health'] => :health_response,
|
|
29
|
+
['POST', '/v1/generate'] => :generate_response,
|
|
30
|
+
['POST', '/v1/embeddings'] => :embeddings_response,
|
|
31
|
+
['POST', '/v1/embeddings/batch'] => :batch_embeddings_response,
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
# @param authentication [#call, nil] Optional authentication hook
|
|
35
|
+
# @param max_body_bytes [Integer] Maximum accepted request body size
|
|
36
|
+
# @return [void]
|
|
37
|
+
def initialize(authentication: nil, max_body_bytes: DEFAULT_MAX_BODY_BYTES)
|
|
38
|
+
@authentication = authentication
|
|
39
|
+
@max_body_bytes = validate_body_limit(max_body_bytes)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Handle a Rack-style environment and return a Rack response tuple.
|
|
43
|
+
# @param env [Hash] Rack-compatible request environment
|
|
44
|
+
# @return [Array(Integer, Hash, Array<String>)] HTTP status, headers, body
|
|
45
|
+
def call(env)
|
|
46
|
+
request_id = request_id_for(env)
|
|
47
|
+
public_path = request_target(env).first
|
|
48
|
+
return dispatch(env, request_id) if ['/healthz', '/readyz'].include?(public_path)
|
|
49
|
+
|
|
50
|
+
unless authenticated?(env)
|
|
51
|
+
return response(401,
|
|
52
|
+
error_payload('authentication_required', 'authentication required',
|
|
53
|
+
request_id))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
dispatch(env, request_id)
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
handle_exception(e, request_id)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def dispatch(env, request_id)
|
|
64
|
+
method = env.fetch('REQUEST_METHOD', 'GET').upcase
|
|
65
|
+
path, query = request_target(env)
|
|
66
|
+
handler = ROUTES[[method, path]]
|
|
67
|
+
return response(404, error_payload('not_found', 'route not found', request_id)) unless handler
|
|
68
|
+
|
|
69
|
+
send(handler, env, query, request_id)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def healthz_response(_env, _query, request_id)
|
|
73
|
+
json_response(200, { status: 'ok' }, request_id)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def version_response(_env, _query, request_id)
|
|
77
|
+
json_response(200, { version: Prescient::VERSION, api_version: API_VERSION }, request_id)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def generate_response(env, _query, request_id)
|
|
81
|
+
payload = request_payload(env)
|
|
82
|
+
prompt = required_string(payload, 'prompt')
|
|
83
|
+
context = payload.fetch('context', [])
|
|
84
|
+
raise ArgumentError, 'context must be an array' unless context.is_a?(Array)
|
|
85
|
+
|
|
86
|
+
client = client_for(payload)
|
|
87
|
+
result = client.generate_response(prompt, context, **generation_options(payload))
|
|
88
|
+
json_response(200, result, request_id)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def embeddings_response(env, _query, request_id)
|
|
92
|
+
payload = request_payload(env)
|
|
93
|
+
input = required_string(payload, 'input')
|
|
94
|
+
client = client_for(payload)
|
|
95
|
+
result = client.generate_embedding(input, **model_options(payload))
|
|
96
|
+
json_response(200, embedding_payload(result, client), request_id)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def batch_embeddings_response(env, _query, request_id)
|
|
100
|
+
payload = request_payload(env)
|
|
101
|
+
inputs = payload['inputs']
|
|
102
|
+
raise ArgumentError, 'inputs must be a non-empty array' unless inputs.is_a?(Array) && inputs.any?
|
|
103
|
+
raise ArgumentError, "inputs cannot contain more than #{MAX_BATCH_SIZE} items" if inputs.length > MAX_BATCH_SIZE
|
|
104
|
+
raise ArgumentError, 'inputs must contain only strings' unless inputs.all?(String)
|
|
105
|
+
|
|
106
|
+
client = client_for(payload)
|
|
107
|
+
embeddings = inputs.map { |input| client.generate_embedding(input, **model_options(payload)) }
|
|
108
|
+
result = { embeddings: embeddings, dimensions: embeddings.first.length, provider: client.provider_name.to_s }
|
|
109
|
+
json_response(200,
|
|
110
|
+
result, request_id)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def readiness_response(_env, _query, request_id)
|
|
114
|
+
providers = Prescient.configuration.providers.keys
|
|
115
|
+
ready = providers.any? { |name|
|
|
116
|
+
begin
|
|
117
|
+
Prescient.health_check(provider: name)[:ready] == true
|
|
118
|
+
rescue Prescient::Error
|
|
119
|
+
false
|
|
120
|
+
end
|
|
121
|
+
}
|
|
122
|
+
json_response(ready ? 200 : 503, { status: ready ? 'ready' : 'not_ready' }, request_id)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def providers_response(_env, _query, request_id)
|
|
126
|
+
providers = Prescient.configuration.providers.map { |name, registration|
|
|
127
|
+
{ name: name.to_s, class: registration[:class].name }
|
|
128
|
+
}
|
|
129
|
+
json_response(200, { providers: providers }, request_id)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def models_response(_env, query, request_id)
|
|
133
|
+
names = query['provider'] ? [query['provider'].to_sym] : Prescient.configuration.providers.keys
|
|
134
|
+
models = names.flat_map { |name|
|
|
135
|
+
provider = Prescient.configuration.provider(name)
|
|
136
|
+
raise Prescient::Error, "Provider not configured: #{name}" unless provider
|
|
137
|
+
|
|
138
|
+
records = if provider.respond_to?(:list_models)
|
|
139
|
+
provider.list_models
|
|
140
|
+
elsif provider.respond_to?(:available_models)
|
|
141
|
+
provider.available_models
|
|
142
|
+
else
|
|
143
|
+
[]
|
|
144
|
+
end
|
|
145
|
+
records.map { |model| { provider: name.to_s, model: model } }
|
|
146
|
+
}
|
|
147
|
+
json_response(200, { models: models }, request_id)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def capabilities_response(_env, _query, request_id)
|
|
151
|
+
capabilities = Prescient.configuration.providers.map { |name, registration|
|
|
152
|
+
provider = registration[:class]
|
|
153
|
+
{
|
|
154
|
+
provider: name.to_s,
|
|
155
|
+
generation: provider.method_defined?(:generate_response),
|
|
156
|
+
embeddings: provider.method_defined?(:generate_embedding),
|
|
157
|
+
health: provider.method_defined?(:health_check),
|
|
158
|
+
model_listing: provider.method_defined?(:list_models) || provider.method_defined?(:available_models),
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
json_response(200, { capabilities: capabilities }, request_id)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def health_response(_env, query, request_id)
|
|
165
|
+
if query['provider']
|
|
166
|
+
json_response(200, Prescient.health_check(provider: query['provider'].to_sym), request_id)
|
|
167
|
+
else
|
|
168
|
+
results = Prescient.configuration.providers.keys.to_h { |name|
|
|
169
|
+
[name.to_s, Prescient.health_check(provider: name)]
|
|
170
|
+
}
|
|
171
|
+
json_response(200, results, request_id)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def client_for(payload)
|
|
176
|
+
provider = payload['provider']&.to_sym
|
|
177
|
+
fallback = payload.key?('fallback') ? payload['fallback'] : true
|
|
178
|
+
raise ArgumentError, 'fallback must be boolean' unless [true, false].include?(fallback)
|
|
179
|
+
|
|
180
|
+
Prescient.client(provider, enable_fallback: fallback)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def generation_options(payload)
|
|
184
|
+
options = model_options(payload)
|
|
185
|
+
['temperature', 'max_tokens', 'top_p'].each do |key|
|
|
186
|
+
options[key.to_sym] = payload[key] if payload.key?(key)
|
|
187
|
+
end
|
|
188
|
+
options
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def model_options(payload)
|
|
192
|
+
payload['model'] ? { model: payload['model'] } : {}
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def embedding_payload(embedding, client)
|
|
196
|
+
{ embedding: embedding, dimensions: embedding.length, provider: client.provider_name.to_s }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def request_payload(env)
|
|
200
|
+
content_length = env['CONTENT_LENGTH'].to_i
|
|
201
|
+
raise ArgumentError, 'request body exceeds configured limit' if content_length > @max_body_bytes
|
|
202
|
+
|
|
203
|
+
body = env.fetch('rack.input', StringIO.new).read(@max_body_bytes + 1)
|
|
204
|
+
raise ArgumentError, 'request body exceeds configured limit' if body.bytesize > @max_body_bytes
|
|
205
|
+
|
|
206
|
+
parsed = JSON.parse(body)
|
|
207
|
+
raise ArgumentError, 'request body must contain a JSON object' unless parsed.is_a?(Hash)
|
|
208
|
+
|
|
209
|
+
parsed
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def required_string(payload, key)
|
|
213
|
+
value = payload[key]
|
|
214
|
+
raise ArgumentError, "#{key} must be a non-empty string" unless value.is_a?(String) && !value.empty?
|
|
215
|
+
|
|
216
|
+
value
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def request_target(env)
|
|
220
|
+
target = env['REQUEST_URI'] || env['PATH_INFO'] || '/'
|
|
221
|
+
path, query = target.split('?', 2)
|
|
222
|
+
[path, URI.decode_www_form(query.to_s).to_h]
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def authenticated?(env)
|
|
226
|
+
return true unless @authentication
|
|
227
|
+
|
|
228
|
+
@authentication.call(env) == true
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def request_id_for(env)
|
|
232
|
+
supplied = env['HTTP_X_REQUEST_ID'].to_s
|
|
233
|
+
supplied.match?(/\A[a-zA-Z0-9._:-]{1,128}\z/) ? supplied : SecureRandom.uuid
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def json_response(status, payload, request_id)
|
|
237
|
+
response(status, payload.merge(request_id: request_id))
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def response(status, payload)
|
|
241
|
+
body = JSON.generate(payload)
|
|
242
|
+
headers = {
|
|
243
|
+
'content-type' => 'application/json',
|
|
244
|
+
'content-length' => body.bytesize.to_s,
|
|
245
|
+
}
|
|
246
|
+
headers['x-request-id'] = payload[:request_id] if payload[:request_id]
|
|
247
|
+
[status, headers, [body]]
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def error_payload(type, message, request_id)
|
|
251
|
+
{ error: { type: type, message: message }, request_id: request_id }
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def error_type(error)
|
|
255
|
+
error.class.name.split('::').last.delete_suffix('Error').downcase
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def error_status(error)
|
|
259
|
+
return 401 if error.is_a?(Prescient::AuthenticationError)
|
|
260
|
+
return 429 if error.is_a?(Prescient::RateLimitError)
|
|
261
|
+
return 503 if error.is_a?(Prescient::ConnectionError) || error.is_a?(Prescient::ProviderError)
|
|
262
|
+
return 422 if error.is_a?(Prescient::ModelNotAvailableError)
|
|
263
|
+
|
|
264
|
+
500
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def handle_exception(error, request_id)
|
|
268
|
+
case error
|
|
269
|
+
when JSON::ParserError
|
|
270
|
+
response(400, error_payload('invalid_json', 'request body must contain valid JSON', request_id))
|
|
271
|
+
when ArgumentError
|
|
272
|
+
response(400, error_payload('invalid_request', error.message, request_id))
|
|
273
|
+
when Prescient::Error
|
|
274
|
+
response(error_status(error), error_payload(error_type(error), error.message, request_id))
|
|
275
|
+
else
|
|
276
|
+
response(500, error_payload('internal_error', 'internal server error', request_id))
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def validate_body_limit(value)
|
|
281
|
+
return value if value.is_a?(Integer) && value.positive?
|
|
282
|
+
|
|
283
|
+
raise ArgumentError, 'max_body_bytes must be a positive integer'
|
|
284
|
+
end
|
|
285
|
+
end
|
data/lib/prescient/cli.rb
CHANGED
data/lib/prescient/version.rb
CHANGED
data/lib/prescient.rb
CHANGED
|
@@ -14,7 +14,6 @@ require_relative 'prescient/provider/deepseek'
|
|
|
14
14
|
require_relative 'prescient/provider/xai'
|
|
15
15
|
require_relative 'prescient/configuration_loader'
|
|
16
16
|
require_relative 'prescient/client'
|
|
17
|
-
require_relative 'prescient/cli'
|
|
18
17
|
|
|
19
18
|
# Main Prescient module for AI provider abstraction
|
|
20
19
|
#
|
|
@@ -35,6 +34,9 @@ require_relative 'prescient/cli'
|
|
|
35
34
|
# embedding = client.generate_embedding("Some text to embed")
|
|
36
35
|
# puts embedding.length # => 1536 (for OpenAI text-embedding-3-small)
|
|
37
36
|
module Prescient
|
|
37
|
+
autoload :API, 'prescient/api'
|
|
38
|
+
autoload :CLI, 'prescient/cli'
|
|
39
|
+
|
|
38
40
|
# Configure Prescient with custom settings and providers
|
|
39
41
|
#
|
|
40
42
|
# @example Configure with custom provider
|
data/sig/prescient.rbs
CHANGED
|
@@ -298,6 +298,16 @@ module Prescient
|
|
|
298
298
|
def run: () -> Integer
|
|
299
299
|
end
|
|
300
300
|
|
|
301
|
+
class API
|
|
302
|
+
DEFAULT_MAX_BODY_BYTES: Integer
|
|
303
|
+
MAX_BATCH_SIZE: Integer
|
|
304
|
+
API_VERSION: String
|
|
305
|
+
ROUTES: Hash[Array[String], Symbol]
|
|
306
|
+
|
|
307
|
+
def initialize: (?authentication: untyped, ?max_body_bytes: Integer) -> void
|
|
308
|
+
def call: (Hash[String, untyped]) -> [Integer, Hash[String, String], Array[String]]
|
|
309
|
+
end
|
|
310
|
+
|
|
301
311
|
class ConfigurationLoader
|
|
302
312
|
CONFIGURATION_VERSION: Integer
|
|
303
313
|
TOP_LEVEL_KEYS: Array[String]
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: prescient
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ken C. Demanawa
|
|
@@ -33,11 +33,13 @@ executables:
|
|
|
33
33
|
extensions: []
|
|
34
34
|
extra_rdoc_files: []
|
|
35
35
|
files:
|
|
36
|
+
- ".dockerignore"
|
|
36
37
|
- ".env.example"
|
|
37
38
|
- ".rubocop.yml"
|
|
38
39
|
- ".yardopts"
|
|
39
40
|
- CHANGELOG.md
|
|
40
41
|
- CODE_OF_CONDUCT.md
|
|
42
|
+
- Dockerfile
|
|
41
43
|
- Dockerfile.example
|
|
42
44
|
- INTEGRATION_GUIDE.md
|
|
43
45
|
- LICENSE.txt
|
|
@@ -50,14 +52,17 @@ files:
|
|
|
50
52
|
- db/init/03_create_indexes.sql
|
|
51
53
|
- db/init/04_insert_sample_data.sql
|
|
52
54
|
- db/migrate/001_create_prescient_tables.rb
|
|
55
|
+
- docker-compose.api.yml
|
|
53
56
|
- docker-compose.yml
|
|
54
57
|
- examples/README.md
|
|
55
58
|
- examples/basic_usage.rb
|
|
56
59
|
- examples/custom_contexts.rb
|
|
57
60
|
- examples/custom_prompts.rb
|
|
61
|
+
- examples/rest_api.ru
|
|
58
62
|
- examples/vector_search.rb
|
|
59
63
|
- exe/prescient
|
|
60
64
|
- lib/prescient.rb
|
|
65
|
+
- lib/prescient/api.rb
|
|
61
66
|
- lib/prescient/base.rb
|
|
62
67
|
- lib/prescient/cli.rb
|
|
63
68
|
- lib/prescient/client.rb
|