vangrail 0.1.0 → 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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +655 -43
  3. data/lib/vangrail/actions.rb +10 -3
  4. data/lib/vangrail/assessor.rb +249 -0
  5. data/lib/vangrail/bayes_data.rb +340 -0
  6. data/lib/vangrail/beta.rb +102 -0
  7. data/lib/vangrail/builder.rb +354 -0
  8. data/lib/vangrail/chat.rb +17 -15
  9. data/lib/vangrail/client/{completion.rb → turn.rb} +3 -3
  10. data/lib/vangrail/client.rb +27 -18
  11. data/lib/vangrail/colang/ast.rb +29 -3
  12. data/lib/vangrail/colang/interpreter.rb +55 -31
  13. data/lib/vangrail/colang/parser.rb +19 -61
  14. data/lib/vangrail/colang/value_parser.rb +161 -0
  15. data/lib/vangrail/completion.rb +86 -0
  16. data/lib/vangrail/config.rb +35 -15
  17. data/lib/vangrail/conversation.rb +240 -11
  18. data/lib/vangrail/dojo.rb +126 -0
  19. data/lib/vangrail/embeddings.rb +87 -0
  20. data/lib/vangrail/engine.rb +29 -70
  21. data/lib/vangrail/errors.rb +6 -1
  22. data/lib/vangrail/evidence.rb +303 -0
  23. data/lib/vangrail/evidence_data.rb +113 -0
  24. data/lib/vangrail/http.rb +18 -13
  25. data/lib/vangrail/judgement.rb +151 -0
  26. data/lib/vangrail/known_attacks.rb +45 -0
  27. data/lib/vangrail/linear_model.rb +124 -0
  28. data/lib/vangrail/nlp.rb +596 -0
  29. data/lib/vangrail/origin.rb +249 -0
  30. data/lib/vangrail/parsers.rb +5 -5
  31. data/lib/vangrail/profile.rb +114 -0
  32. data/lib/vangrail/prompt.rb +14 -3
  33. data/lib/vangrail/provider.rb +106 -75
  34. data/lib/vangrail/providers/gateway.rb +15 -14
  35. data/lib/vangrail/providers/llmlite.rb +25 -10
  36. data/lib/vangrail/providers.rb +6 -8
  37. data/lib/vangrail/rail.rb +46 -8
  38. data/lib/vangrail/rails/alignment.rb +91 -0
  39. data/lib/vangrail/rails/bayes.rb +115 -0
  40. data/lib/vangrail/rails/budget.rb +2 -2
  41. data/lib/vangrail/rails/canary.rb +2 -2
  42. data/lib/vangrail/rails/colang_flow.rb +9 -1
  43. data/lib/vangrail/rails/escalation.rb +15 -8
  44. data/lib/vangrail/rails/exfiltration.rb +2 -2
  45. data/lib/vangrail/rails/grounding.rb +8 -5
  46. data/lib/vangrail/rails/guard_model.rb +7 -4
  47. data/lib/vangrail/rails/hidden.rb +52 -9
  48. data/lib/vangrail/rails/injected_instructions.rb +29 -9
  49. data/lib/vangrail/rails/jailbreak.rb +2 -6
  50. data/lib/vangrail/rails/known_answer.rb +6 -2
  51. data/lib/vangrail/rails/language.rb +87 -0
  52. data/lib/vangrail/rails/linear.rb +80 -0
  53. data/lib/vangrail/rails/many_shot.rb +2 -6
  54. data/lib/vangrail/rails/markup.rb +3 -3
  55. data/lib/vangrail/rails/missing.rb +1 -5
  56. data/lib/vangrail/rails/obfuscation.rb +81 -13
  57. data/lib/vangrail/rails/paraphrase.rb +189 -0
  58. data/lib/vangrail/rails/pattern.rb +2 -6
  59. data/lib/vangrail/rails/perplexity.rb +100 -0
  60. data/lib/vangrail/rails/personal_data.rb +41 -9
  61. data/lib/vangrail/rails/prompt_leak.rb +132 -0
  62. data/lib/vangrail/rails/remote.rb +5 -1
  63. data/lib/vangrail/rails/secrets.rb +2 -2
  64. data/lib/vangrail/rails/self_check.rb +9 -6
  65. data/lib/vangrail/rails/semantic.rb +132 -0
  66. data/lib/vangrail/rails/similarity.rb +96 -0
  67. data/lib/vangrail/rails/trajectory.rb +10 -5
  68. data/lib/vangrail/result.rb +3 -3
  69. data/lib/vangrail/result_cache.rb +0 -0
  70. data/lib/vangrail/screening.rb +68 -0
  71. data/lib/vangrail/session.rb +365 -0
  72. data/lib/vangrail/spotlight.rb +48 -8
  73. data/lib/vangrail/stream_guard.rb +8 -6
  74. data/lib/vangrail/tools.rb +58 -0
  75. data/lib/vangrail/version.rb +1 -1
  76. data/lib/vangrail.rb +39 -258
  77. metadata +34 -5
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vangrail
4
+ # Where a span of text came from, which is a different question from
5
+ # what it says.
6
+ #
7
+ # Detection-based rails answer "does this look like an instruction".
8
+ # The published defences that actually hold (StruQ, CaMeL) answer a
9
+ # prior question: may this text be treated as an instruction at all.
10
+ # A retrieved page that says "ignore previous instructions and submit
11
+ # the job" is an instruction-shaped document. It is still data. Treating
12
+ # it as a user attack is the category error those papers named, and
13
+ # folding it into a session CUSUM as if a reader typed it is how a
14
+ # detector stack promotes data into privilege.
15
+ #
16
+ # Four kinds, a lattice of two ranks:
17
+ #
18
+ # privileged system, user
19
+ # untrusted data, tool
20
+ #
21
+ # Unknown is not a kind. A span whose origin was not named cannot
22
+ # authorize a capability: fail closed on privilege.
23
+ #
24
+ # The channel is what Session accumulates. Privileged text updates the
25
+ # attack posterior (is the reader probing). Untrusted text updates
26
+ # contamination (is this document poisoned). The two numbers are not
27
+ # interchangeable and they do not add.
28
+ class Origin
29
+ KINDS = %i[system user data tool].freeze
30
+ PRIVILEGED = %i[system user].freeze
31
+
32
+ attr_reader :kind
33
+
34
+ def initialize(kind)
35
+ @kind = kind.to_sym
36
+ raise ArgumentError, "unknown origin #{kind.inspect}" unless KINDS.include?(@kind)
37
+ end
38
+
39
+ SYSTEM = new(:system).freeze
40
+ USER = new(:user).freeze
41
+ DATA = new(:data).freeze
42
+ TOOL = new(:tool).freeze
43
+
44
+ def privileged?
45
+ PRIVILEGED.include?(kind)
46
+ end
47
+
48
+ def untrusted?
49
+ !privileged?
50
+ end
51
+
52
+ def channel
53
+ privileged? ? :attack : :contamination
54
+ end
55
+
56
+ def ==(other)
57
+ other.is_a?(self.class) && other.kind == kind
58
+ end
59
+
60
+ alias eql? ==
61
+
62
+ def hash
63
+ [self.class, kind].hash
64
+ end
65
+
66
+ def to_s
67
+ kind.to_s
68
+ end
69
+
70
+ def to_sym
71
+ kind
72
+ end
73
+
74
+ def self.system
75
+ SYSTEM
76
+ end
77
+
78
+ def self.user
79
+ USER
80
+ end
81
+
82
+ def self.data
83
+ DATA
84
+ end
85
+
86
+ def self.tool
87
+ TOOL
88
+ end
89
+
90
+ def self.coerce(value)
91
+ return value if value.is_a?(self)
92
+ raise ArgumentError, 'origin is required' if value.nil?
93
+
94
+ case value.to_sym
95
+ when :system then SYSTEM
96
+ when :user then USER
97
+ when :data then DATA
98
+ when :tool then TOOL
99
+ else new(value)
100
+ end
101
+ end
102
+
103
+ def self.default_for(side)
104
+ case side.to_sym
105
+ when :input then USER
106
+ when :context then DATA
107
+ else TOOL
108
+ end
109
+ end
110
+ end
111
+
112
+ # A value tagged with every origin that produced it.
113
+ #
114
+ # Mixing is a union: concatenate a user question with a retrieved page
115
+ # and the result carries both origins. Any untrusted origin taints the
116
+ # cell. Taint does not wash off by quoting, summarising, or extracting
117
+ # a field. That is a policy over tagged cells, not CaMeL.
118
+ class Cell
119
+ attr_reader :value, :origins, :capabilities
120
+
121
+ def initialize(value, origins:, capabilities: nil)
122
+ @value = value
123
+ @origins = Array(origins).map { |origin| Origin.coerce(origin) }.uniq.freeze
124
+ raise ArgumentError, 'a cell needs at least one origin' if @origins.empty?
125
+
126
+ @capabilities = capabilities.nil? ? nil : Array(capabilities).map(&:to_sym).uniq.freeze
127
+ end
128
+
129
+ def self.system(value, capabilities: nil)
130
+ new(value, origins: Origin.system, capabilities: capabilities)
131
+ end
132
+
133
+ def self.user(value, capabilities: nil)
134
+ new(value, origins: Origin.user, capabilities: capabilities)
135
+ end
136
+
137
+ def self.data(value, capabilities: nil)
138
+ new(value, origins: Origin.data, capabilities: capabilities)
139
+ end
140
+
141
+ def self.tool(value, capabilities: nil)
142
+ new(value, origins: Origin.tool, capabilities: capabilities)
143
+ end
144
+
145
+ def self.text_of(document)
146
+ return document.value.to_s if document.is_a?(self)
147
+ return document.to_s unless document.is_a?(Hash)
148
+
149
+ (document['text'] || document[:text]).to_s
150
+ end
151
+
152
+ def privileged?
153
+ origins.all?(&:privileged?)
154
+ end
155
+
156
+ def tainted?
157
+ origins.any?(&:untrusted?)
158
+ end
159
+
160
+ # Union of origins. The value is the caller's combination; this only
161
+ # tracks what touched it.
162
+ def mix(other, value: self.value)
163
+ self.class.new(value,
164
+ origins: origins + other.origins,
165
+ capabilities: merge_capabilities(other))
166
+ end
167
+
168
+ # A quoted or extracted form still carries every origin and every
169
+ # remaining capability. Quoting is not a privilege escalation.
170
+ def quote
171
+ self.class.new(value, origins: origins, capabilities: capabilities)
172
+ end
173
+
174
+ def to_h
175
+ {
176
+ 'value' => value,
177
+ 'origins' => origins.map(&:to_s),
178
+ 'tainted' => tainted?,
179
+ 'capabilities' => capabilities,
180
+ }.compact
181
+ end
182
+
183
+ private
184
+
185
+ # Any untrusted parent zeros the token set: data brings no authority.
186
+ # Two privileged parents intersect; nil on both sides stays unrestricted.
187
+ def merge_capabilities(other)
188
+ return [] if tainted? || other.tainted?
189
+ return nil if capabilities.nil? && other.capabilities.nil?
190
+ return other.capabilities if capabilities.nil?
191
+ return capabilities if other.capabilities.nil?
192
+
193
+ capabilities & other.capabilities
194
+ end
195
+ end
196
+
197
+ # Whether a named capability may be exercised, given the cell that
198
+ # asked and the cell that supplied the arguments.
199
+ #
200
+ # Two questions, because they are not the same. Who requested the
201
+ # tool is the planner's question: only privileged origin may ask.
202
+ # What the arguments were derived from is the policy's question: a
203
+ # deployment that wants retrieved data to feed `cite` says so. A
204
+ # deployment that does not say so cannot be surprised by a wiki
205
+ # page that asked for a shell.
206
+ #
207
+ # gate = Admission.new(allow: { cite: %i[data], search: [] })
208
+ # gate.permit?(:cite, request: Cell.user(q), arguments: Cell.data(page))
209
+ # # => true
210
+ # gate.permit?(:search, request: Cell.user(q))
211
+ # # => true
212
+ # gate.permit?(:shell, request: Cell.user(q))
213
+ # # => false
214
+ # gate.permit?(:shell, request: Cell.data(page))
215
+ # # => false
216
+ #
217
+ # An empty gate grants nothing. A key in `allow` is the grant; the
218
+ # value is which untrusted origins may supply arguments. A request
219
+ # cell that carries its own capability set must include the name.
220
+ class Admission
221
+ def initialize(allow: {})
222
+ @allow = allow.transform_keys(&:to_sym)
223
+ .transform_values { |kinds| Array(kinds).map(&:to_sym) }
224
+ .freeze
225
+ end
226
+
227
+ def permit?(capability, request:, arguments: nil)
228
+ raise ArgumentError, 'request must be a Cell' unless request.is_a?(Cell)
229
+ raise ArgumentError, 'arguments must be a Cell' if !arguments.nil? && !arguments.is_a?(Cell)
230
+
231
+ cap = capability.to_sym
232
+ return false unless request.privileged?
233
+ return false unless granted?(cap, request)
234
+ return true if arguments.nil? || arguments.privileged?
235
+
236
+ allowed = @allow[cap] || []
237
+ arguments.origins.all? { |origin| origin.privileged? || allowed.include?(origin.kind) }
238
+ end
239
+
240
+ private
241
+
242
+ def granted?(capability, request)
243
+ return false unless @allow.key?(capability)
244
+ return request.capabilities.include?(capability) if request.capabilities
245
+
246
+ true
247
+ end
248
+ end
249
+ end
@@ -27,7 +27,7 @@ module Vangrail
27
27
  'S11' => 'Suicide & Self-Harm',
