Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,71 @@
# Changelog

## 0.2.0 - 2026-10-06

The decision models that shipped after Jev, behind the same `Client`.

- Added providers `:openai` (OpenAI Decisions API, `gpt-6-luna`),
`:cloudflare` (Clef and Clef-flash on Workers AI), `:perplexity`
(`pplx-decider-v1.1-27b` and `pplx-decider-v1-27b`), `:databricks`
(`ai_decide` over REST), and `:system_one` (any server speaking
`/v1/systemone`: Ollama, the autojev server, strands-decider, and others).
- The OpenAI provider translates both ways between System One and OpenAI's
wire format. Questions, answers, and `Answers::*` types are unchanged for
callers. A noul with true/false criteria goes out as a boolean choice and
comes back as a noul.
- `Client#ask` takes `images:`, an array of base64 data URLs. Each provider
places them where its API expects. Providers that don't read images raise
`RequestError` before sending. Added `RubyDecisionModel::Images.data_url`
and `.from_file`.
- Refused questions (OpenAI) raise `MissingAnswers` as before, with the new
`#refused` listing the ids the provider declined.
- Noul `probabilities` are always `{"true" => p, "false" => 1 - p}`. Jev,
Clef, pplx-decider, and most System One servers send only the probability,
so the client fills in the split. A noul with junk `probabilities` now gets
the split instead of `{}`, and a noul outside 0..1 raises
`InvalidResponse`.
- State or questions that cannot be encoded as JSON (invalid UTF-8, `NaN`,
nesting deeper than 100 levels) raise `RequestError`, with the original
error on `#cause`.
- `RUBY_DECISION_MODEL_PROVIDER` names a provider from the environment ahead
of key detection (any case; `-` reads as `_`), and
`Client.new(api_key: ...)` sends that key to the named provider rather
than OpenRouter. `SYSTEM_ONE_BASE_URL` joins detection after
`TYPESAFE_API_KEY` and `OPENROUTER_API_KEY`. Keys like `OPENAI_API_KEY` do
not select a provider by themselves.
- New aliases. On OpenRouter: `luna` and `gpt-6-luna` resolve to
`openai/gpt-6-luna-decisions`, `clef` and `clef-flash` to the
`cloudflare/` slugs, and `pplx-decider` to
`perplexity/pplx-decider-v1-27b`. Native providers accept the OpenRouter
slugs for their own models. Typesafe accepts `~typesafe/jev-latest`.
- Each provider names its own request id header: `x-request-id` on OpenAI
and Perplexity, `cf-ray` on Cloudflare, and `x-typesafe-request-id`
everywhere else, as in 0.1.0.
- `timeout:` now defaults to `nil`, meaning the provider's read timeout: 5
seconds for Typesafe, OpenRouter, and Cloudflare, 30 for OpenAI,
Perplexity, Databricks, and System One servers, with a 5 second open
timeout. Passing a number sets both, as before. The write timeout, which
bounds image uploads, follows the read timeout, and a write timeout is
retried and raised as `TimeoutError` like the other two. In 0.1.0 an
explicit `timeout: nil` meant no limit at all; it now means these
defaults. Added `Client#open_timeout`.
- `ApiError` messages end with the vendor's reason when the error body has
one, for example `api error (status 400): Invalid model 'x'.` The client
reads OpenAI, Perplexity, and OpenRouter `error.message`, Cloudflare
`errors[].message`, FastAPI `detail[].msg` (Typesafe), and Databricks
`message`. The API key is masked if a vendor echoes it, and the text is
capped at 500 characters. `#body` is unchanged.
- API keys are stripped before they go into the `Authorization` header, so
a key read from a file with its trailing newline works.
- Added `rake smoke`, an opt-in live check against every provider whose keys
are set. CI never runs it.
- Provider hooks for authors: `normalize_response`, `supports_images?`,
`request_id_header`, `requires_api_key?`, `default_timeout`,
`error_message`, `validate!`, `configured?`, and `url(model)`. Base sends
`Authorization` only when a key is present. Providers written against
0.1.0 keep working: a `url` override without the model argument is still
called without it.

## 0.1.0 - 2026-09-18

Provider-neutral release. One `Client`, two providers behind it.
Expand Down
236 changes: 203 additions & 33 deletions README.md

Large diffs are not rendered by default.

65 changes: 65 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,71 @@ end

task default: :test

