From f6824c0bdc64c1cb0e196352a3a653c7a7364e4b Mon Sep 17 00:00:00 2001 From: Valentino Stoll Date: Wed, 23 Sep 2026 12:40:44 -0400 Subject: [PATCH] Let a provider live in its own gem Both providers here are HTTP clients with a key, and the registry is closed, so a provider that runs a model in-process, or one that simply ships separately, cannot exist. This is the seam for both, modelled on what RubyLLM does with Provider.register and its provider gems. Providers.register(name, klass) adds a provider from anywhere, and build forwards extra keyword arguments so a provider can take its own options. Base gains requires_api_key? and transport, each defaulting to what the hosted providers already did, and env_var defaults to nil for a provider with no credential. Client honours both. Nothing about OpenRouter or Typesafe changes, and the gem still has no runtime dependencies: the new test fixture is a provider that answers from memory, which is the whole contract a gem outside this repository has to meet. --- CHANGELOG.md | 14 +++ README.md | 59 +++++++++- lib/ruby_decision_model/client.rb | 8 +- lib/ruby_decision_model/providers.rb | 46 +++++++- lib/ruby_decision_model/providers/base.rb | 20 +++- test/extension_test.rb | 126 ++++++++++++++++++++++ 6 files changed, 264 insertions(+), 9 deletions(-) create mode 100644 test/extension_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 846df76..5b1b7c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 79638d6..2e8e622 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/ruby_decision_model/client.rb b/lib/ruby_decision_model/client.rb index 3457726..be90a13 100644 --- a/lib/ruby_decision_model/client.rb +++ b/lib/ruby_decision_model/client.rb @@ -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). @@ -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 diff --git a/lib/ruby_decision_model/providers.rb b/lib/ruby_decision_model/providers.rb index 05a9eae..4478775 100644 --- a/lib/ruby_decision_model/providers.rb +++ b/lib/ruby_decision_model/providers.rb @@ -6,10 +6,12 @@ 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 @@ -17,15 +19,51 @@ 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. diff --git a/lib/ruby_decision_model/providers/base.rb b/lib/ruby_decision_model/providers/base.rb index 85f7089..2dc12e7 100644 --- a/lib/ruby_decision_model/providers/base.rb +++ b/lib/ruby_decision_model/providers/base.rb @@ -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 @@ -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 diff --git a/test/extension_test.rb b/test/extension_test.rb new file mode 100644 index 0000000..104e5c3 --- /dev/null +++ b/test/extension_test.rb @@ -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