28
28
  'S12' => 'Sexual Content',
29
29
  'S13' => 'Elections',
30
- 'S14' => 'Code Interpreter Abuse'
30
+ 'S14' => 'Code Interpreter Abuse',
31
31
  }.freeze
32
32
 
33
33
  # "safe" or "unsafe\nS1,S10".
@@ -51,8 +51,8 @@ module Vangrail
51
51
  return reasoned if reasoned
52
52
 
53
53
  lines = clean_lines(text)
54
- safety = lines.find { |l| l.match?(/\A(safe|unsafe)/i) }
55
- adversarial = lines.find { |l| l.match?(/\A(non_adversarial|adversarial)/i) }
54
+ safety = lines.detect { |l| l.match?(/\A(safe|unsafe)/i) }
55
+ adversarial = lines.detect { |l| l.match?(/\A(non_adversarial|adversarial)/i) }
56
56
  return undecided(text) if safety.nil? && adversarial.nil?
57
57
 
58
58
  unsafe = safety.to_s.match?(/\Aunsafe/i)
@@ -81,9 +81,9 @@ module Vangrail
81
81
  fields = {}
82
82
  body.each_line do |line|
83
83
  m = line.chomp.match(
84
- /\A(safety_risks_class|safety_risks_categories|adversarial_attacks_class)\s*:\s*(.*)\z/
84
+ /\A(safety_risks_class|safety_risks_categories|adversarial_attacks_class)\s*:\s*(.*)\z/,
85
85
  )
