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

## Unreleased

- Providers read a base URL and a default model from the environment:
`TYPESAFE_BASE_URL` and `TYPESAFE_DEFAULT_MODEL`, which the official
Typesafe SDKs already define, and `OPENROUTER_BASE_URL` /
`OPENROUTER_DEFAULT_MODEL`. Only the API key was read before, so a process
configured for the Python or JavaScript SDK had to pass the other two by
hand. Explicit arguments still win, and a blank value is treated as unset.
- Providers declare these with `base_url_env` and `default_model_env`, both
nil by default.

## 0.1.0 - 2026-09-18

Provider-neutral release. One `Client`, two providers behind it.
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,21 @@ client.model # => "jev-latest" (resolved after aliasing)

Both providers send `User-Agent: ruby_decision_model/<version>`.

### Environment

Each provider reads three variables. An explicit argument always wins, a blank
value is treated as unset, and one provider never reads another's.

| | Typesafe | OpenRouter |
| --- | --- | --- |
| API key | `TYPESAFE_API_KEY` | `OPENROUTER_API_KEY` |
| Base URL | `TYPESAFE_BASE_URL` | `OPENROUTER_BASE_URL` |
| Default model | `TYPESAFE_DEFAULT_MODEL` | `OPENROUTER_DEFAULT_MODEL` |

The Typesafe names are the ones the official SDKs read, so a process already
configured for them needs nothing new. A model from the environment resolves
through the provider's aliases like any other.

### Model aliases

Each provider resolves a few friendly names to its own canonical model name.
Expand Down
30 changes: 27 additions & 3 deletions lib/ruby_decision_model/providers/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class Base

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

# Identifier used in error messages and by Client#provider.
Expand All @@ -25,6 +25,16 @@ def env_var
raise NotImplementedError
end

# Environment variables a provider reads besides its API key, or nil
# when it has none. Explicit arguments still win over both.
def base_url_env
nil
end

def default_model_env
nil
end

def default_base_url
raise NotImplementedError
end
Expand Down Expand Up @@ -59,10 +69,16 @@ def api_key?
!(api_key.nil? || api_key.to_s.strip.empty?)
end

# Nil or blank means the provider default. Known aliases resolve to the
# Nil or blank falls back to the provider's default-model environment
# variable, then to its built-in default. Known aliases resolve to the
# provider's canonical name. Anything else passes through untouched.
def resolve_model(model)
return default_model if model.nil? || model.to_s.strip.empty?
if model.nil? || model.to_s.strip.empty?
from_env = env_value(default_model_env)
return default_model if from_env.nil?

model = from_env
end

aliases.fetch(model.to_s, model.to_s)
end
Expand Down Expand Up @@ -95,6 +111,14 @@ def inspect
"#<#{self.class.name} name=#{name.inspect} base_url=#{base_url.inspect} api_key=#{api_key? ? '[REDACTED]' : 'nil'}>"
end

# A set, non-blank environment variable, or nil.
def env_value(name)
return nil if name.nil?

value = ENV.fetch(name, nil)
value.nil? || value.to_s.strip.empty? ? nil : value
end

# Applies non-nil overrides in place. Used when a caller hands Client a
# provider instance together with api_key: or base_url:.
def configure(api_key: nil, base_url: nil)
Expand Down
8 changes: 8 additions & 0 deletions lib/ruby_decision_model/providers/open_router.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ def env_var
"OPENROUTER_API_KEY"
end

def base_url_env
"OPENROUTER_BASE_URL"
end

def default_model_env
"OPENROUTER_DEFAULT_MODEL"
end

def default_base_url
"https://openrouter.ai/api/alpha"
end
Expand Down
10 changes: 10 additions & 0 deletions lib/ruby_decision_model/providers/typesafe.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ def env_var
"TYPESAFE_API_KEY"
end

# The names the official Typesafe SDKs read; see
# https://docs.typesafe.ai/sdk/python/api/constants.
def base_url_env
"TYPESAFE_BASE_URL"
end

def default_model_env
"TYPESAFE_DEFAULT_MODEL"
end

def default_base_url
"https://api.typesafe.ai"
end
Expand Down
106 changes: 106 additions & 0 deletions test/provider_env_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# frozen_string_literal: true

