The decision-model interface for Ruby. Decision models answer typed questions
about a state with calibrated probabilities instead of generating text. This gem
talks to them through one Client with a provider behind it. OpenRouter is the
default. Typesafe (Jev), OpenAI (gpt-6-luna), Cloudflare (Clef), Perplexity
(pplx-decider), and Databricks (ai_decide) each have a native provider, and
any server that speaks the System One API (Ollama, strands-decider, the
autojev server) works through :system_one. Your questions and answers keep
one shape whichever provider serves them. No runtime dependencies beyond the
standard library.
gem "ruby_decision_model"require "ruby_decision_model"
client = RubyDecisionModel::Client.new
response = client.ask(
state: { title: "Server returns 500 on checkout", reporter: "support" },
questions: {
"urgent" => RubyDecisionModel::Questions.noul("Is this urgent?"),
"severity" => RubyDecisionModel::Questions.score(
"How severe is this issue?",
criteria: ["cosmetic", "minor", "major", "critical"]
)
}
)
response["urgent"].noul # => 0.87
response["severity"].score # => 2.4
response.usage.input_tokens # => 120Client.new with no arguments reads the environment.
RUBY_DECISION_MODEL_PROVIDER names a provider outright (openai,
cloudflare, and so on, in any case, with - or _), and an api_key: passed without provider: goes
to that provider. Without it, TYPESAFE_API_KEY selects Typesafe,
then OPENROUTER_API_KEY selects OpenRouter, then SYSTEM_ONE_BASE_URL
selects a System One server. General-purpose credentials such as
OPENAI_API_KEY or CLOUDFLARE_API_TOKEN never pick a provider on their own,
since plenty of apps hold them for other reasons. With nothing usable set,
Client.new raises ConfigurationError naming the variables it checked.
RubyDecisionModel.client memoizes one such default client; assign nil to
reset it.
Switching vendors is a configuration change:
RubyDecisionModel::Client.new(provider: :openai) # gpt-6-luna
RubyDecisionModel::Client.new(provider: :perplexity) # pplx-decider-v1.1-27b
RubyDecisionModel::Client.new(provider: :open_router, model: "clef")# ENV["OPENROUTER_API_KEY"]
client = RubyDecisionModel::Client.new(provider: :open_router)
# or pass the key directly; api_key: alone means OpenRouter
# unless RUBY_DECISION_MODEL_PROVIDER names another provider
client = RubyDecisionModel::Client.new(api_key: "sk-or-...")Requests go to https://openrouter.ai/api/alpha/decisions. The default model is
typesafe/jev-1.13. Usage reports input_tokens, output_tokens, and cost.
# ENV["TYPESAFE_API_KEY"]
client = RubyDecisionModel::Client.new(provider: :typesafe)Requests go to https://api.typesafe.ai/v1/systemone. The default model is
jev-latest. Usage reports input_tokens and output_tokens; cost is nil.
Typesafe returns an x-typesafe-request-id header, exposed as
response.request_id. Quote it when reporting a problem to Typesafe.
# ENV["OPENAI_API_KEY"]
client = RubyDecisionModel::Client.new(provider: :openai)Requests go to https://api.openai.com/v1/decisions. The API is in public beta
as of October 2026, so its wire format may still change. The default model is
gpt-6-luna. OpenAI's wire format differs from System One, so the
provider translates both ways and your questions stay the same:
statebecomesinput. A String passes through as is. Anything else is sent as JSON text, because the API takes no structured input.noulbecomes apredicate. A noul withtrue/falsecriteria becomes a booleanchoiceso the descriptions reach the model, and the answer comes back as a noul.- Choice criteria become
choicesand score criteria becomelevels. - Answers arrive as an array and are rebuilt keyed by question id, with
probability Hashes and a score
legendbuilt from your criteria.
When the model declines a question the client raises MissingAnswers with
that id in #refused; the other answers are on #answers. Usage reports
tokens with no cost. OpenAI's response carries no id today, so response.id
is nil unless OpenAI starts sending one. response.request_id reads
x-request-id.
# ENV["CLOUDFLARE_API_TOKEN"] (or CLOUDFLARE_AUTH_TOKEN) and ENV["CLOUDFLARE_ACCOUNT_ID"]
client = RubyDecisionModel::Client.new(provider: :cloudflare, model: "clef-flash")
# or configure the provider directly
provider = RubyDecisionModel::Providers::Cloudflare.new(api_key: "...", account_id: "...")
client = RubyDecisionModel::Client.new(provider: provider)Requests go to
https://api.cloudflare.com/client/v4/accounts/<account_id>/ai/run/@cf/cloudflare/<model>.
The default model is clef; clef-flash is the smaller, faster one. The body
is System One. The provider unwraps Cloudflare's {"result": ...} envelope,
and a body with "success": false raises InvalidResponse carrying
Cloudflare's error text. The account id and model end up in the URL path, so
both are checked when the client is built. The account id may hold letters,
digits, -, and _; surrounding whitespace is stripped. The model must start
with a letter or digit and may also hold ., -, and _. Anything else
raises ConfigurationError.
Usage reports tokens with no cost, and response.request_id is the cf-ray
header.
# ENV["PERPLEXITY_API_KEY"]
client = RubyDecisionModel::Client.new(provider: :perplexity)Requests go to https://api.perplexity.ai/v1/decisions. The default model is
pplx-decider-v1.1-27b; pplx-decider-v1-27b also works. The body is System
One. response.request_id reads x-request-id.
# ENV["DATABRICKS_HOST"] and ENV["DATABRICKS_TOKEN"]
client = RubyDecisionModel::Client.new(provider: :databricks)Requests go to <host>/api/2.0/ai-functions/ai-decide. ai_decide is a
Databricks beta: a workspace admin has to enable it, and its REST shape may
still change. The workspace serves one managed model, so
client.model is nil and passing model: raises ConfigurationError. The
provider reads answers from the response wrapper. Databricks reports no
usage, so every usage field is nil.
# ENV["SYSTEM_ONE_BASE_URL"], and ENV["SYSTEM_ONE_API_KEY"] if the server wants one
client = RubyDecisionModel::Client.new(provider: :system_one, base_url: "http://localhost:11434", model: "nimble")Anything that serves Typesafe's API at /v1/systemone works here: the autojev
server shipped with pplx-decider's weights, strands-decider's local server,
and hosted lookalikes. Pydantic AI's docs say Ollama 0.35 and later serves it
too, with models such as nimble and tev1. The base URL has no
/v1 suffix and needs its http:// or https:// and a host. It is checked
when the first request is built rather than in Client.new, so a base_url:
passed to the client can replace an unusable SYSTEM_ONE_BASE_URL; a bad one
raises ConfigurationError from ask. The API key and model are optional; without a key no
Authorization header is sent, and without a model the body has no model
field. The environment variable names match Pydantic AI's.
RubyDecisionModel::Client.new(
provider: :typesafe, # a name from Providers.names, or a Providers::Base instance
api_key: nil, # overrides the provider's env var
model: nil, # nil means the provider default; see aliases below
base_url: nil, # overrides the provider base URL
timeout: nil, # seconds; nil means the provider's read default and a 5s open timeout
retry: { max_retries: 2 }, # RetryPolicy or a Hash of overrides
transport: nil # see Transport
)
client.provider # => #<RubyDecisionModel::Providers::Typesafe ...>
client.model # => "jev-latest" (resolved after aliasing)Every provider sends User-Agent: ruby_decision_model/<version>.
The default read timeout is 5 seconds for Typesafe, OpenRouter, and
Cloudflare, whose models answer in well under a second. OpenAI, Perplexity,
Databricks, and System One servers default to 30, since Perplexity documents
responses of up to 23 seconds on large inputs and a local server may load the
model on the first request. Connecting gets 5 seconds either way. A number
passed as timeout: sets both, as in 0.1.0. Through OpenRouter, pass a
longer timeout: yourself for large inputs to slower models.
Each provider resolves a few friendly names to its own canonical model name,
so one short name follows a model from OpenRouter to its vendor's API and
back. OpenRouter slugs also resolve on the vendor's own provider. Anything
not listed passes through untouched, which is how you reach models without an
alias, such as liquid/d1 on OpenRouter. The model field on a response is
whatever the provider returned.
| You pass | OpenRouter sends | Native provider sends |
|---|---|---|
nil |
typesafe/jev-1.13 |
that provider's default |
"jev", "jev-latest" |
typesafe/jev-1.13 |
jev-latest (Typesafe) |
"typesafe/jev-1.13", "~typesafe/jev-latest" |
as given | jev-latest (Typesafe) |
"luna", "gpt-6-luna" |
openai/gpt-6-luna-decisions |
gpt-6-luna (OpenAI) |
"openai/gpt-6-luna-decisions", "openai/gpt-6-luna", "gpt-6-luna-decisions" |
as given | gpt-6-luna (OpenAI) |
"clef", "clef-flash" |
cloudflare/clef, cloudflare/clef-flash |
as given (Cloudflare) |
"cloudflare/clef", "cloudflare/clef-flash" |
as given | clef, clef-flash (Cloudflare) |
"@cf/cloudflare/clef", "@cf/cloudflare/clef-flash" |
as given | clef, clef-flash (Cloudflare) |
"pplx-decider" |
perplexity/pplx-decider-v1-27b |
pplx-decider-v1.1-27b (Perplexity) |
"pplx-decider-v1-27b" |
perplexity/pplx-decider-v1-27b |
as given (Perplexity) |
"perplexity/pplx-decider-v1-27b" |
as given | pplx-decider-v1-27b (Perplexity) |
| anything else | as given | as given |
OpenRouter carries pplx-decider v1 only, so "pplx-decider" means v1 there
and v1.1 on Perplexity's own API.
Subclass RubyDecisionModel::Providers::Base and define name, env_var,
default_base_url, endpoint_path, and default_model. Optional hooks:
aliases, reports_cost?, supports_images?, request_id_header,
requires_api_key?, default_timeout, error_message, and validate!. When the wire format differs from
System One, override request_body to encode and normalize_response to turn
the parsed body back into System One answers keyed by question id; the
OpenAI provider shows both directions. Override url(model) when the model
belongs in the path. Pass an instance as provider:.
Three question types, built with RubyDecisionModel::Questions:
Questions.noul("Is this spam?") # yes/no probability
Questions.choice("Which team?", criteria: { "billing" => "...", "auth" => "..." }) # up to 255 options
Questions.score("How severe?", criteria: ["cosmetic", "minor", "major"]) # 2 to 10 levelsAnswers come back typed: Answers::Noul (noul, probabilities),
Answers::Choice (choice, confidence, probabilities), and
Answers::Score (score, confidence, probabilities, legend).
response.nouls, response.choices, and response.scores return the answers
of one type keyed the same way as response.answers.
Score probabilities and legend are keyed by the wire's string level keys
("0", "1", ...), not by the criteria labels. Choice probabilities sum to
approximately 1; treat them as calibrated, not normalized. Noul
probabilities are always {"true" => p, "false" => 1 - p}. Most providers
send only the probability, and the client fills in the split.
OpenAI, Cloudflare, Perplexity, and System One servers read images. Pass them as base64 data URLs; no provider fetches a remote URL.
photo = RubyDecisionModel::Images.from_file("damage.jpg")
# or RubyDecisionModel::Images.data_url(bytes, content_type: "image/png")
client.ask(
state: "Customer says the screen arrived cracked.",
questions: { "damaged" => RubyDecisionModel::Questions.noul("Is there visible damage?") },
images: [photo]
)Each provider puts them where its API expects: input_image parts for
OpenAI, the images field for Cloudflare and System One servers, and
image_url parts inside state for Perplexity. Typesafe, OpenRouter, and
Databricks don't take images, so the client raises RequestError before
sending. Size and count limits vary by vendor and are enforced server side.
Retry behaviour follows the official Typesafe SDKs and lives in
RubyDecisionModel::RetryPolicy. Pass a policy or a Hash of overrides as
retry:.
| Option | Default | Meaning |
|---|---|---|
max_retries |
2 |
Retries after the initial attempt |
backoff_initial |
0.5 |
First backoff in seconds, doubling each retry |
backoff_max |
5.0 |
Backoff ceiling in seconds |
backoff_jitter |
0.25 |
Fraction of the backoff randomly subtracted |
http_statuses |
[408, 429] + (500..599) |
Statuses that trigger a retry |
respect_retry_after |
true |
Honor Retry-After and retry-after-ms |
max_retry_after |
60.0 |
Ceiling for a server-supplied delay |
retry_connection_errors |
true |
Retry socket and connection failures |
retry_timeouts |
true |
Retry open, read, and write timeouts |
total_timeout |
30.0 |
Budget in seconds across attempts and delays; nil disables |
When the next delay would push past total_timeout, the client stops and
raises the last error instead of sleeping. The budget governs whether another
attempt starts; an attempt already in flight still runs to its own timeout.
With the 30 second read timeout that OpenAI, Perplexity, Databricks, and
System One servers default to, an attempt that times out uses the whole
default budget, so it is not retried. Pass retry: { total_timeout: 90.0 }
if you would rather wait for a retry.
Invalid settings (a negative duration, a non-integer max_retries, a jitter
outside 0..1, a NaN budget) raise ConfigurationError when the client is built.
RubyDecisionModel::Client.new(retry: { max_retries: 4, total_timeout: 60.0 })
RubyDecisionModel::Client.new(retry: RubyDecisionModel::RetryPolicy.new(max_retries: 0))The client uses Net::HTTP by default. Inject transport: with any callable
that accepts url:, headers:, body: and returns
[status, body_string, headers_hash]. A two-element [status, body_string]
return is still accepted and treated as having no headers, which means no
Retry-After support and a nil request_id.
| Error | Meaning |
|---|---|
ConfigurationError |
No provider could be resolved, a missing key or setting (api_key, account_id, base_url), unknown provider, a model: Databricks can't take, or bad retry: value |
RequestError |
Questions hash was empty, images were not data URLs or went to a provider that doesn't read them, or state or questions could not be encoded as JSON (original error on #cause) |
TransportError (TimeoutError) |
Network or timeout failure after retries, carries #cause_error |
ApiError |
Non-2xx response, carries #status, #body, and #headers. The message ends with the vendor's own reason when the body has one |
Unauthorized |
401 |
PayloadTooLarge |
413 |
UnprocessableEntity |
422 (never retried) |
RateLimited |
429 (retried) |
Overloaded |
529 (retried) |
InvalidResponse |
Body wasn't JSON, wasn't a Hash, or an answer was malformed (including a noul outside 0..1 or an id answered twice) |
MissingAnswers |
One or more question ids came back missing, wrong-typed, or refused. Carries #missing, #refused (the subset the provider declined), and #answers |
Status: 0.2.0, API may change.
The companion gem decide builds decisions and verdicts on top of this client.
Publishing runs through RubyGems trusted publishing, so no API key is stored anywhere. To ship a version:
- Bump
lib/ruby_decision_model/version.rb. - Add the version to
CHANGELOG.md. - Run
rake smokewith whatever provider keys you have. It makes one live request per configured provider and prints the answers. CI never runs it.TARGETS=open_router:clef,open_router:lunapicks provider and model pairs, andSMOKE_IMAGE=photo.pngadds an image where supported. - Merge to
main. The Release workflow runs the suite, builds the gem withgem build --strict, checks the built gem carries every file underlib/, and pushes it. A version already on RubyGems is skipped, so the workflow is safe to re-run.
The same workflow can be started by hand from the Actions tab or with
gh workflow run release.yml.