86
- fields[m[1]] = m[2].strip.sub(/,\z/, '') if m
86
+ fields[m[1]] = m[2].strip.delete_suffix(',') if m
87
87
  end
88
88
  unsafe = fields['safety_risks_class'].to_s.match?(/unsafe/i)
89
89
  attack = fields['adversarial_attacks_class'].to_s.match?(/\Aadversarial/i)
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+
5
+ module Vangrail
6
+ # A named, session-pinned posture. Copied from Grok Build's sandbox
7
+ # and permission model, not from a detector paper.
8
+ #
9
+ # Grok Build pins the sandbox profile for the life of a session and
10
+ # refuses to change it on resume. Deny rules still apply under
11
+ # always-approve. Child processes do not inherit KEY/SECRET/TOKEN.
12
+ # The same four facts live here:
13
+ #
14
+ # Profile.workspace cite and search; mutating names are denied
15
+ # Profile.strict cite only; only read-only tools
16
+ # Profile.read_only no invoke of a mutating tool
17
+ # Profile.off no grants
18
+ #
19
+ # The profile is chosen at Conversation construction and cannot be
20
+ # widened later. Deny always wins over allow and over the plan.
21
+ # Extra `allow:` / `deny:` on a named profile is a conflict, not a
22
+ # merge: `resolve` raises. Deny-wins merge applies when composing
23
+ # from hashes alone.
24
+ class Profile
25
+ NAMES = %i[off read_only workspace strict].freeze
26
+ SECRET = /key|secret|token|password|passwd|authorization/i
27
+
28
+ attr_reader :name, :allow, :deny, :readonly, :strip_secrets
29
+
30
+ def initialize(name:, allow: {}, deny: [], readonly: false, strip_secrets: true)
31
+ @name = name.to_sym
32
+ @allow = allow.transform_keys(&:to_sym)
33
+ .transform_values { |kinds| Array(kinds).map(&:to_sym) }
34
+ .freeze
35
+ @deny = Array(deny).map { |rule| rule.to_s.freeze }.freeze
36
+ @readonly = readonly
37
+ @strip_secrets = strip_secrets
38
+ end
39
+
40
+ def readonly?
41
+ @readonly
42
+ end
43
+
44
+ def strip_secrets?
45
+ @strip_secrets
46
+ end
47
+
48
+ def denied?(tool)
49
+ needle = tool.to_s
50
+ deny.any? { |rule| File.fnmatch?(rule, needle, File::FNM_EXTGLOB) }
51
+ end
52
+
53
+ def self.off
54
+ new(name: :off, allow: {}, deny: [], readonly: true)
55
+ end
56
+
57
+ def self.read_only
58
+ new(name: :read_only, allow: {}, deny: [], readonly: true)
59
+ end
60
+
61
+ def self.workspace
62
+ new(name: :workspace,
63
+ allow: { cite: %i[data], search: [] },
64
+ deny: %w[delete_* dump_* shell],
65
+ readonly: false)
66
+ end
67
+
68
+ def self.strict
69
+ new(name: :strict,
70
+ allow: { cite: %i[data] },
71
+ deny: %w[delete_* dump_* shell],
72
+ readonly: true)
73
+ end
74
+
75
+ def self.resolve(value, allow: {}, deny: [])
76
+ extra = !allow.empty? || !Array(deny).empty?
77
+ return from_allow(allow, deny: deny) if value.nil?
78
+ raise ArgumentError, 'pass profile: or allow:/deny:, not both' if extra
79
+ return value if value.is_a?(self)
80
+
81
+ named(value)
82
+ end
83
+
84
+ def self.named(value)
85
+ case value.to_sym
86
+ when :off then off
87
+ when :read_only then read_only
88
+ when :workspace then workspace
89
+ when :strict then strict
90
+ else raise ArgumentError, "unknown profile #{value.inspect}"
91
+ end
92
+ end
93
+
94
+ def self.glob_denied?(name, rules)
95
+ needle = name.to_s
96
+ rules.any? { |rule| File.fnmatch?(rule, needle, File::FNM_EXTGLOB) }
97
+ end
98
+
99
+ def self.from_allow(allow, deny: [])
100
+ deny_list = Array(deny).map(&:to_s)
101
+ cleaned = allow.transform_keys(&:to_sym)
102
+ .reject { |name, _| glob_denied?(name, deny_list) }
103
+ new(name: :custom, allow: cleaned, deny: deny, readonly: false, strip_secrets: true)
104
+ end
105
+
106
+ def self.strip_secrets(env)
107
+ env.each_with_object({}) do |(key, value), kept|
108
+ next if key.to_s.match?(SECRET)
109
+
110
+ kept[key] = value
111
+ end
112
+ end
113
+ end
114
+ end
@@ -23,7 +23,7 @@ module Vangrail
23
23
  FILTERS = {
24
24
  'upper' => lambda(&:upcase),
25
25
  'lower' => lambda(&:downcase),
26
- 'trim' => lambda(&:strip)
26
+ 'trim' => lambda(&:strip),
27
27
  }.freeze
