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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 53a88a152c9fb689789c06dc11612905e30d76e997dbdcc1ca9906870bb01899
4
- data.tar.gz: 4dba29dd0ad5ae0edbccd0f9d50d8e1eeb8815ba1aeadda838b327ec124111d6
3
+ metadata.gz: 167f50351b78b35d1ae63d3487285db48c07799ad015b5d49f46dd3c451afa49
4
+ data.tar.gz: 91e1895635015120c75b0b461fea017d8ec7c4b5ebd81f1d2b0c86ba37690e1d
5
5
  SHA512:
6
- metadata.gz: 0beee5d0fc6ca63e1d2f881055402a18ab374d8ec71f17c6e706bba7a0ec8f565bbd77ff4f1b337f100597d357a07ffcc45b37e080429199603ce3147e292579
7
- data.tar.gz: 2d8214bfb8d5f61536e5c79462d560cd4d6f83215dae54cffed821fe0f23d2959adbe02ec94f522d69bd2e04adb37b52087ba364752a98e093e6605ce204abfb
6
+ metadata.gz: 6cdf4ed9b05525692898cdd88153f092c23f5caac1d422962bdc768055bb225e960e45a9fb877adb17a7c686e8bf608db09dea1b3e24ebc15767071255910285
7
+ data.tar.gz: ecec27b8c215dfe5fb92e00f68fd6a5ae66c8284342525c9ae635214318032fc4970a72d2944679e455915f2ba8de8425a07706530ca6c309bedc5d5d4ff114a
data/.dockerignore ADDED
@@ -0,0 +1,18 @@
1
+ .git
2
+ .github
3
+ .ignoreme
4
+ .bundle
5
+ .yardoc
6
+ _yardoc
7
+ coverage
8
+ doc
9
+ tmp
10
+ test
11
+ spec
12
+ examples/*.rb
13
+ *.gem
14
+ .env*
15
+ AGENTS.md
16
+ Dockerfile.example
17
+ docker-compose.yml
18
+ docker-compose.api.yml
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', path: './prescient_gem' # Local development
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. Update Environment Variables
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
- ### 5. Update Controllers
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
- ### 6. Health Check Integration
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 = [:openai, :anthropic, :huggingface] - [primary_provider]
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
- ### 7. Migration Strategy
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
- ### 8. Testing Updates
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
- ### 9. Monitoring and Logging
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
- ### 10. Performance Optimization
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`, and
1214
- `huggingface`. The corresponding provider environment variables and model
1215
- overrides are honored. These tests are never live unless both opt-in
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
@@ -3,6 +3,7 @@
3
3
  require 'json'
4
4
  require 'optparse'
5
5
  require 'yaml'
6
+ require_relative '../prescient'
6
7
 
7
8
  # Command-line interface for common Prescient operations.
8
9
  class Prescient::CLI
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Prescient
4
4
  # Current Prescient gem version.
5
- VERSION = '0.5.0'
5
+ VERSION = '0.6.0'
6
6
  end
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.5.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