prescient 0.5.0 → 0.7.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 +122 -11
- data/README.md +243 -24
- data/docker-compose.api.yml +21 -0
- data/docker-compose.yml +22 -0
- data/examples/README.md +51 -0
- data/examples/rest_api.ru +30 -0
- data/examples/web_search.rb +34 -0
- data/lib/prescient/api.rb +337 -0
- data/lib/prescient/cli.rb +136 -6
- data/lib/prescient/client.rb +26 -0
- data/lib/prescient/configuration_loader.rb +171 -0
- data/lib/prescient/errors.rb +12 -0
- data/lib/prescient/provider/openai.rb +0 -2
- data/lib/prescient/tool/search_api.rb +126 -0
- data/lib/prescient/tool/searxng.rb +124 -0
- data/lib/prescient/tool.rb +123 -0
- data/lib/prescient/version.rb +1 -1
- data/lib/prescient.rb +71 -1
- data/schema/prescient.configuration.schema.json +119 -0
- data/searxng/settings.yml +18 -0
- data/sig/prescient.rbs +62 -0
- metadata +16 -5
data/docker-compose.yml
CHANGED
|
@@ -91,6 +91,26 @@ services:
|
|
|
91
91
|
retries: 3
|
|
92
92
|
start_period: 30s
|
|
93
93
|
|
|
94
|
+
# Optional SearXNG instance for the external web-search example.
|
|
95
|
+
searxng:
|
|
96
|
+
image: searxng/searxng:latest
|
|
97
|
+
container_name: prescient-searxng
|
|
98
|
+
ports:
|
|
99
|
+
- "8080:8080"
|
|
100
|
+
environment:
|
|
101
|
+
- SEARXNG_BASE_URL=http://localhost:8080/
|
|
102
|
+
- SEARXNG_SECRET=${SEARXNG_SECRET:-prescient-development-secret}
|
|
103
|
+
volumes:
|
|
104
|
+
- ./searxng/settings.yml:/etc/searxng/settings.yml:ro
|
|
105
|
+
- searxng_cache:/var/cache/searxng
|
|
106
|
+
restart: unless-stopped
|
|
107
|
+
healthcheck:
|
|
108
|
+
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/')"]
|
|
109
|
+
interval: 30s
|
|
110
|
+
timeout: 10s
|
|
111
|
+
retries: 3
|
|
112
|
+
start_period: 30s
|
|
113
|
+
|
|
94
114
|
# Optional: Redis for caching embeddings (useful for development)
|
|
95
115
|
redis:
|
|
96
116
|
image: redis:7-alpine
|
|
@@ -145,6 +165,8 @@ volumes:
|
|
|
145
165
|
driver: local
|
|
146
166
|
redis_data:
|
|
147
167
|
driver: local
|
|
168
|
+
searxng_cache:
|
|
169
|
+
driver: local
|
|
148
170
|
|
|
149
171
|
networks:
|
|
150
172
|
default:
|
data/examples/README.md
CHANGED
|
@@ -17,6 +17,38 @@ 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
|
+
- `web_search.rb` — explicit SearXNG tool invocation with normalized JSON output.
|
|
23
|
+
|
|
24
|
+
The same `web_search` capability can use SearchApi instead of SearXNG when the
|
|
25
|
+
tool is configured with `type: searchapi` and `SEARCHAPI_API_KEY`.
|
|
26
|
+
|
|
27
|
+
Run the REST API example with a Rack server such as `rackup`:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
BUNDLE_WITH=rack_example bundle install
|
|
31
|
+
PRESCIENT_API_TOKEN=change-me BUNDLE_WITH=rack_example \
|
|
32
|
+
bundle exec rackup -s puma examples/rest_api.ru
|
|
33
|
+
curl http://localhost:9292/
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The endpoint catalog includes `POST /v1/search/generate`. With a configured
|
|
37
|
+
SearXNG tool and AI provider, call it explicitly to feed search results into
|
|
38
|
+
generation:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
curl -X POST http://localhost:9292/v1/search/generate \
|
|
42
|
+
-H 'Authorization: Bearer change-me' \
|
|
43
|
+
-H 'Content-Type: application/json' \
|
|
44
|
+
-d '{"query":"Ruby HTTP clients","provider":"openai","limit":5}'
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Running `bundle exec ruby examples/rest_api.ru` directly prints the same
|
|
48
|
+
endpoint catalog without starting a server.
|
|
49
|
+
|
|
50
|
+
The example does not add Rack as a Prescient runtime dependency; it only uses
|
|
51
|
+
the Rack-compatible `call` interface provided by `Prescient::API`.
|
|
20
52
|
|
|
21
53
|
The first three examples use Ollama by default. Start Ollama and pull the
|
|
22
54
|
current local models before running them:
|
|
@@ -40,6 +72,25 @@ configuration. The scripts are demonstrations rather than isolated test
|
|
|
40
72
|
fixtures; they may make real provider requests when the configured service is
|
|
41
73
|
available.
|
|
42
74
|
|
|
75
|
+
The web-search example requires a reachable SearXNG instance:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
docker compose up -d searxng
|
|
79
|
+
SEARXNG_URL=http://localhost:8080 bundle exec ruby examples/web_search.rb "Ruby HTTP clients"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The example returns normalized search results directly by default. Opt in to
|
|
83
|
+
feeding those results to the configured AI provider with `--generate`:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
SEARXNG_URL=http://localhost:8080 PRESCIENT_PROVIDER=openai \
|
|
87
|
+
bundle exec ruby examples/web_search.rb --generate "Ruby HTTP clients"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Omit `--generate` to keep the search results direct. `PRESCIENT_PROVIDER` is
|
|
91
|
+
only used with `--generate` and may be omitted when the default provider is
|
|
92
|
+
configured.
|
|
93
|
+
|
|
43
94
|
See the [main README](../README.md) for configuration, fallback behavior,
|
|
44
95
|
prompt templates, context exclusions, embeddings, and the public API. Rails
|
|
45
96
|
applications can also use the [integration guide](../INTEGRATION_GUIDE.md),
|
|
@@ -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,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative '../lib/prescient'
|
|
5
|
+
|
|
6
|
+
generate = ARGV.delete('--generate')
|
|
7
|
+
if ARGV.include?('--help')
|
|
8
|
+
puts 'Usage: ruby examples/web_search.rb [--generate] [QUERY]'
|
|
9
|
+
puts ' --generate Feed normalized search results to the configured AI provider'
|
|
10
|
+
puts ' PRESCIENT_PROVIDER Provider used with --generate (default: configured provider)'
|
|
11
|
+
exit
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
query = ARGV.empty? ? 'Ruby HTTP clients' : ARGV.join(' ')
|
|
15
|
+
|
|
16
|
+
Prescient.configure do |config|
|
|
17
|
+
config.add_tool(
|
|
18
|
+
:web_search,
|
|
19
|
+
Prescient::Tool::SearXNG,
|
|
20
|
+
url: ENV.fetch('SEARXNG_URL', 'http://localhost:8080'),
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
result = if generate
|
|
25
|
+
Prescient.search_and_generate(
|
|
26
|
+
query,
|
|
27
|
+
provider: ENV['PRESCIENT_PROVIDER']&.to_sym,
|
|
28
|
+
limit: 20,
|
|
29
|
+
)
|
|
30
|
+
else
|
|
31
|
+
Prescient.tool(:web_search).search(query, limit: 20)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
puts JSON.pretty_generate(result)
|
|
@@ -0,0 +1,337 @@
|
|
|
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/search'] => :search_response,
|
|
31
|
+
['POST', '/v1/search/generate'] => :search_generate_response,
|
|
32
|
+
['POST', '/v1/embeddings'] => :embeddings_response,
|
|
33
|
+
['POST', '/v1/embeddings/batch'] => :batch_embeddings_response,
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
# @param authentication [#call, nil] Optional authentication hook
|
|
37
|
+
# @param max_body_bytes [Integer] Maximum accepted request body size
|
|
38
|
+
# @return [void]
|
|
39
|
+
def initialize(authentication: nil, max_body_bytes: DEFAULT_MAX_BODY_BYTES)
|
|
40
|
+
@authentication = authentication
|
|
41
|
+
@max_body_bytes = validate_body_limit(max_body_bytes)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Handle a Rack-style environment and return a Rack response tuple.
|
|
45
|
+
# @param env [Hash] Rack-compatible request environment
|
|
46
|
+
# @return [Array(Integer, Hash, Array<String>)] HTTP status, headers, body
|
|
47
|
+
def call(env)
|
|
48
|
+
request_id = request_id_for(env)
|
|
49
|
+
public_path = request_target(env).first
|
|
50
|
+
return dispatch(env, request_id) if ['/healthz', '/readyz'].include?(public_path)
|
|
51
|
+
|
|
52
|
+
unless authenticated?(env)
|
|
53
|
+
return response(401,
|
|
54
|
+
error_payload('authentication_required', 'authentication required',
|
|
55
|
+
request_id))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
dispatch(env, request_id)
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
handle_exception(e, request_id)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def dispatch(env, request_id)
|
|
66
|
+
method = env.fetch('REQUEST_METHOD', 'GET').upcase
|
|
67
|
+
path, query = request_target(env)
|
|
68
|
+
handler = ROUTES[[method, path]]
|
|
69
|
+
return response(404, error_payload('not_found', 'route not found', request_id)) unless handler
|
|
70
|
+
|
|
71
|
+
send(handler, env, query, request_id)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def healthz_response(_env, _query, request_id)
|
|
75
|
+
json_response(200, { status: 'ok' }, request_id)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def version_response(_env, _query, request_id)
|
|
79
|
+
json_response(200, { version: Prescient::VERSION, api_version: API_VERSION }, request_id)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def generate_response(env, _query, request_id)
|
|
83
|
+
payload = request_payload(env)
|
|
84
|
+
prompt = required_string(payload, 'prompt')
|
|
85
|
+
context = payload.fetch('context', [])
|
|
86
|
+
raise ArgumentError, 'context must be an array' unless context.is_a?(Array)
|
|
87
|
+
|
|
88
|
+
client = client_for(payload)
|
|
89
|
+
result = client.generate_response(prompt, context, **generation_options(payload))
|
|
90
|
+
json_response(200, result, request_id)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def search_generate_response(env, _query, request_id)
|
|
94
|
+
payload = request_payload(env)
|
|
95
|
+
query = required_string(payload, 'query')
|
|
96
|
+
tool = search_tool_name(payload)
|
|
97
|
+
fallback = search_fallback(payload)
|
|
98
|
+
limit = search_limit(payload)
|
|
99
|
+
|
|
100
|
+
result = Prescient.search_and_generate(
|
|
101
|
+
query,
|
|
102
|
+
tool: tool,
|
|
103
|
+
provider: payload['provider']&.to_sym,
|
|
104
|
+
limit: limit,
|
|
105
|
+
enable_fallback: fallback,
|
|
106
|
+
**generation_options(payload),
|
|
107
|
+
)
|
|
108
|
+
json_response(200, result, request_id)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def search_response(env, _query, request_id)
|
|
112
|
+
payload = request_payload(env)
|
|
113
|
+
query = required_string(payload, 'query')
|
|
114
|
+
tool_name = search_tool_name(payload)
|
|
115
|
+
tool = Prescient.tool(tool_name)
|
|
116
|
+
raise Prescient::ToolConfigurationError, "tool not configured: #{tool_name}" unless tool
|
|
117
|
+
|
|
118
|
+
result = tool.search(query, limit: search_limit(payload))
|
|
119
|
+
json_response(200, result, request_id)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def search_tool_name(payload)
|
|
123
|
+
value = payload.fetch('tool', 'web_search')
|
|
124
|
+
raise ArgumentError, 'tool must be a non-empty string' unless value.is_a?(String) && !value.empty?
|
|
125
|
+
|
|
126
|
+
value.to_sym
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def search_fallback(payload)
|
|
130
|
+
fallback = payload.key?('fallback') ? payload['fallback'] : true
|
|
131
|
+
raise ArgumentError, 'fallback must be boolean' unless [true, false].include?(fallback)
|
|
132
|
+
|
|
133
|
+
fallback
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def search_limit(payload)
|
|
137
|
+
limit = payload['limit']
|
|
138
|
+
raise ArgumentError, 'limit must be a positive integer' if limit && (!limit.is_a?(Integer) || !limit.positive?)
|
|
139
|
+
|
|
140
|
+
limit
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def embeddings_response(env, _query, request_id)
|
|
144
|
+
payload = request_payload(env)
|
|
145
|
+
input = required_string(payload, 'input')
|
|
146
|
+
client = client_for(payload)
|
|
147
|
+
result = client.generate_embedding(input, **model_options(payload))
|
|
148
|
+
json_response(200, embedding_payload(result, client), request_id)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def batch_embeddings_response(env, _query, request_id)
|
|
152
|
+
payload = request_payload(env)
|
|
153
|
+
inputs = payload['inputs']
|
|
154
|
+
raise ArgumentError, 'inputs must be a non-empty array' unless inputs.is_a?(Array) && inputs.any?
|
|
155
|
+
raise ArgumentError, "inputs cannot contain more than #{MAX_BATCH_SIZE} items" if inputs.length > MAX_BATCH_SIZE
|
|
156
|
+
raise ArgumentError, 'inputs must contain only strings' unless inputs.all?(String)
|
|
157
|
+
|
|
158
|
+
client = client_for(payload)
|
|
159
|
+
embeddings = inputs.map { |input| client.generate_embedding(input, **model_options(payload)) }
|
|
160
|
+
result = { embeddings: embeddings, dimensions: embeddings.first.length, provider: client.provider_name.to_s }
|
|
161
|
+
json_response(200,
|
|
162
|
+
result, request_id)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def readiness_response(_env, _query, request_id)
|
|
166
|
+
providers = Prescient.configuration.providers.keys
|
|
167
|
+
ready = providers.any? { |name|
|
|
168
|
+
begin
|
|
169
|
+
Prescient.health_check(provider: name)[:ready] == true
|
|
170
|
+
rescue Prescient::Error
|
|
171
|
+
false
|
|
172
|
+
end
|
|
173
|
+
}
|
|
174
|
+
json_response(ready ? 200 : 503, { status: ready ? 'ready' : 'not_ready' }, request_id)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def providers_response(_env, _query, request_id)
|
|
178
|
+
providers = Prescient.configuration.providers.map { |name, registration|
|
|
179
|
+
{ name: name.to_s, class: registration[:class].name }
|
|
180
|
+
}
|
|
181
|
+
json_response(200, { providers: providers }, request_id)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def models_response(_env, query, request_id)
|
|
185
|
+
names = query['provider'] ? [query['provider'].to_sym] : Prescient.configuration.providers.keys
|
|
186
|
+
models = names.flat_map { |name|
|
|
187
|
+
provider = Prescient.configuration.provider(name)
|
|
188
|
+
raise Prescient::Error, "Provider not configured: #{name}" unless provider
|
|
189
|
+
|
|
190
|
+
records = if provider.respond_to?(:list_models)
|
|
191
|
+
provider.list_models
|
|
192
|
+
elsif provider.respond_to?(:available_models)
|
|
193
|
+
provider.available_models
|
|
194
|
+
else
|
|
195
|
+
[]
|
|
196
|
+
end
|
|
197
|
+
records.map { |model| { provider: name.to_s, model: model } }
|
|
198
|
+
}
|
|
199
|
+
json_response(200, { models: models }, request_id)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def capabilities_response(_env, _query, request_id)
|
|
203
|
+
capabilities = Prescient.configuration.providers.map { |name, registration|
|
|
204
|
+
provider = registration[:class]
|
|
205
|
+
{
|
|
206
|
+
provider: name.to_s,
|
|
207
|
+
generation: provider.method_defined?(:generate_response),
|
|
208
|
+
embeddings: provider.method_defined?(:generate_embedding),
|
|
209
|
+
health: provider.method_defined?(:health_check),
|
|
210
|
+
model_listing: provider.method_defined?(:list_models) || provider.method_defined?(:available_models),
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
json_response(200, { capabilities: capabilities }, request_id)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def health_response(_env, query, request_id)
|
|
217
|
+
if query['provider']
|
|
218
|
+
json_response(200, Prescient.health_check(provider: query['provider'].to_sym), request_id)
|
|
219
|
+
else
|
|
220
|
+
results = Prescient.configuration.providers.keys.to_h { |name|
|
|
221
|
+
[name.to_s, Prescient.health_check(provider: name)]
|
|
222
|
+
}
|
|
223
|
+
json_response(200, results, request_id)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def client_for(payload)
|
|
228
|
+
provider = payload['provider']&.to_sym
|
|
229
|
+
fallback = payload.key?('fallback') ? payload['fallback'] : true
|
|
230
|
+
raise ArgumentError, 'fallback must be boolean' unless [true, false].include?(fallback)
|
|
231
|
+
|
|
232
|
+
Prescient.client(provider, enable_fallback: fallback)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def generation_options(payload)
|
|
236
|
+
options = model_options(payload)
|
|
237
|
+
['temperature', 'max_tokens', 'top_p'].each do |key|
|
|
238
|
+
options[key.to_sym] = payload[key] if payload.key?(key)
|
|
239
|
+
end
|
|
240
|
+
options
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def model_options(payload)
|
|
244
|
+
payload['model'] ? { model: payload['model'] } : {}
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def embedding_payload(embedding, client)
|
|
248
|
+
{ embedding: embedding, dimensions: embedding.length, provider: client.provider_name.to_s }
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def request_payload(env)
|
|
252
|
+
content_length = env['CONTENT_LENGTH'].to_i
|
|
253
|
+
raise ArgumentError, 'request body exceeds configured limit' if content_length > @max_body_bytes
|
|
254
|
+
|
|
255
|
+
body = env.fetch('rack.input', StringIO.new).read(@max_body_bytes + 1)
|
|
256
|
+
raise ArgumentError, 'request body exceeds configured limit' if body.bytesize > @max_body_bytes
|
|
257
|
+
|
|
258
|
+
parsed = JSON.parse(body)
|
|
259
|
+
raise ArgumentError, 'request body must contain a JSON object' unless parsed.is_a?(Hash)
|
|
260
|
+
|
|
261
|
+
parsed
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def required_string(payload, key)
|
|
265
|
+
value = payload[key]
|
|
266
|
+
raise ArgumentError, "#{key} must be a non-empty string" unless value.is_a?(String) && !value.empty?
|
|
267
|
+
|
|
268
|
+
value
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def request_target(env)
|
|
272
|
+
target = env['REQUEST_URI'] || env['PATH_INFO'] || '/'
|
|
273
|
+
path, query = target.split('?', 2)
|
|
274
|
+
[path, URI.decode_www_form(query.to_s).to_h]
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def authenticated?(env)
|
|
278
|
+
return true unless @authentication
|
|
279
|
+
|
|
280
|
+
@authentication.call(env) == true
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def request_id_for(env)
|
|
284
|
+
supplied = env['HTTP_X_REQUEST_ID'].to_s
|
|
285
|
+
supplied.match?(/\A[a-zA-Z0-9._:-]{1,128}\z/) ? supplied : SecureRandom.uuid
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def json_response(status, payload, request_id)
|
|
289
|
+
response(status, payload.merge(request_id: request_id))
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def response(status, payload)
|
|
293
|
+
body = JSON.generate(payload)
|
|
294
|
+
headers = {
|
|
295
|
+
'content-type' => 'application/json',
|
|
296
|
+
'content-length' => body.bytesize.to_s,
|
|
297
|
+
}
|
|
298
|
+
headers['x-request-id'] = payload[:request_id] if payload[:request_id]
|
|
299
|
+
[status, headers, [body]]
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def error_payload(type, message, request_id)
|
|
303
|
+
{ error: { type: type, message: message }, request_id: request_id }
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def error_type(error)
|
|
307
|
+
error.class.name.split('::').last.delete_suffix('Error').downcase
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def error_status(error)
|
|
311
|
+
return 401 if error.is_a?(Prescient::AuthenticationError)
|
|
312
|
+
return 429 if error.is_a?(Prescient::RateLimitError)
|
|
313
|
+
return 503 if error.is_a?(Prescient::ConnectionError) || error.is_a?(Prescient::ProviderError)
|
|
314
|
+
return 422 if error.is_a?(Prescient::ModelNotAvailableError)
|
|
315
|
+
|
|
316
|
+
500
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def handle_exception(error, request_id)
|
|
320
|
+
case error
|
|
321
|
+
when JSON::ParserError
|
|
322
|
+
response(400, error_payload('invalid_json', 'request body must contain valid JSON', request_id))
|
|
323
|
+
when ArgumentError
|
|
324
|
+
response(400, error_payload('invalid_request', error.message, request_id))
|
|
325
|
+
when Prescient::Error
|
|
326
|
+
response(error_status(error), error_payload(error_type(error), error.message, request_id))
|
|
327
|
+
else
|
|
328
|
+
response(500, error_payload('internal_error', 'internal server error', request_id))
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def validate_body_limit(value)
|
|
333
|
+
return value if value.is_a?(Integer) && value.positive?
|
|
334
|
+
|
|
335
|
+
raise ArgumentError, 'max_body_bytes must be a positive integer'
|
|
336
|
+
end
|
|
337
|
+
end
|