Skip to content
Open
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Changelog

## Unreleased

- `Providers.register(name, klass)` lets a provider live in its own gem and add
itself to the registry, the way `ruby_decision_model-providers-laya` does. `Providers`
also gained `registered?`, and `build` forwards extra keyword arguments so a
provider can take its own options.
- `Providers::Base` gained two hooks for a provider that runs a model in-process
instead of calling a service, both defaulting to what the hosted providers
already did: `requires_api_key?` (true) and `transport` (nil, meaning Client's
HTTP transport). `env_var` now defaults to nil rather than raising, for a
provider with no credential to read.
- `Client` honours both, so a local provider needs no key and answers without
the network while keeping retries, error mapping and typed answers unchanged.

## 0.1.0 - 2026-09-18

Provider-neutral release. One `Client`, two providers behind it.
Expand Down
59 changes: 58 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,68 @@ Typesafe returns an `x-typesafe-request-id` header, exposed as
`response.request_id` (nil on OpenRouter). Quote it when reporting a problem
to Typesafe.

### Writing a provider

A provider can live in its own gem. Subclass `Providers::Base`, answer the few
questions the client asks, and register the class when your gem is required:

```ruby
module RubyDecisionModel
module Providers
class Acme < Base
def name = :acme
def env_var = "ACME_API_KEY"
def default_base_url = "https://api.acme.example"
def endpoint_path = "/v1/decisions"
def default_model = "acme-1"
def aliases = { "acme" => "acme-1" }
end
end
end

RubyDecisionModel::Providers.register(:acme, RubyDecisionModel::Providers::Acme)
```

`Client.new(provider: :acme)` then works like anything in this repository, and
so do retries, error mapping and the typed answers, because a provider decides
where the request goes and how usage is read, not what happens afterwards.

A provider that runs a model in-process rather than calling a service overrides
two more:

```ruby
def requires_api_key? = false # nothing to authenticate against

def transport # same shape as Client's transport
lambda do |url:, headers:, body:|
request = JSON.parse(body)
[200, JSON.generate(answer_locally(request)), {}]
end
end
```

Return the payload the hosted APIs return, `answers` and `usage`, and the rest of
the client treats it identically. An explicit `transport:` passed to `Client`
still wins, which is how tests substitute either kind.

Name the gem after the provider, `ruby_decision_model-providers-acme`, and have people
require it directly so registration happens at load:

```ruby
gem "ruby_decision_model-providers-acme", require: "ruby_decision_model/providers/acme"
```

Known provider gems:

| Gem | Provider | Runs |
| --- | --- | --- |
| [ruby_decision_model-providers-laya](https://github.com/codenamev/ruby_decision_model-providers-laya) | `:laya` | Locally, through [ruby-laya](https://github.com/codenamev/ruby-laya) |

### Options

```ruby
RubyDecisionModel::Client.new(
provider: :typesafe, # :open_router, :typesafe, or a Providers::Base instance
provider: :typesafe, # :open_router, :typesafe, a registered name, or a Providers::Base
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
Expand Down
8 changes: 6 additions & 2 deletions lib/ruby_decision_model/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ class Client
# 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.
# A provider that runs the model itself supplies its own
# transport, and api_key: and base_url: do not apply to it.
# transport: callable(url:, headers:, body:) returning
# [status, body_string, headers_hash] (a 2-element return is
# still accepted and treated as having no headers).
Expand All @@ -35,14 +37,16 @@ def initialize(provider: nil, api_key: nil, model: nil, base_url: nil, timeout:
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?
if @provider.requires_api_key? && !@provider.api_key?
raise ConfigurationError,
"api_key is required for #{@provider.name}: pass api_key: or set #{@provider.env_var}"
end

@model = @provider.resolve_model(model)
@timeout = timeout
@transport = transport || default_transport
# A provider that runs locally brings its own; an explicit transport: still
# wins, so a test can stand in for either kind.
@transport = transport || @provider.transport || default_transport
@sleeper = sleeper
@retry_policy = RetryPolicy.from(binding.local_variable_get(:retry))
@random = random
Expand Down
46 changes: 42 additions & 4 deletions lib/ruby_decision_model/providers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,64 @@

module RubyDecisionModel
module Providers
# Providers shipped with this gem. A gem can add its own with ::register,
# which is how a provider lives outside this repository.
REGISTRY = {
open_router: OpenRouter,
typesafe: Typesafe
}.freeze
}

module_function

def names
REGISTRY.keys
end

def build(name, api_key: nil, base_url: nil)
# Teach this gem about a provider defined elsewhere.
#
# module RubyDecisionModel
# module Providers
# class Acme < Base
# def name = :acme
# def env_var = "ACME_API_KEY"
# def default_base_url = "https://api.acme.example"
# def endpoint_path = "/v1/decisions"
# def default_model = "acme-1"
# end
# end
# end
#
# RubyDecisionModel::Providers.register(:acme, RubyDecisionModel::Providers::Acme)
#
# Call it when your gem is required. After that `Client.new(provider: :acme)`
# works like any provider in this repository. Registering a name twice
# replaces it, so an application can override one deliberately.
def register(name, klass)
key = name.to_s.to_sym
raise ConfigurationError, "provider name must not be empty" if key.to_s.empty?

unless klass.is_a?(Class) && klass <= Base
raise ConfigurationError, "provider must be a Class inheriting from #{Base}, got #{klass.inspect}"
end

REGISTRY[key] = klass
key
end

def registered?(name)
REGISTRY.key?(name.to_s.to_sym)
end

def build(name, api_key: nil, base_url: nil, **options)
klass = REGISTRY[name.to_s.to_sym]
raise ConfigurationError, "unknown provider #{name.inspect}; known providers: #{names.join(', ')}" if klass.nil?

klass.new(api_key: api_key, base_url: base_url)
klass.new(api_key: api_key, base_url: base_url, **options)
end

# Order in which environment variables are consulted when no provider or
# api_key is given. Typesafe wins when both keys are set.
# api_key is given. Typesafe wins when both keys are set. Registered
# providers are not consulted: choosing one is explicit.
ENV_PRIORITY = [Typesafe, OpenRouter].freeze

# Picks a provider from the environment, or nil when no key is set.
Expand Down
20 changes: 18 additions & 2 deletions lib/ruby_decision_model/providers/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class Base
attr_reader :api_key

def initialize(api_key: nil, base_url: nil)
@api_key = api_key || ENV.fetch(env_var, nil)
@api_key = api_key || (env_var && ENV.fetch(env_var, nil))
@base_url = base_url
end

Expand All @@ -21,8 +21,24 @@ def name
raise NotImplementedError
end

# Nil for a provider with no credential to read, such as one that runs
# the model in this process.
def env_var
raise NotImplementedError
nil
end

# Whether Client should refuse to start without a key.
def requires_api_key?
true
end

# A provider that does not speak HTTP returns its own callable here, with
# the shape Client's transport takes: (url:, headers:, body:) =>
# [status, body, headers]. Nil means "use Client's HTTP transport", which
# is what a hosted provider wants. Returning one here keeps retries,
# response parsing and the typed Answers exactly as they are.
def transport
nil
end

def default_base_url
Expand Down
126 changes: 126 additions & 0 deletions test/extension_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# frozen_string_literal: true

require "test_helper"

# What a provider living in another gem can rely on: register itself, skip the
# API key when it has no service to authenticate against, and answer in-process
# instead of over HTTP. The fixture below is the whole contract, and needs no
# dependency to prove it.
class ExtensionTest < Minitest::Test
# A provider that runs "the model" in this process. A real local provider,
# such as one wrapping an on-disk model, differs only in what #answer does.
class Local < RubyDecisionModel::Providers::Base
attr_reader :calls

def initialize(api_key: nil, base_url: nil, answer: 0.5)
super(api_key: api_key, base_url: base_url)
@answer = answer
@calls = []
end

def name = :local
def requires_api_key? = false
def default_base_url = "local://memory"
def endpoint_path = ""
def default_model = "in-memory"

def transport
lambda do |url:, headers:, body:|
@calls << { url: url, headers: headers, body: body }
request = JSON.parse(body)
answers = request["questions"].keys.to_h do |id|
[id, { "type" => "noul", "noul" => @answer, "probabilities" => {} }]
end
[200, JSON.generate("model" => request["model"], "answers" => answers,
"usage" => { "input_tokens" => 7, "output_tokens" => 0 }), {}]
end
end
end

def questions
{ "urgent" => RubyDecisionModel::Questions.noul("Is this urgent?") }
end

def teardown
RubyDecisionModel::Providers::REGISTRY.delete(:local)
end

def test_a_provider_can_register_itself_and_be_named
assert_equal :local, RubyDecisionModel::Providers.register(:local, Local)
assert RubyDecisionModel::Providers.registered?(:local)
assert_includes RubyDecisionModel::Providers.names, :local
assert_instance_of Local, RubyDecisionModel::Providers.build(:local)

client = RubyDecisionModel::Client.new(provider: :local)
assert_equal :local, client.provider.name
assert_equal "in-memory", client.model
end

def test_registering_refuses_anything_that_is_not_a_provider
assert_raises(RubyDecisionModel::ConfigurationError) { RubyDecisionModel::Providers.register(:nope, String) }
assert_raises(RubyDecisionModel::ConfigurationError) { RubyDecisionModel::Providers.register(:nope, "Local") }
assert_raises(RubyDecisionModel::ConfigurationError) { RubyDecisionModel::Providers.register("", Local) }
end

def test_a_provider_with_no_credential_starts_without_one
without_provider_env do
provider = Local.new
refute_predicate provider, :requires_api_key?
assert_nil provider.env_var
assert_silent { RubyDecisionModel::Client.new(provider: provider) }
end
end

def test_a_hosted_provider_still_demands_its_key
without_provider_env do
error = assert_raises(RubyDecisionModel::ConfigurationError) do
RubyDecisionModel::Client.new(provider: :typesafe)
end
assert_match(/api_key is required for typesafe/, error.message)
end
end

def test_its_own_transport_answers_without_http
provider = Local.new(answer: 0.82)
response = RubyDecisionModel::Client.new(provider: provider).ask(state: "the site is down", questions: questions)

assert_in_delta 0.82, response["urgent"].probability, 1e-9
assert_equal 7, response.usage.input_tokens
assert_equal "in-memory", response.model
assert_equal 1, provider.calls.length, "the request went to the provider, not over the wire"
assert_equal "local://memory", provider.calls.first[:url]
end

def test_an_injected_transport_still_wins
provider = Local.new
fake = FakeTransport.new([[200, JSON.generate(
"model" => "stub",
"answers" => { "urgent" => { "type" => "noul", "noul" => 0.1, "probabilities" => {} } },
"usage" => { "input_tokens" => 1, "output_tokens" => 0 }
)]])
response = RubyDecisionModel::Client.new(provider: provider, transport: fake).ask(state: "x", questions: questions)

assert_in_delta 0.1, response["urgent"].probability, 1e-9
assert_empty provider.calls
assert_equal 1, fake.calls.length
end

def test_the_retry_policy_still_applies_to_a_local_provider
failing = Class.new(Local) do
def transport
@attempts = 0
lambda do |url:, headers:, body:| # rubocop:disable Lint/UnusedBlockArgument
@attempts += 1
@attempts == 1 ? [529, "overloaded", {}] : [200, JSON.generate(
"model" => "in-memory",
"answers" => { "urgent" => { "type" => "noul", "noul" => 0.3, "probabilities" => {} } },
"usage" => { "input_tokens" => 1, "output_tokens" => 0 }
), {}]
end
end
end

client = RubyDecisionModel::Client.new(provider: failing.new, sleeper: no_sleep)
assert_in_delta 0.3, client.ask(state: "x", questions: questions)["urgent"].probability, 1e-9
end
end