# Live calls against real decision-model APIs, so it never runs in CI or as
# part of the default task. Each provider whose credentials are in the
# environment gets one request with a noul, a noul with criteria, a choice,
# and a score question, and the answers are printed for a human to judge.
#
# rake smoke every configured provider
# rake smoke TARGETS=open_router:clef,perplexity provider[:model] pairs
# rake smoke SMOKE_IMAGE=photo.png also send an image where supported
desc "Live smoke test against each configured provider (needs real keys)"
task :smoke do
$LOAD_PATH.unshift File.expand_path("lib", __dir__)
require "ruby_decision_model"

rdm = RubyDecisionModel
targets =
if ENV["TARGETS"].to_s.strip.empty?
rdm::Providers.names.filter_map { |name| [name, nil] if rdm::Providers.build(name).configured? }
else
ENV["TARGETS"].split(",").map { |pair| pair.strip.split(":", 2).then { |name, model| [name.to_sym, model] } }
end
abort "smoke: no provider configured; set a provider's keys or TARGETS=provider[:model]" if targets.empty?

state = { title: "Checkout returns 500 for every customer",
body: "Since 09:10 UTC every checkout attempt fails with a server error. Revenue is blocked.",
reporter: "support" }
questions = {
"outage" => rdm::Questions.noul("Does this ticket describe a production outage?"),
"urgent" => rdm::Questions.noul("Is this urgent?", criteria: { "true" => "Needs action within the hour",
"false" => "Can wait for normal triage" }),
"team" => rdm::Questions.choice("Which team should handle this?",
criteria: { "billing" => "Invoices, refunds, pricing",
"platform" => "Outages, errors, and infrastructure",
"design" => "Layout and copy" }),
"severity" => rdm::Questions.score("How severe is the customer impact?",
criteria: ["No impact", "Minor", "Major", "Critical"])
}
image = ENV["SMOKE_IMAGE"].to_s.empty? ? nil : rdm::Images.from_file(ENV["SMOKE_IMAGE"])

failures = 0
targets.each do |name, model|
label = model ? "#{name}:#{model}" : name.to_s
begin
client = rdm::Client.new(provider: name, model: model)
images = image && client.provider.supports_images? ? [image] : nil
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
response = client.ask(state: state, questions: questions, images: images)
elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round

puts "ok #{label} model=#{response.model.inspect} #{elapsed}ms#{images ? ' +image' : ''}"
puts format(" outage=%.3f urgent=%.3f team=%s (%.2f) severity=%.2f (%.2f)",
response["outage"].noul, response["urgent"].noul, response["team"].choice,
response["team"].confidence, response["severity"].score, response["severity"].confidence)
usage = response.usage
puts " usage in=#{usage.input_tokens.inspect} out=#{usage.output_tokens.inspect} " \
"cost=#{usage.cost.inspect} id=#{response.id.inspect} request_id=#{response.request_id.inspect}"
rescue rdm::Error => e
failures += 1
puts "FAIL #{label} #{e.class.name.split('::').last}: #{e.message}"
puts " refused: #{e.refused.join(', ')}" if e.respond_to?(:refused) && e.refused.any?
end
end

abort "smoke: #{failures} of #{targets.size} failed" if failures.positive?
end

# Release tooling, the single-gem shape of the terret repo's rake release
# tasks. The gemspec is the one source of name and version; nothing here
# hardcodes either. The release workflow drives these three tasks in order.
Expand Down
1 change: 1 addition & 0 deletions lib/ruby_decision_model.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
require_relative "ruby_decision_model/version"
require_relative "ruby_decision_model/errors"
require_relative "ruby_decision_model/questions"
require_relative "ruby_decision_model/images"
require_relative "ruby_decision_model/answers"
require_relative "ruby_decision_model/response"
require_relative "ruby_decision_model/providers"
Expand Down
134 changes: 86 additions & 48 deletions lib/ruby_decision_model/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,41 +7,48 @@

module RubyDecisionModel
class Client
REQUEST_ID_HEADER = "x-typesafe-request-id"

# Kept from 0.0.1 for callers that referenced them. They describe the
# OpenRouter provider and the default RetryPolicy; prefer those directly.
# Typesafe and OpenRouter providers and the default RetryPolicy; prefer
# those directly. Each provider now names its own request id header.
REQUEST_ID_HEADER = "x-typesafe-request-id"
DEFAULT_BASE_URL = Providers::OpenRouter.new.default_base_url
DEFAULT_MODEL = Providers::OpenRouter.new.default_model
MAX_ATTEMPTS = RetryPolicy.new.max_retries + 1
RETRYABLE_STATUSES = RetryPolicy::DEFAULT_STATUSES
RETRYABLE_EXCEPTIONS = (RetryPolicy::TIMEOUT_EXCEPTIONS + RetryPolicy::CONNECTION_EXCEPTIONS).freeze