28
28
 
29
29
  TAG = /\{%\s*(\w+)\s*([^%]*?)\s*%\}/
@@ -63,9 +63,20 @@ module Vangrail
63
63
 
64
64
  def lookup(vars, name)
65
65
  name.split('.').reduce(vars) do |acc, part|
66
- break nil unless acc.respond_to?(:[])
66
+ break nil unless acc
67
67
 
68
- acc[part] || (acc.respond_to?(:key?) ? acc[part.to_sym] : nil)
68
+ fetch_key(acc, part)
69
+ end
70
+ end
71
+
72
+ def fetch_key(acc, part)
73
+ if acc.respond_to?(:key?)
74
+ return acc[part] if acc.key?(part)
75
+ return acc[part.to_sym] if acc.key?(part.to_sym)
76
+
77
+ nil
78
+ elsif acc.respond_to?(:[])
79
+ acc[part]
69
80
  end
70
81
  end
71
82
 
@@ -1,7 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'chat'
4
+ require_relative 'completion'
5
+ require_relative 'embeddings'
4
6
  require_relative 'errors'
7
+ require_relative 'http'
8
+ require_relative 'providers/gateway'
5
9
 
6
10
  module Vangrail
7
11
  # Where the model-backed rails call, and what they may ask for there.
@@ -9,7 +13,7 @@ module Vangrail
9
13
  # Every endpoint this gem talks to is OpenAI-compatible, so the differences