require "test_helper"

# The environment variables the official SDKs define, and the order an
# explicit argument, the environment, and the built-in default are consulted.
class ProviderEnvTest < Minitest::Test
ALL = %w[TYPESAFE_API_KEY TYPESAFE_BASE_URL TYPESAFE_DEFAULT_MODEL
OPENROUTER_API_KEY OPENROUTER_BASE_URL OPENROUTER_DEFAULT_MODEL].freeze

def with_clean_env(overrides = {})
with_env(ALL.to_h { |key| [key, nil] }.merge(overrides)) { yield }
end

def client(**options)
RubyDecisionModel::Client.new(**{ transport: FakeTransport.new([]) }.merge(options))
end

# --- base url ---

def test_base_url_comes_from_the_environment
with_clean_env("TYPESAFE_API_KEY" => "k", "TYPESAFE_BASE_URL" => "https://staging.typesafe.example") do
assert_equal "https://staging.typesafe.example", client(provider: :typesafe).base_url
end
end

def test_an_explicit_base_url_wins_over_the_environment
with_clean_env("TYPESAFE_API_KEY" => "k", "TYPESAFE_BASE_URL" => "https://from-env.example") do
assert_equal "https://explicit.example",
client(provider: :typesafe, base_url: "https://explicit.example").base_url
end
end

def test_the_built_in_default_is_used_when_the_variable_is_unset
with_clean_env("TYPESAFE_API_KEY" => "k") do
assert_equal "https://api.typesafe.ai", client(provider: :typesafe).base_url
end
end

def test_a_blank_variable_is_ignored
with_clean_env("TYPESAFE_API_KEY" => "k", "TYPESAFE_BASE_URL" => " ") do
assert_equal "https://api.typesafe.ai", client(provider: :typesafe).base_url
end
end

def test_open_router_reads_its_own_variable
with_clean_env("OPENROUTER_API_KEY" => "k", "OPENROUTER_BASE_URL" => "https://proxy.example") do
assert_equal "https://proxy.example", client(provider: :open_router).base_url
end
end

def test_one_providers_variable_does_not_reach_another
with_clean_env("OPENROUTER_API_KEY" => "k", "TYPESAFE_BASE_URL" => "https://typesafe-only.example") do
assert_equal "https://openrouter.ai/api/alpha", client(provider: :open_router).base_url
end
end

# --- default model ---

def test_default_model_comes_from_the_environment
with_clean_env("TYPESAFE_API_KEY" => "k", "TYPESAFE_DEFAULT_MODEL" => "jev-1.13.0") do
assert_equal "jev-1.13.0", client(provider: :typesafe).model
end
end

def test_an_explicit_model_wins_over_the_environment
with_clean_env("TYPESAFE_API_KEY" => "k", "TYPESAFE_DEFAULT_MODEL" => "jev-1.13.0") do
assert_equal "jev-preview", client(provider: :typesafe, model: "jev-preview").model
end
end

def test_a_model_from_the_environment_still_goes_through_aliases
with_clean_env("TYPESAFE_API_KEY" => "k", "TYPESAFE_DEFAULT_MODEL" => "jev") do
assert_equal "jev-latest", client(provider: :typesafe).model
end
end

def test_the_built_in_default_model_is_used_when_unset
with_clean_env("TYPESAFE_API_KEY" => "k") do
assert_equal "jev-latest", client(provider: :typesafe).model
end
end

def test_a_blank_default_model_variable_is_ignored
with_clean_env("TYPESAFE_API_KEY" => "k", "TYPESAFE_DEFAULT_MODEL" => "") do
assert_equal "jev-latest", client(provider: :typesafe).model
end
end

# --- providers that declare nothing keep the old behaviour ---

def test_a_provider_without_extra_variables_is_unaffected
bare = Class.new(RubyDecisionModel::Providers::Base) do
def name = :bare
def env_var = "BARE_API_KEY"
def default_base_url = "https://bare.example"
def endpoint_path = "/decide"
def default_model = "bare-1"
end.new(api_key: "k")

assert_nil bare.base_url_env
assert_nil bare.default_model_env
assert_equal "https://bare.example", bare.base_url
assert_equal "bare-1", bare.resolve_model(nil)
end
end