attr_reader :provider, :model, :retry_policy, :timeout
# Connecting is quick wherever the answer is slow, so the open timeout
# stays short unless the caller sets timeout: explicitly.
DEFAULT_OPEN_TIMEOUT = 5

# provider: :open_router, :typesafe, or a Providers::Base instance. When
# nil, api_key: alone selects OpenRouter; otherwise the
# environment decides (TYPESAFE_API_KEY, then OPENROUTER_API_KEY).
attr_reader :provider, :model, :retry_policy, :timeout, :open_timeout

# provider: a name from Providers.names or a Providers::Base instance.
# When nil, RUBY_DECISION_MODEL_PROVIDER names one if set;
# otherwise api_key: alone selects OpenRouter, and with no
# api_key: the environment decides (see Providers.from_env).
# api_key: overrides the provider's env var.
# model: nil means the provider default; aliases resolve per provider.
# base_url: overrides the provider base URL.
# timeout: read timeout in seconds, and open timeout too when given;
# nil means the provider's read default (5 for Jev-speed APIs,
# 30 where the vendor documents multi-second responses) with a
# 5 second open timeout.
# transport: callable(url:, headers:, body:) returning
# [status, body_string, headers_hash] (a 2-element return is
# still accepted and treated as having no headers).
# retry: a RetryPolicy or a Hash of overrides.
# random: callable returning a Float in 0...1, used for backoff jitter.
# clock: callable returning monotonic seconds, used for total_timeout.
def initialize(provider: nil, api_key: nil, model: nil, base_url: nil, timeout: 5,
def initialize(provider: nil, api_key: nil, model: nil, base_url: nil, timeout: nil,
transport: nil, sleeper: ->(seconds) { sleep(seconds) }, retry: {},
random: -> { rand }, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
@provider = resolve_provider(provider, api_key: api_key, base_url: base_url)
unless @provider.api_key?
raise ConfigurationError,
"api_key is required for #{@provider.name}: pass api_key: or set #{@provider.env_var}"
end
@provider.validate!

@model = @provider.resolve_model(model)
@timeout = timeout
@timeout = timeout || @provider.default_timeout
@open_timeout = timeout || DEFAULT_OPEN_TIMEOUT
@transport = transport || default_transport
@sleeper = sleeper
@retry_policy = RetryPolicy.from(binding.local_variable_get(:retry))
Expand All @@ -53,18 +60,46 @@ def base_url
@provider.base_url
end

def ask(state:, questions:)
# images: data URLs (see Images) for providers that read images. Each
# provider places them where its API expects.
def ask(state:, questions:, images: nil)
raise RequestError, "questions must not be empty" if questions.nil? || questions.empty?

body = @provider.request_body(model: @model, state: state, questions: questions)
body = build_body(state, questions, images)
status, response_body, response_headers = perform_with_retry(
url: @provider.url, headers: @provider.headers, body: body
url: request_url, headers: @provider.headers, body: body
)
handle_response(status, response_body, response_headers, questions)
end

private

# Providers written against 0.1.0 may override url without the model
# argument added in 0.2.0.
def request_url
takes_model = @provider.method(:url).parameters.any? { |type, _| %i[req opt rest].include?(type) }
takes_model ? @provider.url(@model) : @provider.url
end

def build_body(state, questions, images)
images = Array(images)
return @provider.request_body(model: @model, state: state, questions: questions) if images.empty?

raise RequestError, "#{@provider.name} does not accept images" unless @provider.supports_images?

images.each do |image|
next if image.is_a?(String) && image.start_with?("data:image/")

raise RequestError, "images must be data URLs (data:image/...); see RubyDecisionModel::Images"
end

@provider.request_body(model: @model, state: state, questions: questions, images: images)
rescue JSON::GeneratorError, JSON::NestingError => e
# The generator's message can quote the offending value, so it stays
# on #cause rather than in a message that may be logged.
raise RequestError, "state or questions could not be encoded as JSON (#{e.class})"
end

def resolve_provider(provider, api_key:, base_url:)
case provider
when Providers::Base
Expand All @@ -81,7 +116,7 @@ def resolve_provider(provider, api_key:, base_url:)
"no provider configured: pass provider: or api_key:, or set one of #{Providers.env_vars.join(', ')}"
)
else
Providers.build(:open_router, api_key: api_key, base_url: base_url)
Providers.build(Providers.named_in_env || :open_router, api_key: api_key, base_url: base_url)
end
else
raise ConfigurationError, "provider must be a Symbol or a Providers::Base, got #{provider.class}"
Expand All @@ -93,8 +128,9 @@ def default_transport
uri = URI.parse(url)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = uri.scheme == "https"
http.open_timeout = @timeout
http.open_timeout = @open_timeout
http.read_timeout = @timeout
http.write_timeout = @timeout

request = Net::HTTP::Post.new(uri.request_uri)
headers.each { |k, v| request[k] = v }
Expand Down Expand Up @@ -162,25 +198,23 @@ def raise_transport_error(exception)
raise TransportError.new("transport error: #{exception.message}", cause_error: exception)
end

ERROR_CLASSES = {
401 => [Unauthorized, "unauthorized"],
413 => [PayloadTooLarge, "payload too large"],
422 => [UnprocessableEntity, "unprocessable entity"],
429 => [RateLimited, "rate limited"],
529 => [Overloaded, "overloaded"]
}.freeze
private_constant :ERROR_CLASSES

def handle_response(status, response_body, response_headers, questions)
case status
when 200..299
parse_success(response_body, response_headers, questions)
when 401
raise Unauthorized.new("unauthorized", status: status, body: response_body, headers: response_headers)
when 413
raise PayloadTooLarge.new("payload too large", status: status, body: response_body, headers: response_headers)
when 422
raise UnprocessableEntity.new("unprocessable entity", status: status, body: response_body,
headers: response_headers)
when 429
raise RateLimited.new("rate limited", status: status, body: response_body, headers: response_headers)
when 529
raise Overloaded.new("overloaded", status: status, body: response_body, headers: response_headers)
else
raise ApiError.new("api error (status #{status})", status: status, body: response_body,
headers: response_headers)
end
return parse_success(response_body, response_headers, questions) if (200..299).cover?(status)

klass, message = ERROR_CLASSES.fetch(status) { [ApiError, "api error (status #{status})"] }
detail = @provider.error_message(response_body)
message = "#{message}: #{detail}" if detail

raise klass.new(message, status: status, body: response_body, headers: response_headers)
end

def parse_success(response_body, response_headers, questions)
Expand All @@ -194,12 +228,16 @@ def parse_success(response_body, response_headers, questions)

raise InvalidResponse, "response body was not a JSON object" unless parsed.is_a?(Hash)

raw_answers = parsed["answers"]
canonical = @provider.normalize_response(parsed, questions: questions)
raise InvalidResponse, "response body was not a JSON object" unless canonical.is_a?(Hash)

raw_answers = canonical["answers"]
raw_answers = {} unless raw_answers.is_a?(Hash)

normalized = {}
malformed = []
missing = []
refused = []

questions.each do |raw_id, question|
id = raw_id.to_s
Expand All @@ -213,6 +251,7 @@ def parse_success(response_body, response_headers, questions)
malformed << id
end
else
refused << id if answer_hash.is_a?(Hash) && answer_hash["type"] == "refusal"
missing << id
end
end
Expand All @@ -225,28 +264,27 @@ def parse_success(response_body, response_headers, questions)
end

if missing.any?
raise MissingAnswers.new(
"missing or wrong-type answers for: #{missing.join(', ')}",
answers: normalized,
missing: missing
)
message = "missing or wrong-type answers for: #{missing.join(', ')}"
message += " (refused: #{refused.join(', ')})" if refused.any?
raise MissingAnswers.new(message, answers: normalized, missing: missing, refused: refused)
end

Response.new(
answers: normalized,
usage: @provider.usage(parsed),
model: parsed["model"],
id: parsed["id"],
usage: @provider.usage(canonical),
model: canonical["model"],
id: canonical["id"],
raw: parsed,
request_id: request_id_from(response_headers)
)
end

def request_id_from(headers)
return nil unless headers.is_a?(Hash)
header = @provider.request_id_header
return nil unless header && headers.is_a?(Hash)

headers.each do |key, value|
next unless key.to_s.casecmp?(REQUEST_ID_HEADER)
next unless key.to_s.casecmp?(header)

return value.is_a?(Array) ? value.first : value
end
Expand All @@ -259,7 +297,7 @@ def normalize_answer(type, hash)
case type
when "noul"
noul = hash["noul"]
raise MalformedAnswer unless noul.is_a?(Numeric)
raise MalformedAnswer unless noul.is_a?(Numeric) && noul.between?(0, 1)

Answers::Noul.new(noul: noul.to_f, probabilities: hash_or_empty(hash["probabilities"]))
when "choice"
Expand Down
Loading
Loading