omni_agent 0.1.10 → 0.2.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/CHANGELOG.md +55 -0
- data/README.md +100 -2
- data/Rakefile +4 -0
- data/app/jobs/omni_agent/embed_job.rb +10 -0
- data/app/jobs/omni_agent/ingest_job.rb +12 -0
- data/docs/agent/callbacks.md +60 -0
- data/docs/agent/delegation.md +56 -0
- data/docs/agent/provider.md +93 -0
- data/docs/agent/run-entrypoints.md +81 -0
- data/docs/agent/streaming.md +62 -0
- data/docs/agent/structured-output.md +80 -0
- data/docs/configuration.md +68 -0
- data/docs/evals/caching.md +21 -0
- data/docs/evals/generating-and-running.md +87 -0
- data/docs/evals/golden-sets.md +32 -0
- data/docs/evals/judge-provider.md +20 -0
- data/docs/generators.md +143 -0
- data/docs/index.md +98 -0
- data/docs/intro.md +93 -0
- data/docs/mcp/authentication.md +226 -0
- data/docs/mcp/defining-servers.md +196 -0
- data/docs/mcp/transports.md +166 -0
- data/docs/rag.md +372 -0
- data/docs/stock-agents/ocr.md +52 -0
- data/docs/stock-agents/overview.md +91 -0
- data/docs/supported-providers.md +47 -0
- data/docs/tools.md +171 -0
- data/exe/omni_agent +48 -10
- data/lib/generators/omni_agent/chunks/chunks_generator.rb +130 -0
- data/lib/generators/omni_agent/embeddings/embeddings_generator.rb +72 -0
- data/lib/generators/omni_agent/install/install_generator.rb +78 -1
- data/lib/generators/omni_agent/mcp_server/mcp_server_generator.rb +4 -0
- data/lib/generators/omni_agent/stock_agent/stock_agent_generator.rb +73 -0
- data/lib/generators/omni_agent/vector_store.rb +113 -0
- data/lib/omni_agent/agent.rb +168 -39
- data/lib/omni_agent/chunkable.rb +181 -0
- data/lib/omni_agent/chunkers/hierarchical.rb +20 -0
- data/lib/omni_agent/chunkers/markdown.rb +41 -0
- data/lib/omni_agent/chunkers/recursive.rb +53 -0
- data/lib/omni_agent/chunkers.rb +31 -0
- data/lib/omni_agent/configuration.rb +14 -1
- data/lib/omni_agent/docs.rb +32 -0
- data/lib/omni_agent/embeddable.rb +131 -0
- data/lib/omni_agent/engine.rb +11 -0
- data/lib/omni_agent/errors.rb +15 -0
- data/lib/omni_agent/loaders/html.rb +21 -0
- data/lib/omni_agent/loaders/markdown.rb +18 -0
- data/lib/omni_agent/loaders/pdf.rb +25 -0
- data/lib/omni_agent/loaders/text.rb +15 -0
- data/lib/omni_agent/loaders.rb +68 -0
- data/lib/omni_agent/mcp/authenticator.rb +5 -2
- data/lib/omni_agent/mcp/oauth/access_token.rb +60 -0
- data/lib/omni_agent/mcp/oauth/http_client.rb +52 -0
- data/lib/omni_agent/mcp/oauth/introspection_verifier.rb +43 -0
- data/lib/omni_agent/mcp/oauth/jwt_verifier.rb +122 -0
- data/lib/omni_agent/mcp/oauth/resource_server.rb +166 -0
- data/lib/omni_agent/mcp/oauth.rb +18 -0
- data/lib/omni_agent/mcp/rack_app.rb +31 -7
- data/lib/omni_agent/mcp/server.rb +22 -5
- data/lib/omni_agent/providers/base.rb +14 -0
- data/lib/omni_agent/providers/mock.rb +89 -4
- data/lib/omni_agent/providers/ollama.rb +4 -0
- data/lib/omni_agent/providers/openai.rb +33 -2
- data/lib/omni_agent/providers/response.rb +6 -1
- data/lib/omni_agent/providers.rb +7 -0
- data/lib/omni_agent/schema.rb +200 -0
- data/lib/omni_agent/semantic_search.rb +45 -0
- data/lib/omni_agent/stock_agent.rb +59 -0
- data/lib/omni_agent/stock_agents/ocr/prompt.md.erb +6 -0
- data/lib/omni_agent/stock_agents/ocr.rb +70 -0
- data/lib/omni_agent/stock_agents.rb +26 -0
- data/lib/omni_agent/tool.rb +7 -107
- data/lib/omni_agent/version.rb +1 -1
- data/lib/omni_agent.rb +15 -1
- data/lib/tasks/omni_agent_chunks_tasks.rake +16 -0
- data/lib/tasks/omni_agent_embeddings_tasks.rake +15 -0
- metadata +55 -8
- data/app/assets/stylesheets/omni_agent/application.css +0 -15
- data/app/controllers/omni_agent/application_controller.rb +0 -4
- data/app/helpers/omni_agent/application_helper.rb +0 -4
- data/app/jobs/omni_agent/application_job.rb +0 -4
- data/app/mailers/omni_agent/application_mailer.rb +0 -6
- data/app/models/omni_agent/application_record.rb +0 -5
- data/app/views/layouts/omni_agent/application.html.erb +0 -17
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
module OmniAgent
|
|
2
|
+
module MCP
|
|
3
|
+
module OAuth
|
|
4
|
+
# Verifies JWT access tokens (RFC 9068) with the authorization server's
|
|
5
|
+
# JSON Web Key Set. Returns the payload, or nil when the token is invalid.
|
|
6
|
+
# Issuer, audience and scopes are checked afterwards by ResourceServer.
|
|
7
|
+
class JWTVerifier
|
|
8
|
+
# Asymmetric algorithms only: a JWKS publishes public keys.
|
|
9
|
+
DEFAULT_ALGORITHMS = %w[RS256 RS384 RS512 PS256 PS384 PS512 ES256 ES384 ES512].freeze
|
|
10
|
+
CACHE_TTL = 300
|
|
11
|
+
# A token signed with an unknown `kid` refetches the key set (key
|
|
12
|
+
# rotation), but at most this often, so forged tokens cannot make every
|
|
13
|
+
# request call the authorization server. Also the wait after a failed fetch.
|
|
14
|
+
MIN_REFRESH_INTERVAL = 30
|
|
15
|
+
|
|
16
|
+
def self.require_jwt!
|
|
17
|
+
require "jwt"
|
|
18
|
+
rescue LoadError
|
|
19
|
+
raise OmniAgent::MissingDependencyError,
|
|
20
|
+
"The 'jwt' gem is required for `authenticate :oauth, jwks_uri: ...`. " \
|
|
21
|
+
"Please add `gem 'jwt'` to your Gemfile."
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(jwks_uri, algorithms: DEFAULT_ALGORITHMS, leeway: 0)
|
|
25
|
+
self.class.require_jwt!
|
|
26
|
+
|
|
27
|
+
@jwks_uri = jwks_uri
|
|
28
|
+
@algorithms = Array(algorithms).map(&:to_s)
|
|
29
|
+
@leeway = leeway
|
|
30
|
+
# Held only while fetching, so at most one fetch runs at a time.
|
|
31
|
+
# Reading the cached keys never takes it.
|
|
32
|
+
@fetch_mutex = Mutex.new
|
|
33
|
+
@jwks = nil
|
|
34
|
+
@fetched_at = nil
|
|
35
|
+
@failed_at = nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def call(token, _request = nil)
|
|
39
|
+
payload, _header = ::JWT.decode(
|
|
40
|
+
token, nil, true,
|
|
41
|
+
algorithms: @algorithms,
|
|
42
|
+
jwks: method(:jwks),
|
|
43
|
+
required_claims: %w[exp iss],
|
|
44
|
+
leeway: @leeway
|
|
45
|
+
)
|
|
46
|
+
payload
|
|
47
|
+
rescue ::JWT::DecodeError
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# Key loader for JWT.decode. Called with `kid_not_found: true` when the
|
|
54
|
+
# token's `kid` is not in the cached set.
|
|
55
|
+
def jwks(options = {})
|
|
56
|
+
if @jwks.nil?
|
|
57
|
+
load_initial_jwks
|
|
58
|
+
elsif refresh_due?(options) && @fetch_mutex.try_lock
|
|
59
|
+
# Only the thread that got the lock refreshes; the others keep
|
|
60
|
+
# using the cached keys instead of waiting for it.
|
|
61
|
+
begin
|
|
62
|
+
fetch_jwks
|
|
63
|
+
ensure
|
|
64
|
+
@fetch_mutex.unlock
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
@jwks
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# With no keys yet, concurrent requests wait for one shared fetch rather
|
|
72
|
+
# than each fetching in turn. After a failure they fail fast until
|
|
73
|
+
# MIN_REFRESH_INTERVAL has passed, so a down authorization server does
|
|
74
|
+
# not tie up every app thread on timeouts.
|
|
75
|
+
def load_initial_jwks
|
|
76
|
+
raise_unavailable if backing_off?
|
|
77
|
+
|
|
78
|
+
@fetch_mutex.synchronize do
|
|
79
|
+
next if @jwks
|
|
80
|
+
raise_unavailable if backing_off?
|
|
81
|
+
|
|
82
|
+
fetch_jwks
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
raise_unavailable if @jwks.nil?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def refresh_due?(options)
|
|
89
|
+
return false if backing_off?
|
|
90
|
+
|
|
91
|
+
age = monotonic_now - @fetched_at
|
|
92
|
+
age > CACHE_TTL || (options[:kid_not_found] && age > MIN_REFRESH_INTERVAL)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def fetch_jwks
|
|
96
|
+
keys = HTTPClient.get_json(OAuth.resolve(@jwks_uri))
|
|
97
|
+
# @fetched_at first: readers that see @jwks expect it to be set.
|
|
98
|
+
@fetched_at = monotonic_now
|
|
99
|
+
@jwks = keys
|
|
100
|
+
@failed_at = nil
|
|
101
|
+
rescue VerificationError => e
|
|
102
|
+
@failed_at = monotonic_now
|
|
103
|
+
fallback = @jwks ? "using cached keys" : "retrying in #{MIN_REFRESH_INTERVAL}s"
|
|
104
|
+
MCP.logger&.warn("[OmniAgent::MCP] Could not fetch JWKS, #{fallback}: #{e.message}")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def backing_off?
|
|
108
|
+
failed_at = @failed_at
|
|
109
|
+
!failed_at.nil? && monotonic_now - failed_at < MIN_REFRESH_INTERVAL
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def raise_unavailable
|
|
113
|
+
raise VerificationError, "JWKS from #{OAuth.resolve(@jwks_uri)} is unavailable"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def monotonic_now
|
|
117
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module OmniAgent
|
|
4
|
+
module MCP
|
|
5
|
+
module OAuth
|
|
6
|
+
# Built by `authenticate :oauth, ...`. Validates bearer access tokens
|
|
7
|
+
# (signature or introspection, issuer, audience, expiry and scopes) and
|
|
8
|
+
# describes the server in Protected Resource Metadata (RFC 9728) so MCP
|
|
9
|
+
# clients can find the authorization server.
|
|
10
|
+
class ResourceServer
|
|
11
|
+
METADATA_PATH = "/.well-known/oauth-protected-resource".freeze
|
|
12
|
+
# Seconds of clock skew tolerated when checking exp and nbf.
|
|
13
|
+
LEEWAY = 30
|
|
14
|
+
|
|
15
|
+
attr_reader :scopes
|
|
16
|
+
|
|
17
|
+
def initialize(resource:, authorization_servers:, scopes: [], scopes_supported: nil, audience: nil,
|
|
18
|
+
jwks_uri: nil, algorithms: JWTVerifier::DEFAULT_ALGORITHMS, introspection: nil,
|
|
19
|
+
verify_token: nil, &principal_block)
|
|
20
|
+
raise ArgumentError, "authenticate :oauth requires resource:" if resource.nil?
|
|
21
|
+
raise ArgumentError, "authenticate :oauth requires authorization_servers:" if Array(authorization_servers).empty?
|
|
22
|
+
|
|
23
|
+
validate_resource!(resource) unless resource.respond_to?(:call)
|
|
24
|
+
|
|
25
|
+
@resource = resource
|
|
26
|
+
@authorization_servers = authorization_servers
|
|
27
|
+
@scopes = Array(scopes).map(&:to_s).freeze
|
|
28
|
+
@scopes_supported = scopes_supported && Array(scopes_supported).map(&:to_s).freeze
|
|
29
|
+
@audience = audience
|
|
30
|
+
@principal_block = principal_block
|
|
31
|
+
@verifier = build_verifier(jwks_uri, algorithms, introspection, verify_token)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Returns an Authenticator::Result. `error` is nil when no token was
|
|
35
|
+
# sent, :invalid_token or :insufficient_scope (RFC 6750 section 3.1).
|
|
36
|
+
def authenticate(request)
|
|
37
|
+
token = request.bearer_token
|
|
38
|
+
return failure(nil) if token.nil?
|
|
39
|
+
|
|
40
|
+
claims = verify(token, request)
|
|
41
|
+
return failure(:invalid_token) unless claims.is_a?(Hash)
|
|
42
|
+
|
|
43
|
+
access_token = AccessToken.new(token, claims)
|
|
44
|
+
return failure(:invalid_token) unless valid?(access_token)
|
|
45
|
+
return failure(:insufficient_scope) unless access_token.scope?(scopes)
|
|
46
|
+
|
|
47
|
+
principal = @principal_block ? @principal_block.call(access_token, request) : access_token
|
|
48
|
+
return failure(:invalid_token) unless principal
|
|
49
|
+
|
|
50
|
+
Authenticator::Result.new(true, principal, nil, access_token)
|
|
51
|
+
rescue VerificationError => e
|
|
52
|
+
MCP.logger&.error("[OmniAgent::MCP] OAuth token verification failed: #{e.message}")
|
|
53
|
+
failure(:invalid_token)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def metadata(resource_name: nil)
|
|
57
|
+
supported = @scopes_supported || scopes
|
|
58
|
+
|
|
59
|
+
{
|
|
60
|
+
resource: resource,
|
|
61
|
+
authorization_servers: authorization_servers,
|
|
62
|
+
scopes_supported: supported.empty? ? nil : supported,
|
|
63
|
+
bearer_methods_supported: [ "header" ],
|
|
64
|
+
resource_name: resource_name
|
|
65
|
+
}.compact
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Value of the WWW-Authenticate header for a 401 or 403 response.
|
|
69
|
+
def challenge(realm:, error: nil)
|
|
70
|
+
params = { realm: realm, resource_metadata: metadata_url }
|
|
71
|
+
params[:error] = error.to_s if error
|
|
72
|
+
params[:scope] = scopes.join(" ") unless scopes.empty?
|
|
73
|
+
|
|
74
|
+
"Bearer " + params.map { |key, value| %(#{key}="#{value}") }.join(", ")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Served by RackApp under the MCP endpoint itself, so it works wherever
|
|
78
|
+
# the server is mounted.
|
|
79
|
+
def metadata_url
|
|
80
|
+
"#{resource.chomp('/')}#{METADATA_PATH}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def resource
|
|
84
|
+
OAuth.resolve(@resource).to_s
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def authorization_servers
|
|
88
|
+
Array(OAuth.resolve(@authorization_servers)).map(&:to_s)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def build_verifier(jwks_uri, algorithms, introspection, verify_token)
|
|
94
|
+
given = { jwks_uri: jwks_uri, introspection: introspection, verify_token: verify_token }.compact
|
|
95
|
+
unless given.size == 1
|
|
96
|
+
raise ArgumentError, "authenticate :oauth requires exactly one of jwks_uri:, introspection: or verify_token:"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
if jwks_uri
|
|
100
|
+
JWTVerifier.new(jwks_uri, algorithms: algorithms, leeway: LEEWAY)
|
|
101
|
+
elsif introspection
|
|
102
|
+
IntrospectionVerifier.new(**introspection.transform_keys(&:to_sym))
|
|
103
|
+
else
|
|
104
|
+
raise ArgumentError, "verify_token: must respond to #call" unless verify_token.respond_to?(:call)
|
|
105
|
+
|
|
106
|
+
verify_token
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def verify(token, request)
|
|
111
|
+
if @verifier.respond_to?(:arity) && @verifier.arity == 1
|
|
112
|
+
@verifier.call(token)
|
|
113
|
+
else
|
|
114
|
+
@verifier.call(token, request)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def valid?(access_token)
|
|
119
|
+
claims = access_token.claims
|
|
120
|
+
|
|
121
|
+
fresh?(claims) && trusted_issuer?(claims) && intended_audience?(access_token)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def fresh?(claims)
|
|
125
|
+
now = Time.now.to_i
|
|
126
|
+
return false if claims.key?("exp") && !(claims["exp"].is_a?(Numeric) && now < claims["exp"] + LEEWAY)
|
|
127
|
+
return false if claims.key?("nbf") && !(claims["nbf"].is_a?(Numeric) && now >= claims["nbf"] - LEEWAY)
|
|
128
|
+
|
|
129
|
+
true
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# JWTs must carry `iss`; introspection responses and custom verifiers may omit it.
|
|
133
|
+
def trusted_issuer?(claims)
|
|
134
|
+
return true unless claims.key?("iss")
|
|
135
|
+
|
|
136
|
+
authorization_servers.map { |server| normalize(server) }.include?(normalize(claims["iss"]))
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# RFC 8707: the token must have been issued for this server. Tokens
|
|
140
|
+
# meant for another resource are rejected, never passed through.
|
|
141
|
+
def intended_audience?(access_token)
|
|
142
|
+
expected = Array(OAuth.resolve(@audience) || resource).map { |value| normalize(value) }
|
|
143
|
+
|
|
144
|
+
access_token.audience.any? { |value| expected.include?(normalize(value)) }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def normalize(uri)
|
|
148
|
+
uri.to_s.chomp("/")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def validate_resource!(resource)
|
|
152
|
+
uri = URI.parse(resource.to_s)
|
|
153
|
+
return if uri.is_a?(URI::HTTP) && uri.host && uri.fragment.nil?
|
|
154
|
+
|
|
155
|
+
raise ArgumentError, "resource: must be the absolute http(s) URL of the MCP endpoint, without a fragment"
|
|
156
|
+
rescue URI::InvalidURIError
|
|
157
|
+
raise ArgumentError, "resource: #{resource.inspect} is not a valid URL"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def failure(error)
|
|
161
|
+
Authenticator::Result.new(false, nil, error, nil)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module OmniAgent
|
|
2
|
+
module MCP
|
|
3
|
+
# Resource server side of the MCP authorization spec (OAuth 2.1). Access
|
|
4
|
+
# tokens are issued by an external authorization server and validated here;
|
|
5
|
+
# see ResourceServer.
|
|
6
|
+
module OAuth
|
|
7
|
+
# The authorization server could not be reached or answered with something
|
|
8
|
+
# unusable. The request is rejected as unauthenticated.
|
|
9
|
+
class VerificationError < OmniAgent::MCPError; end
|
|
10
|
+
|
|
11
|
+
# Settings accept a value or a callable, so they can be read from ENV or
|
|
12
|
+
# credentials on every request.
|
|
13
|
+
def self.resolve(value)
|
|
14
|
+
value.respond_to?(:call) ? value.call : value
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -7,6 +7,11 @@ module OmniAgent
|
|
|
7
7
|
class RackApp
|
|
8
8
|
JSON_HEADERS = { "content-type" => "application/json" }.freeze
|
|
9
9
|
PARSE_FAILED = Object.new.freeze
|
|
10
|
+
METADATA_CORS_HEADERS = {
|
|
11
|
+
"access-control-allow-origin" => "*",
|
|
12
|
+
"access-control-allow-methods" => "GET, OPTIONS",
|
|
13
|
+
"access-control-allow-headers" => "mcp-protocol-version"
|
|
14
|
+
}.freeze
|
|
10
15
|
|
|
11
16
|
# Accepts the server class or its name. Passing a String defers the
|
|
12
17
|
# constant lookup to request time, which keeps routes reload-safe.
|
|
@@ -23,11 +28,17 @@ module OmniAgent
|
|
|
23
28
|
def call(env)
|
|
24
29
|
server_class = MCP.resolve_server_class(@server)
|
|
25
30
|
request = Request.new(env)
|
|
31
|
+
resource_server = server_class.configured_oauth_resource_server
|
|
32
|
+
|
|
33
|
+
# Public, so clients can discover the authorization server before they have a token.
|
|
34
|
+
if resource_server && request.path_info == OAuth::ResourceServer::METADATA_PATH
|
|
35
|
+
return protected_resource_metadata(server_class, resource_server, request)
|
|
36
|
+
end
|
|
26
37
|
|
|
27
38
|
return forbidden unless origin_allowed?(request, server_class)
|
|
28
39
|
|
|
29
40
|
auth = Authenticator.new(server_class).authenticate(request)
|
|
30
|
-
return unauthorized(server_class) unless auth.authenticated?
|
|
41
|
+
return unauthorized(server_class, resource_server, auth) unless auth.authenticated?
|
|
31
42
|
|
|
32
43
|
return method_not_allowed unless request.post?
|
|
33
44
|
|
|
@@ -41,7 +52,7 @@ module OmniAgent
|
|
|
41
52
|
message = parse_json(body)
|
|
42
53
|
return json_response(400, parse_error_response) if message.equal?(PARSE_FAILED)
|
|
43
54
|
|
|
44
|
-
server = server_class.new(principal: auth.principal, request: request, transport: :http)
|
|
55
|
+
server = server_class.new(principal: auth.principal, access_token: auth.access_token, request: request, transport: :http)
|
|
45
56
|
response = Handler.new(server).call(message)
|
|
46
57
|
return [ 202, {}, [] ] if response.nil?
|
|
47
58
|
|
|
@@ -94,13 +105,26 @@ module OmniAgent
|
|
|
94
105
|
http_error(403, "Origin not allowed")
|
|
95
106
|
end
|
|
96
107
|
|
|
97
|
-
def method_not_allowed
|
|
98
|
-
[ 405, JSON_HEADERS.merge("allow" =>
|
|
108
|
+
def method_not_allowed(allow = "POST")
|
|
109
|
+
[ 405, JSON_HEADERS.merge("allow" => allow), [ JSON.generate({ error: "Method not allowed" }) ] ]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def unauthorized(server_class, resource_server, auth)
|
|
113
|
+
realm = server_class.server_name
|
|
114
|
+
challenge = resource_server ? resource_server.challenge(realm: realm, error: auth.error) : %(Bearer realm="#{realm}")
|
|
115
|
+
status, message = auth.error == :insufficient_scope ? [ 403, "Insufficient scope" ] : [ 401, "Unauthorized" ]
|
|
116
|
+
|
|
117
|
+
[ status, JSON_HEADERS.merge("www-authenticate" => challenge), [ JSON.generate({ error: message }) ] ]
|
|
99
118
|
end
|
|
100
119
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
120
|
+
# RFC 9728 Protected Resource Metadata. Browser-based clients fetch it
|
|
121
|
+
# cross-origin, and it holds nothing secret, so any origin may read it.
|
|
122
|
+
def protected_resource_metadata(server_class, resource_server, request)
|
|
123
|
+
return [ 204, METADATA_CORS_HEADERS.dup, [] ] if request.options?
|
|
124
|
+
return method_not_allowed("GET") unless request.get? || request.head?
|
|
125
|
+
|
|
126
|
+
metadata = resource_server.metadata(resource_name: server_class.server_name)
|
|
127
|
+
[ 200, JSON_HEADERS.merge(METADATA_CORS_HEADERS), [ JSON.generate(metadata) ] ]
|
|
104
128
|
end
|
|
105
129
|
end
|
|
106
130
|
end
|
|
@@ -50,8 +50,17 @@ module OmniAgent
|
|
|
50
50
|
add_tool_declaration(type: :tool, tool_class: tool_class, name: as.to_s)
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
def authenticate(strategy = nil, tokens: nil, &block)
|
|
54
|
-
|
|
53
|
+
def authenticate(strategy = nil, tokens: nil, **oauth_options, &block)
|
|
54
|
+
if strategy != :oauth && oauth_options.any?
|
|
55
|
+
raise ArgumentError, "unknown keywords for authenticate: #{oauth_options.keys.map(&:inspect).join(', ')}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
@authentication = if strategy == :oauth
|
|
59
|
+
raise ArgumentError, "authenticate :oauth does not take tokens:" unless tokens.nil?
|
|
60
|
+
|
|
61
|
+
# The block, if any, maps the validated access token to the principal.
|
|
62
|
+
{ strategy: :oauth, resource_server: OAuth::ResourceServer.new(**oauth_options, &block) }
|
|
63
|
+
elsif block
|
|
55
64
|
{ strategy: :custom, block: block }
|
|
56
65
|
elsif strategy == :none
|
|
57
66
|
{ strategy: :none }
|
|
@@ -60,7 +69,7 @@ module OmniAgent
|
|
|
60
69
|
|
|
61
70
|
{ strategy: :bearer, tokens: tokens }
|
|
62
71
|
else
|
|
63
|
-
raise ArgumentError, "authenticate expects :bearer (with tokens:), :none, or a block"
|
|
72
|
+
raise ArgumentError, "authenticate expects :bearer (with tokens:), :oauth, :none, or a block"
|
|
64
73
|
end
|
|
65
74
|
end
|
|
66
75
|
|
|
@@ -93,6 +102,11 @@ module OmniAgent
|
|
|
93
102
|
def configured_stdio_principal_block; setting(:@stdio_principal_block); end
|
|
94
103
|
def configured_allowed_origins; list_setting(:@allowed_origins); end
|
|
95
104
|
|
|
105
|
+
def configured_oauth_resource_server
|
|
106
|
+
authentication = configured_authentication
|
|
107
|
+
authentication[:resource_server] if authentication && authentication[:strategy] == :oauth
|
|
108
|
+
end
|
|
109
|
+
|
|
96
110
|
# Ordered { "ToolName" => tool_class }: for each server in the
|
|
97
111
|
# inheritance chain, its <Server>::Tools namespace first, then its
|
|
98
112
|
# declared tools. Resolved on every call so lazily referenced tools
|
|
@@ -203,12 +217,14 @@ module OmniAgent
|
|
|
203
217
|
end
|
|
204
218
|
end
|
|
205
219
|
|
|
206
|
-
|
|
220
|
+
# access_token is the OAuth::AccessToken for `authenticate :oauth`, nil otherwise.
|
|
221
|
+
attr_reader :principal, :request, :transport, :access_token
|
|
207
222
|
|
|
208
|
-
def initialize(principal: nil, request: nil, transport: :http)
|
|
223
|
+
def initialize(principal: nil, request: nil, transport: :http, access_token: nil)
|
|
209
224
|
@principal = principal
|
|
210
225
|
@request = request
|
|
211
226
|
@transport = transport
|
|
227
|
+
@access_token = access_token
|
|
212
228
|
end
|
|
213
229
|
|
|
214
230
|
def visible_tools
|
|
@@ -227,6 +243,7 @@ module OmniAgent
|
|
|
227
243
|
|
|
228
244
|
def tool_context
|
|
229
245
|
base = { mcp_principal: principal, mcp_request: request }
|
|
246
|
+
base[:mcp_access_token] = access_token if access_token
|
|
230
247
|
context_block = self.class.configured_context_block
|
|
231
248
|
return base unless context_block
|
|
232
249
|
|
|
@@ -13,6 +13,20 @@ module OmniAgent
|
|
|
13
13
|
raise NotImplementedError, "Providers must implement #chat"
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# Returns one embedding vector (Array of Floats) per text in `texts`.
|
|
17
|
+
# `model` is the embedding model; it defaults per provider, never to the
|
|
18
|
+
# chat model.
|
|
19
|
+
def embed(texts, model: nil)
|
|
20
|
+
raise NotImplementedError, "#{self.class.name.split('::').last} does not support embeddings"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Providers that return true receive an agent's output schema as the
|
|
24
|
+
# `output_schema:` keyword of #chat. Otherwise the schema is only
|
|
25
|
+
# described in the system prompt.
|
|
26
|
+
def supports_structured_output?
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
|
|
16
30
|
def validate_messages!(messages, allowed_roles:)
|
|
17
31
|
unless messages.is_a?(Array)
|
|
18
32
|
raise OmniAgent::Error, "messages must be an array"
|
|
@@ -1,20 +1,25 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "digest"
|
|
3
|
+
|
|
1
4
|
module OmniAgent
|
|
2
5
|
module Providers
|
|
3
6
|
class Mock < Base
|
|
4
7
|
LOREM_IPSUM = "Lorem ipsum dolor sit amet, consectetur adipiscing elit."
|
|
5
8
|
|
|
6
|
-
def chat(messages:, tools: [], stream: nil, **_options)
|
|
9
|
+
def chat(messages:, tools: [], stream: nil, output_schema: nil, **_options)
|
|
7
10
|
validate_messages!(messages, allowed_roles: %i[system user assistant tool])
|
|
8
11
|
|
|
12
|
+
content = output_schema ? JSON.generate(sample_output(output_schema)) : LOREM_IPSUM
|
|
13
|
+
|
|
9
14
|
if stream
|
|
10
|
-
|
|
15
|
+
content.split(" ").each_with_index do |word, index|
|
|
11
16
|
chunk = index.zero? ? word : " #{word}"
|
|
12
17
|
stream.call(OmniAgent::Streaming::Event.text(chunk))
|
|
13
18
|
end
|
|
14
19
|
end
|
|
15
20
|
|
|
16
21
|
OmniAgent::Providers::Response.new(
|
|
17
|
-
content:
|
|
22
|
+
content: content,
|
|
18
23
|
raw_request: {
|
|
19
24
|
model: model,
|
|
20
25
|
messages: messages,
|
|
@@ -24,7 +29,7 @@ module OmniAgent
|
|
|
24
29
|
"choices" => [
|
|
25
30
|
{
|
|
26
31
|
"message" => {
|
|
27
|
-
"content" =>
|
|
32
|
+
"content" => content
|
|
28
33
|
}
|
|
29
34
|
}
|
|
30
35
|
]
|
|
@@ -33,6 +38,16 @@ module OmniAgent
|
|
|
33
38
|
)
|
|
34
39
|
end
|
|
35
40
|
|
|
41
|
+
# Deterministic placeholder vectors derived from the text, so specs can
|
|
42
|
+
# index and search without calling a real embedding model.
|
|
43
|
+
def embed(texts, model: nil)
|
|
44
|
+
texts.map { |text| Digest::SHA256.digest(text.to_s).bytes.first(8).map { |byte| byte / 255.0 } }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def supports_structured_output?
|
|
48
|
+
true
|
|
49
|
+
end
|
|
50
|
+
|
|
36
51
|
protected
|
|
37
52
|
|
|
38
53
|
def default_api_key
|
|
@@ -42,6 +57,76 @@ module OmniAgent
|
|
|
42
57
|
def default_model
|
|
43
58
|
"mock"
|
|
44
59
|
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# Builds a placeholder value for every property so agents with an output
|
|
64
|
+
# schema get a parseable response without calling a real model. Raises
|
|
65
|
+
# when a field needs a value the mock can't invent (pattern, custom
|
|
66
|
+
# validate:, polymorphic resolve: true).
|
|
67
|
+
def sample_output(output_schema)
|
|
68
|
+
unresolvable = output_schema.polymorphics.find { |_name, group| group[:resolve] }
|
|
69
|
+
unsatisfiable!(unresolvable.first, "it is polymorphic with resolve: true") if unresolvable
|
|
70
|
+
|
|
71
|
+
sample = sample_object(output_schema.json_schema)
|
|
72
|
+
|
|
73
|
+
output_schema.validators.each do |name, validator|
|
|
74
|
+
passed = begin
|
|
75
|
+
validator.call(sample[name]) != false
|
|
76
|
+
rescue StandardError
|
|
77
|
+
false
|
|
78
|
+
end
|
|
79
|
+
unsatisfiable!(name, "its validate: proc rejects the placeholder #{sample[name].inspect}") unless passed
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
sample
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def sample_object(schema, path = nil)
|
|
86
|
+
(schema[:properties] || {}).to_h do |name, property|
|
|
87
|
+
[ name, sample_value(property, [ path, name ].compact.join(".")) ]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def sample_value(property, path)
|
|
92
|
+
return property[:enum].first if property[:enum]
|
|
93
|
+
|
|
94
|
+
case property[:type]
|
|
95
|
+
when "string" then sample_string(property, path)
|
|
96
|
+
when "integer" then sample_number(property, integer: true)
|
|
97
|
+
when "number" then sample_number(property).to_f
|
|
98
|
+
when "boolean" then true
|
|
99
|
+
when "array" then Array.new([ property[:minItems].to_i, 1 ].max) { sample_value(property[:items] || {}, "#{path}[]") }
|
|
100
|
+
when "object" then sample_object(property, path)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def sample_number(property, integer: false)
|
|
105
|
+
minimum = property[:minimum]
|
|
106
|
+
maximum = property[:maximum]
|
|
107
|
+
return integer ? minimum.ceil : minimum if minimum
|
|
108
|
+
return integer ? maximum.floor : maximum if maximum && maximum < 0
|
|
109
|
+
|
|
110
|
+
0
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def sample_string(property, path)
|
|
114
|
+
text = LOREM_IPSUM
|
|
115
|
+
text = text.ljust(property[:minLength], "x") if property[:minLength]
|
|
116
|
+
text = text[0, property[:maxLength]] if property[:maxLength]
|
|
117
|
+
|
|
118
|
+
if property[:pattern] && !text.match?(Regexp.new(property[:pattern]))
|
|
119
|
+
unsatisfiable!(path, "its value must match the pattern #{property[:pattern]}")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
text
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def unsatisfiable!(field, reason)
|
|
126
|
+
raise OmniAgent::Error,
|
|
127
|
+
"The mock provider can't generate structured output for `#{field}` because #{reason}. " \
|
|
128
|
+
"Stub the provider's #chat response in this test instead."
|
|
129
|
+
end
|
|
45
130
|
end
|
|
46
131
|
end
|
|
47
132
|
end
|
|
@@ -12,13 +12,14 @@ module OmniAgent
|
|
|
12
12
|
"Please add `gem 'openai'` to your Gemfile."
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
def chat(messages:, tools: [], stream: nil, **options)
|
|
15
|
+
def chat(messages:, tools: [], stream: nil, output_schema: nil, **options)
|
|
16
16
|
validate_messages!(messages, allowed_roles: %i[system user assistant tool])
|
|
17
17
|
|
|
18
18
|
messages = messages.map do |msg|
|
|
19
19
|
clean_msg = msg.reject { |_, v| v.nil? }
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
# Arrays are OpenAI content parts (text, image_url, file) and pass through.
|
|
22
|
+
if !clean_msg.key?(:tool_calls) && !clean_msg[:content].is_a?(String) && !clean_msg[:content].is_a?(Array)
|
|
22
23
|
clean_msg[:content] = ""
|
|
23
24
|
end
|
|
24
25
|
|
|
@@ -42,6 +43,7 @@ module OmniAgent
|
|
|
42
43
|
messages: messages
|
|
43
44
|
}
|
|
44
45
|
payload[:tools] = openai_tools if openai_tools.any?
|
|
46
|
+
payload[:response_format] = format_response_format(output_schema) if output_schema
|
|
45
47
|
payload.merge!(options) if options.any?
|
|
46
48
|
|
|
47
49
|
response = if stream
|
|
@@ -55,8 +57,26 @@ module OmniAgent
|
|
|
55
57
|
raise OmniAgent::Error, "Error during #{provider_label} chat: #{e.message}"
|
|
56
58
|
end
|
|
57
59
|
|
|
60
|
+
def embed(texts, model: nil)
|
|
61
|
+
response = with_retries do
|
|
62
|
+
client.embeddings.create(input: texts, model: model || default_embedding_model)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
response.data.map(&:embedding)
|
|
66
|
+
rescue => e
|
|
67
|
+
raise OmniAgent::Error, "Error during #{provider_label} embed: #{e.message}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def supports_structured_output?
|
|
71
|
+
true
|
|
72
|
+
end
|
|
73
|
+
|
|
58
74
|
protected
|
|
59
75
|
|
|
76
|
+
def default_embedding_model
|
|
77
|
+
"text-embedding-3-small"
|
|
78
|
+
end
|
|
79
|
+
|
|
60
80
|
def provider_label
|
|
61
81
|
"OpenAI"
|
|
62
82
|
end
|
|
@@ -109,6 +129,17 @@ module OmniAgent
|
|
|
109
129
|
}
|
|
110
130
|
end
|
|
111
131
|
|
|
132
|
+
def format_response_format(output_schema)
|
|
133
|
+
{
|
|
134
|
+
type: "json_schema",
|
|
135
|
+
json_schema: {
|
|
136
|
+
name: "structured_output",
|
|
137
|
+
schema: output_schema.json_schema,
|
|
138
|
+
strict: output_schema.strict_compatible?
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
|
|
112
143
|
def parse_response(raw_response, raw_request)
|
|
113
144
|
provider_raw_response = raw_response.respond_to?(:to_h) ? raw_response.to_h : raw_response
|
|
114
145
|
choices = raw_response.respond_to?(:choices) ? raw_response.choices : provider_raw_response["choices"]
|