prescient 0.6.0 → 0.8.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/.rubocop.yml +21 -268
- data/CHANGELOG.md +59 -0
- data/INTEGRATION_GUIDE.md +19 -1
- data/README.md +369 -21
- data/Steepfile +12 -12
- data/db/migrate/001_create_prescient_tables.rb +15 -16
- data/docker-compose.yml +22 -0
- data/examples/README.md +36 -1
- data/examples/custom_contexts.rb +4 -4
- data/examples/web_search.rb +34 -0
- data/exe/prescient +2 -2
- data/exe/prescient-mcp +7 -0
- data/lib/prescient/agent/audit_log.rb +37 -0
- data/lib/prescient/agent/cli_adapter.rb +29 -0
- data/lib/prescient/agent/configuration.rb +57 -0
- data/lib/prescient/agent/context.rb +56 -0
- data/lib/prescient/agent/error_serializer.rb +47 -0
- data/lib/prescient/agent/errors.rb +25 -0
- data/lib/prescient/agent/parser.rb +49 -0
- data/lib/prescient/agent/prompt_builder.rb +31 -0
- data/lib/prescient/agent/result.rb +36 -0
- data/lib/prescient/agent/runtime.rb +175 -0
- data/lib/prescient/agent/schema_validator.rb +215 -0
- data/lib/prescient/agent/tool_registry.rb +89 -0
- data/lib/prescient/agent.rb +22 -0
- data/lib/prescient/api.rb +346 -231
- data/lib/prescient/base.rb +370 -372
- data/lib/prescient/cli.rb +591 -402
- data/lib/prescient/client.rb +31 -4
- data/lib/prescient/configuration_loader.rb +511 -336
- data/lib/prescient/document_source.rb +114 -0
- data/lib/prescient/errors.rb +13 -3
- data/lib/prescient/mcp/authentication.rb +39 -0
- data/lib/prescient/mcp/configuration.rb +38 -0
- data/lib/prescient/mcp/rack.rb +243 -0
- data/lib/prescient/mcp/server.rb +202 -0
- data/lib/prescient/mcp/stdio.rb +42 -0
- data/lib/prescient/mcp.rb +8 -0
- data/lib/prescient/pgvector.rb +193 -189
- data/lib/prescient/provider/anthropic.rb +129 -125
- data/lib/prescient/provider/deepseek.rb +122 -118
- data/lib/prescient/provider/gemini.rb +153 -149
- data/lib/prescient/provider/huggingface.rb +191 -187
- data/lib/prescient/provider/mistral.rb +151 -147
- data/lib/prescient/provider/ollama.rb +168 -165
- data/lib/prescient/provider/openai.rb +174 -171
- data/lib/prescient/provider/xai.rb +122 -118
- data/lib/prescient/tool/search_api.rb +130 -0
- data/lib/prescient/tool/searxng.rb +128 -0
- data/lib/prescient/tool.rb +125 -0
- data/lib/prescient/version.rb +1 -1
- data/lib/prescient.rb +129 -55
- data/schema/prescient.configuration.schema.json +119 -0
- data/searxng/settings.yml +18 -0
- data/sig/prescient.rbs +228 -1
- metadata +33 -5
data/lib/prescient/api.rb
CHANGED
|
@@ -1,285 +1,400 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
require
|
|
6
|
-
require
|
|
7
|
-
require_relative
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
|
|
14
|
-
#
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
[
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
@
|
|
40
|
-
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "stringio"
|
|
6
|
+
require "uri"
|
|
7
|
+
require_relative "../prescient"
|
|
8
|
+
|
|
9
|
+
module Prescient
|
|
10
|
+
# Dependency-free Rack-compatible HTTP application for Prescient operations.
|
|
11
|
+
#
|
|
12
|
+
# The application exposes only generic Prescient operations. It does not
|
|
13
|
+
# expose provider-specific methods, credentials, or raw provider responses.
|
|
14
|
+
# rubocop:disable Metrics/ClassLength
|
|
15
|
+
class API
|
|
16
|
+
# @return [Integer] Default maximum request body size in bytes
|
|
17
|
+
DEFAULT_MAX_BODY_BYTES = 1_048_576
|
|
18
|
+
# @return [Integer] Maximum number of inputs accepted by batch embeddings
|
|
19
|
+
MAX_BATCH_SIZE = 32
|
|
20
|
+
# @return [String] HTTP API version
|
|
21
|
+
API_VERSION = "1"
|
|
22
|
+
# @return [Hash<Array<String>, Symbol>] Generic HTTP route handlers
|
|
23
|
+
ROUTES = {
|
|
24
|
+
["GET", "/healthz"] => :healthz_response,
|
|
25
|
+
["GET", "/readyz"] => :readiness_response,
|
|
26
|
+
["GET", "/v1/version"] => :version_response,
|
|
27
|
+
["GET", "/v1/providers"] => :providers_response,
|
|
28
|
+
["GET", "/v1/models"] => :models_response,
|
|
29
|
+
["GET", "/v1/capabilities"] => :capabilities_response,
|
|
30
|
+
["GET", "/v1/health"] => :health_response,
|
|
31
|
+
["POST", "/v1/generate"] => :generate_response,
|
|
32
|
+
["POST", "/v1/search"] => :search_response,
|
|
33
|
+
["POST", "/v1/search/generate"] => :search_generate_response,
|
|
34
|
+
["POST", "/v1/agent"] => :agent_response,
|
|
35
|
+
["POST", "/v1/embeddings"] => :embeddings_response,
|
|
36
|
+
["POST", "/v1/embeddings/batch"] => :batch_embeddings_response
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
# @param authentication [#call, nil] Optional authentication hook
|
|
40
|
+
# @param max_body_bytes [Integer] Maximum accepted request body size
|
|
41
|
+
# @return [void]
|
|
42
|
+
def initialize(authentication: nil, authorization: nil, request_context: nil,
|
|
43
|
+
telemetry: nil, max_body_bytes: DEFAULT_MAX_BODY_BYTES)
|
|
44
|
+
@authentication = authentication
|
|
45
|
+
@authorization = authorization
|
|
46
|
+
@request_context = request_context
|
|
47
|
+
@telemetry = telemetry
|
|
48
|
+
@max_body_bytes = validate_body_limit(max_body_bytes)
|
|
49
|
+
end
|
|
41
50
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
# Handle a Rack-style environment and return a Rack response tuple.
|
|
52
|
+
# @param env [Hash] Rack-compatible request environment
|
|
53
|
+
# @return [Array(Integer, Hash, Array<String>)] HTTP status, headers, body
|
|
54
|
+
def call(env)
|
|
55
|
+
request_id = request_id_for(env)
|
|
56
|
+
public_path = request_target(env).first
|
|
57
|
+
return dispatch(env, request_id) if ["/healthz", "/readyz"].include?(public_path)
|
|
58
|
+
|
|
59
|
+
authentication = authentication_result(env)
|
|
60
|
+
unless authentication
|
|
61
|
+
return response(401,
|
|
62
|
+
error_payload("authentication_required", "authentication required",
|
|
63
|
+
request_id))
|
|
64
|
+
end
|
|
49
65
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
request_id))
|
|
66
|
+
dispatch(env, request_id, principal: authentication == true ? nil : authentication)
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
handle_exception(e, request_id)
|
|
54
69
|
end
|
|
55
70
|
|
|
56
|
-
|
|
57
|
-
rescue StandardError => e
|
|
58
|
-
handle_exception(e, request_id)
|
|
59
|
-
end
|
|
71
|
+
private
|
|
60
72
|
|
|
61
|
-
|
|
73
|
+
def dispatch(env, request_id, principal: nil)
|
|
74
|
+
method = env.fetch("REQUEST_METHOD", "GET").upcase
|
|
75
|
+
path, query = request_target(env)
|
|
76
|
+
handler = ROUTES[[method, path]]
|
|
77
|
+
return response(404, error_payload("not_found", "route not found", request_id)) unless handler
|
|
62
78
|
|
|
63
|
-
|
|
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
|
|
79
|
+
return send(handler, env, query, request_id, principal) if handler == :agent_response
|
|
68
80
|
|
|
69
|
-
|
|
70
|
-
|
|
81
|
+
send(handler, env, query, request_id)
|
|
82
|
+
end
|
|
71
83
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
84
|
+
def healthz_response(_env, _query, request_id)
|
|
85
|
+
json_response(200, { status: "ok" }, request_id)
|
|
86
|
+
end
|
|
75
87
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
88
|
+
def version_response(_env, _query, request_id)
|
|
89
|
+
json_response(200, { version: Prescient::VERSION, api_version: API_VERSION }, request_id)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def generate_response(env, _query, request_id)
|
|
93
|
+
payload = request_payload(env)
|
|
94
|
+
prompt = required_string(payload, "prompt")
|
|
95
|
+
context = if payload.key?("documents")
|
|
96
|
+
Prescient::DocumentSource::Memory.new(documents: payload["documents"]).fetch
|
|
97
|
+
else
|
|
98
|
+
payload.fetch("context", [])
|
|
99
|
+
end
|
|
100
|
+
raise ArgumentError, "context must be an array" unless context.is_a?(Array)
|
|
79
101
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
raise ArgumentError, 'context must be an array' unless context.is_a?(Array)
|
|
102
|
+
client = client_for(payload)
|
|
103
|
+
result = client.generate_response(prompt, context, **generation_options(payload))
|
|
104
|
+
json_response(200, result, request_id)
|
|
105
|
+
end
|
|
85
106
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
107
|
+
def search_generate_response(env, _query, request_id)
|
|
108
|
+
payload = request_payload(env)
|
|
109
|
+
query = required_string(payload, "query")
|
|
110
|
+
tool = search_tool_name(payload)
|
|
111
|
+
fallback = search_fallback(payload)
|
|
112
|
+
limit = search_limit(payload)
|
|
113
|
+
|
|
114
|
+
result = Prescient.search_and_generate(
|
|
115
|
+
query,
|
|
116
|
+
tool: tool,
|
|
117
|
+
provider: payload["provider"]&.to_sym,
|
|
118
|
+
limit: limit,
|
|
119
|
+
enable_fallback: fallback,
|
|
120
|
+
**generation_options(payload)
|
|
121
|
+
)
|
|
122
|
+
json_response(200, result, request_id)
|
|
123
|
+
end
|
|
90
124
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
end
|
|
125
|
+
def search_response(env, _query, request_id)
|
|
126
|
+
payload = request_payload(env)
|
|
127
|
+
query = required_string(payload, "query")
|
|
128
|
+
tool_name = search_tool_name(payload)
|
|
129
|
+
tool = Prescient.tool(tool_name)
|
|
130
|
+
raise Prescient::ToolConfigurationError, "tool not configured: #{tool_name}" unless tool
|
|
98
131
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
132
|
+
result = tool.search(query, limit: search_limit(payload))
|
|
133
|
+
json_response(200, result, request_id)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def agent_response(env, _query, request_id, principal)
|
|
137
|
+
require "prescient/agent"
|
|
138
|
+
payload = request_payload(env)
|
|
139
|
+
prompt = required_string(payload, "prompt")
|
|
140
|
+
json_response(200, agent_runtime(payload, env, request_id, principal:).run(prompt).to_h, request_id)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def agent_runtime(payload, env, request_id, principal: nil)
|
|
144
|
+
tools = payload.fetch("tools", [])
|
|
145
|
+
validate_agent_tools(tools)
|
|
146
|
+
configuration = Prescient::Agent::Configuration.new(max_loops: payload.fetch("max_loops", 5))
|
|
147
|
+
Prescient::Agent::Runtime.new(
|
|
148
|
+
provider: payload["provider"]&.to_sym,
|
|
149
|
+
tool_names: tools,
|
|
150
|
+
configuration: configuration,
|
|
151
|
+
authorization: @authorization,
|
|
152
|
+
telemetry: @telemetry,
|
|
153
|
+
enable_fallback: agent_fallback(payload),
|
|
154
|
+
generation_options: model_options(payload),
|
|
155
|
+
request_context: request_context(env, request_id, principal:)
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def agent_fallback(payload)
|
|
160
|
+
fallback = payload.fetch("fallback", true)
|
|
161
|
+
raise ArgumentError, "fallback must be boolean" unless [true, false].include?(fallback)
|
|
112
162
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
163
|
+
fallback
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def validate_agent_tools(tools)
|
|
167
|
+
valid = tools.is_a?(Array) && tools.all? { |name| name.is_a?(String) && !name.empty? }
|
|
168
|
+
raise ArgumentError, "tools must be an array of names" unless valid
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def search_tool_name(payload)
|
|
172
|
+
value = payload.fetch("tool", "web_search")
|
|
173
|
+
raise ArgumentError, "tool must be a non-empty string" unless value.is_a?(String) && !value.empty?
|
|
174
|
+
|
|
175
|
+
value.to_sym
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def search_fallback(payload)
|
|
179
|
+
fallback = payload.key?("fallback") ? payload["fallback"] : true
|
|
180
|
+
raise ArgumentError, "fallback must be boolean" unless [true, false].include?(fallback)
|
|
181
|
+
|
|
182
|
+
fallback
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def search_limit(payload)
|
|
186
|
+
limit = payload["limit"]
|
|
187
|
+
raise ArgumentError, "limit must be a positive integer" if limit && (!limit.is_a?(Integer) || !limit.positive?)
|
|
188
|
+
|
|
189
|
+
limit
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def embeddings_response(env, _query, request_id)
|
|
193
|
+
payload = request_payload(env)
|
|
194
|
+
input = required_string(payload, "input")
|
|
195
|
+
client = client_for(payload)
|
|
196
|
+
result = client.generate_embedding(input, **model_options(payload))
|
|
197
|
+
json_response(200, embedding_payload(result, client), request_id)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def batch_embeddings_response(env, _query, request_id)
|
|
201
|
+
payload = request_payload(env)
|
|
202
|
+
inputs = payload["inputs"]
|
|
203
|
+
raise ArgumentError, "inputs must be a non-empty array" unless inputs.is_a?(Array) && inputs.any?
|
|
204
|
+
raise ArgumentError, "inputs cannot contain more than #{MAX_BATCH_SIZE} items" if inputs.length > MAX_BATCH_SIZE
|
|
205
|
+
raise ArgumentError, "inputs must contain only strings" unless inputs.all?(String)
|
|
206
|
+
|
|
207
|
+
client = client_for(payload)
|
|
208
|
+
embeddings = inputs.map { |input| client.generate_embedding(input, **model_options(payload)) }
|
|
209
|
+
result = { embeddings: embeddings, dimensions: embeddings.first.length, provider: client.provider_name.to_s }
|
|
210
|
+
json_response(200,
|
|
211
|
+
result, request_id)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def readiness_response(_env, _query, request_id)
|
|
215
|
+
providers = Prescient.configuration.providers.keys
|
|
216
|
+
ready = providers.any? do |name|
|
|
117
217
|
Prescient.health_check(provider: name)[:ready] == true
|
|
118
218
|
rescue Prescient::Error
|
|
119
219
|
false
|
|
120
220
|
end
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
end
|
|
221
|
+
json_response(ready ? 200 : 503, { status: ready ? "ready" : "not_ready" }, request_id)
|
|
222
|
+
end
|
|
124
223
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
224
|
+
def providers_response(_env, _query, request_id)
|
|
225
|
+
providers = Prescient.configuration.providers.map do |name, registration|
|
|
226
|
+
{ name: name.to_s, class: registration[:class].name }
|
|
227
|
+
end
|
|
228
|
+
json_response(200, { providers: providers }, request_id)
|
|
229
|
+
end
|
|
131
230
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
231
|
+
def models_response(_env, query, request_id)
|
|
232
|
+
names = query["provider"] ? [query["provider"].to_sym] : Prescient.configuration.providers.keys
|
|
233
|
+
models = names.flat_map do |name|
|
|
234
|
+
provider = Prescient.configuration.provider(name)
|
|
235
|
+
raise Prescient::Error, "Provider not configured: #{name}" unless provider
|
|
236
|
+
|
|
237
|
+
records = if provider.respond_to?(:list_models)
|
|
238
|
+
provider.list_models
|
|
239
|
+
elsif provider.respond_to?(:available_models)
|
|
240
|
+
provider.available_models
|
|
241
|
+
else
|
|
242
|
+
[]
|
|
243
|
+
end
|
|
244
|
+
records.map { |model| { provider: name.to_s, model: model } }
|
|
245
|
+
end
|
|
246
|
+
json_response(200, { models: models }, request_id)
|
|
247
|
+
end
|
|
137
248
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
249
|
+
def capabilities_response(_env, _query, request_id)
|
|
250
|
+
capabilities = Prescient.configuration.providers.map do |name, registration|
|
|
251
|
+
provider = registration[:class]
|
|
252
|
+
{
|
|
253
|
+
provider: name.to_s,
|
|
254
|
+
generation: provider.method_defined?(:generate_response),
|
|
255
|
+
embeddings: provider.method_defined?(:generate_embedding),
|
|
256
|
+
health: provider.method_defined?(:health_check),
|
|
257
|
+
model_listing: provider.method_defined?(:list_models) || provider.method_defined?(:available_models)
|
|
258
|
+
}
|
|
259
|
+
end
|
|
260
|
+
json_response(200, { capabilities: capabilities }, request_id)
|
|
261
|
+
end
|
|
149
262
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
json_response(200, { capabilities: capabilities }, request_id)
|
|
162
|
-
end
|
|
263
|
+
def health_response(_env, query, request_id)
|
|
264
|
+
if query["provider"]
|
|
265
|
+
json_response(200, Prescient.health_check(provider: query["provider"].to_sym), request_id)
|
|
266
|
+
else
|
|
267
|
+
results = Prescient.configuration.providers.keys.to_h do |name|
|
|
268
|
+
[name.to_s, Prescient.health_check(provider: name)]
|
|
269
|
+
end
|
|
270
|
+
json_response(200, results, request_id)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
163
273
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
171
|
-
json_response(200, results, request_id)
|
|
274
|
+
def client_for(payload)
|
|
275
|
+
provider = payload["provider"]&.to_sym
|
|
276
|
+
fallback = payload.key?("fallback") ? payload["fallback"] : true
|
|
277
|
+
raise ArgumentError, "fallback must be boolean" unless [true, false].include?(fallback)
|
|
278
|
+
|
|
279
|
+
Prescient.client(provider, enable_fallback: fallback)
|
|
172
280
|
end
|
|
173
|
-
end
|
|
174
281
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
282
|
+
def generation_options(payload)
|
|
283
|
+
options = model_options(payload)
|
|
284
|
+
%w[temperature max_tokens top_p].each do |key|
|
|
285
|
+
options[key.to_sym] = payload[key] if payload.key?(key)
|
|
286
|
+
end
|
|
287
|
+
options
|
|
288
|
+
end
|
|
179
289
|
|
|
180
|
-
|
|
181
|
-
|
|
290
|
+
def model_options(payload)
|
|
291
|
+
payload["model"] ? { model: payload["model"] } : {}
|
|
292
|
+
end
|
|
182
293
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
['temperature', 'max_tokens', 'top_p'].each do |key|
|
|
186
|
-
options[key.to_sym] = payload[key] if payload.key?(key)
|
|
294
|
+
def embedding_payload(embedding, client)
|
|
295
|
+
{ embedding: embedding, dimensions: embedding.length, provider: client.provider_name.to_s }
|
|
187
296
|
end
|
|
188
|
-
options
|
|
189
|
-
end
|
|
190
297
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
298
|
+
def request_payload(env)
|
|
299
|
+
content_length = env["CONTENT_LENGTH"].to_i
|
|
300
|
+
raise ArgumentError, "request body exceeds configured limit" if content_length > @max_body_bytes
|
|
194
301
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
end
|
|
302
|
+
body = env.fetch("rack.input", StringIO.new).read(@max_body_bytes + 1)
|
|
303
|
+
raise ArgumentError, "request body exceeds configured limit" if body.bytesize > @max_body_bytes
|
|
198
304
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
raise ArgumentError, 'request body exceeds configured limit' if content_length > @max_body_bytes
|
|
305
|
+
parsed = JSON.parse(body)
|
|
306
|
+
raise ArgumentError, "request body must contain a JSON object" unless parsed.is_a?(Hash)
|
|
202
307
|
|
|
203
|
-
|
|
204
|
-
|
|
308
|
+
parsed
|
|
309
|
+
end
|
|
205
310
|
|
|
206
|
-
|
|
207
|
-
|
|
311
|
+
def required_string(payload, key)
|
|
312
|
+
value = payload[key]
|
|
313
|
+
raise ArgumentError, "#{key} must be a non-empty string" unless value.is_a?(String) && !value.empty?
|
|
208
314
|
|
|
209
|
-
|
|
210
|
-
|
|
315
|
+
value
|
|
316
|
+
end
|
|
211
317
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
318
|
+
def request_target(env)
|
|
319
|
+
target = env["REQUEST_URI"] || env["PATH_INFO"] || "/"
|
|
320
|
+
path, query = target.split("?", 2)
|
|
321
|
+
[path, URI.decode_www_form(query.to_s).to_h]
|
|
322
|
+
end
|
|
215
323
|
|
|
216
|
-
|
|
217
|
-
|
|
324
|
+
def authentication_result(env)
|
|
325
|
+
return true unless @authentication
|
|
218
326
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
path, query = target.split('?', 2)
|
|
222
|
-
[path, URI.decode_www_form(query.to_s).to_h]
|
|
223
|
-
end
|
|
327
|
+
@authentication.call(env)
|
|
328
|
+
end
|
|
224
329
|
|
|
225
|
-
|
|
226
|
-
|
|
330
|
+
def request_context(env, request_id, principal: nil)
|
|
331
|
+
base = {
|
|
332
|
+
request_id: request_id,
|
|
333
|
+
tenant_id: env["HTTP_X_TENANT_ID"],
|
|
334
|
+
principal: principal || env["REMOTE_USER"]
|
|
335
|
+
}.compact
|
|
336
|
+
return base unless @request_context
|
|
227
337
|
|
|
228
|
-
|
|
229
|
-
|
|
338
|
+
resolved = @request_context.call(env)
|
|
339
|
+
raise ArgumentError, "request context hook must return a mapping" unless resolved.is_a?(Hash)
|
|
230
340
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
supplied.match?(/\A[a-zA-Z0-9._:-]{1,128}\z/) ? supplied : SecureRandom.uuid
|
|
234
|
-
end
|
|
341
|
+
base.merge(resolved)
|
|
342
|
+
end
|
|
235
343
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
344
|
+
def request_id_for(env)
|
|
345
|
+
supplied = env["HTTP_X_REQUEST_ID"].to_s
|
|
346
|
+
supplied.match?(/\A[a-zA-Z0-9._:-]{1,128}\z/) ? supplied : SecureRandom.uuid
|
|
347
|
+
end
|
|
239
348
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
|
349
|
+
def json_response(status, payload, request_id)
|
|
350
|
+
response(status, payload.merge(request_id: request_id))
|
|
351
|
+
end
|
|
249
352
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
353
|
+
def response(status, payload)
|
|
354
|
+
body = JSON.generate(payload)
|
|
355
|
+
headers = {
|
|
356
|
+
"content-type" => "application/json",
|
|
357
|
+
"content-length" => body.bytesize.to_s
|
|
358
|
+
}
|
|
359
|
+
headers["x-request-id"] = payload[:request_id] if payload[:request_id]
|
|
360
|
+
[status, headers, [body]]
|
|
361
|
+
end
|
|
253
362
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
363
|
+
def error_payload(type, message, request_id)
|
|
364
|
+
{ error: { type: type, message: message }, request_id: request_id }
|
|
365
|
+
end
|
|
257
366
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
return 503 if error.is_a?(Prescient::ConnectionError) || error.is_a?(Prescient::ProviderError)
|
|
262
|
-
return 422 if error.is_a?(Prescient::ModelNotAvailableError)
|
|
367
|
+
def error_type(error)
|
|
368
|
+
error.class.name.split("::").last.delete_suffix("Error").downcase
|
|
369
|
+
end
|
|
263
370
|
|
|
264
|
-
|
|
265
|
-
|
|
371
|
+
def error_status(error)
|
|
372
|
+
return 401 if error.is_a?(Prescient::AuthenticationError)
|
|
373
|
+
return 429 if error.is_a?(Prescient::RateLimitError)
|
|
374
|
+
return 503 if error.is_a?(Prescient::ConnectionError) || error.is_a?(Prescient::ProviderError)
|
|
375
|
+
return 422 if error.is_a?(Prescient::ModelNotAvailableError)
|
|
266
376
|
|
|
267
|
-
|
|
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))
|
|
377
|
+
500
|
|
277
378
|
end
|
|
278
|
-
end
|
|
279
379
|
|
|
280
|
-
|
|
281
|
-
|
|
380
|
+
def handle_exception(error, request_id)
|
|
381
|
+
case error
|
|
382
|
+
when JSON::ParserError
|
|
383
|
+
response(400, error_payload("invalid_json", "request body must contain valid JSON", request_id))
|
|
384
|
+
when ArgumentError
|
|
385
|
+
response(400, error_payload("invalid_request", error.message, request_id))
|
|
386
|
+
when Prescient::Error
|
|
387
|
+
response(error_status(error), error_payload(error_type(error), error.message, request_id))
|
|
388
|
+
else
|
|
389
|
+
response(500, error_payload("internal_error", "internal server error", request_id))
|
|
390
|
+
end
|
|
391
|
+
end
|
|
282
392
|
|
|
283
|
-
|
|
393
|
+
def validate_body_limit(value)
|
|
394
|
+
return value if value.is_a?(Integer) && value.positive?
|
|
395
|
+
|
|
396
|
+
raise ArgumentError, "max_body_bytes must be a positive integer"
|
|
397
|
+
end
|
|
284
398
|
end
|
|
399
|
+
# rubocop:enable Metrics/ClassLength
|
|
285
400
|
end
|