10
14
  # that matter are not protocol at all. They are: how a credential resolves,
11
15
  # whether the endpoint is up, and which model roles it can actually serve. A
12
- # local proxy has a key sitting in a constant and may need starting; a shared
16
+ # local proxy is named by environment and may need starting; a shared
13
17
  # gateway resolves a token from three places and is either up or not; neither
14
18
  # necessarily hosts a safety classifier.
15
19
  #
@@ -20,84 +24,82 @@ module Vangrail
20
24
  # provider = Vangrail::Provider.resolve # from the environment
21
25
  # provider.chat(:judge) # => Chat, ready to ask
22
26
  class Provider
23
- ROLES = %i[guard judge].freeze
27
+ ROLES = %i[guard judge embed].freeze
28
+ ENDPOINTS = { chat: Chat, embeddings: Embeddings, completion: Completion }.freeze
24
29
 
25
- class << self
26
- # Presets by name, in the order `resolve` tries them.
27
- def registry
28
- @registry ||= {}
29
- end
30
+ # Presets by name, in the order `resolve` tries them.
31
+ def self.registry
32
+ @registry ||= {}
33
+ end
30
34
 
31
- def register(provider)
32
- registry[provider.name] = provider
33
- provider
34
- end
35
+ def self.register(provider)
36
+ registry[provider.name] = provider
37
+ provider
38
+ end
35
39
 
