Class: Vangrail::Client

Inherits:
Object
  • Object
show all
Defined in:
lib/vangrail/client.rb,
lib/vangrail/client/turn.rb

Overview

Interop with a NeMo Guardrails server that already exists.

A model is not optional against a current release. nemoguardrails 0.23.0 declares both /v1/checks and /v1/chat/completions on the same chat-completion schema, where model is a required field, so a Client built without one gets 422 "body model Field required" from either endpoint. Verified against a running 0.23.0 on 2026-08-21.

Nothing in this gem needs one. It is here for the case where a team already runs the Python service, wants its configs to stay the source of truth, and wants Ruby to call rather than reimplement. Reach for Config#engine first: it runs the same folder in this process with nothing to deploy.

/v1/checks is the endpoint that matches what a rail actually wants, and it answers in the same three states this gem models: passed, modified, blocked. Older servers do not have it, so check falls back to a chat completion with generation switched off and reads the rail-tracking variables out of that.

Defined Under Namespace

Classes: Request, Turn

Constant Summary collapse

CONFIGS_PATH =
'/v1/rails/configs'
CHECKS_PATH =
'/v1/checks'
COMPLETIONS_PATH =
'/v1/chat/completions'
PROTOCOLS =
%i[auto nested flat].freeze
RAIL_VARS =
[Turn::INPUT_RAIL_VAR, Turn::OUTPUT_RAIL_VAR].freeze
Completion =

The 0.1.0 name. Prefer Turn in new code.

Turn

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(http: nil, base_url: nil, config_id: nil, model: nil, api_key: nil, protocol: :auto, open_timeout: HTTP::DEFAULT_OPEN_TIMEOUT, read_timeout: HTTP::DEFAULT_READ_TIMEOUT) ⇒ Client

Returns a new instance of Client.



47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# File 'lib/vangrail/client.rb', line 47

def initialize(http: nil, base_url: nil, config_id: nil, model: nil, api_key: nil,
               protocol: :auto, open_timeout: HTTP::DEFAULT_OPEN_TIMEOUT,
               read_timeout: HTTP::DEFAULT_READ_TIMEOUT)
  unless PROTOCOLS.include?(protocol)
    raise ArgumentError,
          "protocol must be one of #{PROTOCOLS.join(', ')}"
  end

  @config_id = config_id
  @model = model
  @protocol = protocol
  @checks_supported = nil
  @http = HTTP.build(http: http, base_url: base_url, api_key: api_key,
                     open_timeout: open_timeout, read_timeout: read_timeout,
                     missing: 'a Client needs a base_url or an http client')
end

Instance Attribute Details

#checks_supportedObject (readonly)

True once /v1/checks has answered, false once it has 404ed, nil until one of those happens, so a caller can report "not yet known" honestly.



42
43
44
# File 'lib/vangrail/client.rb', line 42

def checks_supported
  @checks_supported
end

#config_idObject (readonly)

Returns the value of attribute config_id.



38
39
40
# File 'lib/vangrail/client.rb', line 38

def config_id
  @config_id
end

#httpObject (readonly)

Returns the value of attribute http.



38
39
40
# File 'lib/vangrail/client.rb', line 38

def http
  @http
end

#modelObject (readonly)

Returns the value of attribute model.



38
39
40
# File 'lib/vangrail/client.rb', line 38

def model
  @model
end

#protocolObject (readonly)

Returns the value of attribute protocol.



38
39
40
# File 'lib/vangrail/client.rb', line 38

def protocol
  @protocol
end

Instance Method Details

#available?Boolean

Returns:

  • (Boolean)


74
75
76
# File 'lib/vangrail/client.rb', line 74

def available?
  http.reachable?(CONFIGS_PATH)
end

#base_urlObject



64
65
66
# File 'lib/vangrail/client.rb', line 64

def base_url
  http.base_url
end

#chat(messages:, config_id: nil, config_ids: nil, options: nil, context: nil, thread_id: nil, model: nil, **extra) ⇒ Object

A full guardrailed completion, for the case where the server generates the answer as well as checking it.



113
114
115
116
117
118
119
120
121
122
# File 'lib/vangrail/client.rb', line 113

def chat(messages:, config_id: nil, config_ids: nil, options: nil, context: nil,
         thread_id: nil, model: nil, **extra)
  request = Request.new(messages: messages, config_id: config_id, config_ids: config_ids,
                        options: options, context: context, thread_id: thread_id,
                        model: model, extra: extra)
  opts = merge_options(request.options)
  body = request.extra.merge(messages: normalize(request.messages))
  chosen = { config_id: request.config_id || @config_id, config_ids: request.config_ids }
  Turn.new(send_payload(body, chosen, opts, request.context, request.thread_id, request.model))
end

#check(messages, rail:, config_id: nil) ⇒ Object

Runs rails without generation and returns a Result.



90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/vangrail/client.rb', line 90

def check(messages, rail:, config_id: nil)
  chosen = config_id || @config_id
  if @checks_supported != false
    begin
      return from_checks(http.post_json(CHECKS_PATH, checks_payload(messages, rail, chosen)), rail)
    rescue HTTPError => e
      # 404 is the server that has no such endpoint. The rest are the server
      # that has one and will not answer the body we sent, which is what
      # nemoguardrails 0.23.0 does: its /v1/checks is a chat-completion
      # schema, so a payload without `model` comes back 422 and a GET comes
      # back 405. Either way this deployment cannot serve our checks
      # contract, and the completion path can, so falling back is the answer
      # and raising was not.
      raise unless [404, 405].include?(e.status) || schema_rejection?(e)

      @checks_supported = false
    end
  end
  from_completion(chat(messages: messages, config_id: chosen, options: check_options(rail)), rail)
end

#check_input(text, config_id: nil) ⇒ Object



78
79
80
# File 'lib/vangrail/client.rb', line 78

def check_input(text, config_id: nil)
  check([{ 'role' => 'user', 'content' => text.to_s }], rail: :input, config_id: config_id)
end

#check_output(text, user_input: nil, config_id: nil) ⇒ Object



82
83
84
85
86
87
# File 'lib/vangrail/client.rb', line 82

def check_output(text, user_input: nil, config_id: nil)
  messages = []
  messages << { 'role' => 'user', 'content' => user_input.to_s } unless user_input.to_s.strip.empty?
  messages << { 'role' => 'assistant', 'content' => text.to_s }
  check(messages, rail: :output, config_id: config_id)
end

#configsObject



68
69
70
71
72
# File 'lib/vangrail/client.rb', line 68

def configs
  body = http.get_json(CONFIGS_PATH)
  list = body.is_a?(Array) ? body : Array(body['configs'])
  list.filter_map { |entry| entry.is_a?(Hash) ? entry['id'] : entry.to_s }
end