diff --git a/CHANGELOG.md b/CHANGELOG.md index 846df76..bd70fd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 79638d6..67a4a7f 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,21 @@ client.model # => "jev-latest" (resolved after aliasing) Both providers send `User-Agent: ruby_decision_model/`. +### 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. diff --git a/lib/ruby_decision_model/providers/base.rb b/lib/ruby_decision_model/providers/base.rb index 85f7089..dfd40f5 100644 --- a/lib/ruby_decision_model/providers/base.rb +++ b/lib/ruby_decision_model/providers/base.rb @@ -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. @@ -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 @@ -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 @@ -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) diff --git a/lib/ruby_decision_model/providers/open_router.rb b/lib/ruby_decision_model/providers/open_router.rb index 89ac172..9d14bb4 100644 --- a/lib/ruby_decision_model/providers/open_router.rb +++ b/lib/ruby_decision_model/providers/open_router.rb @@ -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 diff --git a/lib/ruby_decision_model/providers/typesafe.rb b/lib/ruby_decision_model/providers/typesafe.rb index c8a1ee7..750a795 100644 --- a/lib/ruby_decision_model/providers/typesafe.rb +++ b/lib/ruby_decision_model/providers/typesafe.rb @@ -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 diff --git a/test/provider_env_test.rb b/test/provider_env_test.rb new file mode 100644 index 0000000..7e4670b --- /dev/null +++ b/test/provider_env_test.rb @@ -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