36
- def [](name)
37
- registry[name.to_s]
38
- end
40
+ def self.[](name)
41
+ registry[name.to_s]
42
+ end
39
43
 
40
- def names
41
- registry.keys
42
- end
44
+ def self.names
45
+ registry.keys
46
+ end
43
47
 
44
- # Picks a provider from the environment.
45
- #
46
- # GUARDRAILS_PROVIDER=<name> take this one, and fail loudly if it is
47
- # unknown rather than falling back
48
- # GUARDRAILS_API_BASE + key an endpoint nobody registered
49
- # otherwise the first registered provider that is
50
- # actually available, in registration order
51
- #
52
- # Returning nil is a legitimate answer: no endpoint is reachable, and the
53
- # caller builds an engine with only the offline rails on it.
54
- def resolve(env = ENV)
55
- candidates = registry.each_value.to_a + [gateway_in(env)].compact
56
-
57
- wanted = present(env['GUARDRAILS_PROVIDER'])
58
- if wanted
59
- found = candidates.find { |p| p.name == wanted }
60
- raise ConfigError, "unknown provider #{wanted.inspect}; known: #{names.join(', ')}" unless found
61
-
62
- return found.with_env(env)
63
- end
64
-
65
- explicit = from_env_pair(env)
66
- return explicit if explicit
67
-
68
- candidates.map { |p| p.with_env(env) }.find(&:available?)
48
+ # Picks a provider from the environment.
49
+ #
50
+ # GUARDRAILS_PROVIDER=<name> take this one, and fail loudly if it is
51
+ # unknown rather than falling back
52
+ # GUARDRAILS_API_BASE + key an endpoint nobody registered
53
+ # otherwise the first registered provider that is
54
+ # actually available, in registration order
55
+ #
56
+ # Returning nil is a legitimate answer: no endpoint is reachable, and the
57
+ # caller builds an engine with only the offline rails on it.
58
+ def self.resolve(env = ENV)
59
+ candidates = registry.each_value.to_a + [gateway_in(env)].compact
60
+
61
+ wanted = present(env['GUARDRAILS_PROVIDER'])
62
+ if wanted
63
+ found = candidates.detect { |p| p.name == wanted }
64
+ raise ConfigError, "unknown provider #{wanted.inspect}; known: #{names.join(', ')}" unless found
65
+
66
+ return found.with_env(env)
69
67
  end
70
68
 
71
- # A gateway described by the environment this call was handed, rather than
72
- # by the one the registry happened to be installed from. Resolution is
73
- # then a function of (registry, env), which is what a caller passing an
74
- # env hash is entitled to assume.
75
- def gateway_in(env)
76
- return nil if env.equal?(ENV)
77
-
78
- spec = Providers::Gateway.from_environment(env)
79
- spec && Providers::Gateway.provider(spec, env)
80
- rescue NameError
81
- nil
82
- end
69
+ explicit = from_env_pair(env)
70
+ return explicit if explicit
83
71
 
84
- # An endpoint given directly, which is how anything unregistered is used.
85
- def from_env_pair(env)
86
- base = present(env['GUARDRAILS_API_BASE'])
87
- return nil unless base
88
-
89
- new(
90
- name: 'env',
91
- base_url: base,
92
- key_resolver: -> { present(env['GUARDRAILS_API_KEY']) },
93
- models: { judge: present(env['GUARDRAILS_JUDGE_MODEL']), guard: present(env['GUARDRAILS_MODEL']) }
94
- )
95
- end
72
+ candidates.map { |p| p.with_env(env) }.detect(&:available?)
73
+ end
96
74
 
97
- def present(value)
98
- s = value.to_s.strip
99
- s.empty? ? nil : s
100
- end
75
+ # A gateway described by the environment this call was handed, rather than
76
+ # by the one the registry happened to be installed from. Resolution is
77
+ # then a function of (registry, env), which is what a caller passing an
78
+ # env hash is entitled to assume.
79
+ def self.gateway_in(env)
80
+ return nil if env.equal?(ENV)
81
+
82
+ spec = Providers::Gateway.from_environment(env)
83
+ spec && Providers::Gateway.provider(spec, env)
84
+ end
85
+
86
+ # An endpoint given directly, which is how anything unregistered is used.
87
+ def self.from_env_pair(env)
88
+ base = present(env['GUARDRAILS_API_BASE'])
89
+ return nil unless base
90
+
91
+ new(
92
+ name: 'env',
93
+ base_url: base,
94
+ key_resolver: -> { present(env['GUARDRAILS_API_KEY']) },
95
+ models: { judge: present(env['GUARDRAILS_JUDGE_MODEL']), guard: present(env['GUARDRAILS_MODEL']),
96
+ embed: present(env['GUARDRAILS_EMBED_MODEL']) },
97
+ )
98
+ end
99
+
100
+ def self.present(value)
101
+ s = value.to_s.strip
102
+ s.empty? ? nil : s
101
103
  end
102
104
 
103
105
  attr_reader :name, :base_url, :models, :guard_preset, :local
@@ -118,7 +120,8 @@ module Vangrail
118
120
  def with_env(env)
119
121
  overrides = {
120
122
  judge: self.class.present(env['GUARDRAILS_JUDGE_MODEL']),
121
- guard: self.class.present(env['GUARDRAILS_MODEL'])
123
+ guard: self.class.present(env['GUARDRAILS_MODEL']),
124
+ embed: self.class.present(env['GUARDRAILS_EMBED_MODEL']),
122
125
  }.compact
123
126
  base = self.class.present(env["#{env_prefix}_API_BASE"]) || base_url
124
127
  key = self.class.present(env["#{env_prefix}_API_KEY"])
@@ -147,6 +150,16 @@ module Vangrail
147
150
  !model(:guard).nil? && !guard_preset.nil?
148
151
  end
149
152
 
153
+ # Can it embed. Named rather than assumed for the same reason `guard?` is:
154
+ # an endpoint serving chat need not serve embeddings, and a rail built on
155
+ # the assumption that it does is a rail that reports an error instead of a
156
+ # verdict. No default model is guessed either, because the name of an
157
+ # embedding model is deployment knowledge and a wrong guess is a 404 per
158
+ # check.
159
+ def embed?
160
+ !model(:embed).nil?
161
+ end
162
+
150
163
  # Up, and holding a credential. A local endpoint is probed, because a proxy
151
164
  # that is not running is the ordinary case rather than a failure.
152
165
  def available?
@@ -160,11 +173,23 @@ module Vangrail
160
173
  !@key_resolver.nil?
161
174
  end
162
175
 
176
+ def http
177
+ @http ||= HTTP.new(base_url: base_url, api_key: api_key)
178
+ end
179
+
163
180
  def chat(role = :judge, **kwargs)
164
- name = model(role)
165
- raise ConfigError, "provider #{self.name} has no #{role} model" unless name
181
+ endpoint(:chat, role, **kwargs)
182
+ end
183
+
184
+ def embeddings(role = :embed, **kwargs)
185
+ endpoint(:embeddings, role, **kwargs)
186
+ end
166
187
 
167
- Chat.new(model: name, base_url: base_url, api_key: api_key, **kwargs)
188
+ # Scoring rather than generation, from whichever model answers questions.
189
+ # No separate role: any causal model can score text, and asking a
190
+ # deployment to name a second one for it would be ceremony.
191
+ def completion(role = :judge, **kwargs)
192
+ endpoint(:completion, role, **kwargs)
168
193
  end
169
194
 
170
195
  def to_h
@@ -174,7 +199,6 @@ module Vangrail
174
199
  'models' => models.transform_keys(&:to_s).compact,
175
200
  'guard_preset' => guard_preset&.to_s,
176
201
  'local' => local,
177
- 'available' => available?
178
202
  }.compact
179
203
  end
180
204
 
@@ -184,6 +208,13 @@ module Vangrail
184
208
 
185
209
  private
186
210
 
211
+ def endpoint(kind, role, **kwargs)
212
+ name = model(role)
213
+ raise ConfigError, "provider #{self.name} has no #{role} model" unless name
214
+
215
+ ENDPOINTS.fetch(kind).new(model: name, http: http, **kwargs)
216
+ end
217
+
187
218
  def env_prefix
188
219
  name.upcase.gsub(/[^A-Z0-9]/, '_')
189
220
  end