diff --git a/CHANGELOG.md b/CHANGELOG.md index 457a675..938fc4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Added +- `Locallingo.configure { |c| c.anthropic_api_key = ... }` — gem-level provider + credentials as Strings or lazy callables, for apps whose keys don't live in + ENV (Rails credentials, app config objects, vaults). Precedence: + `Locallingo.configure` → host `RubyLLM.configure` → ENV. +- `.locallingo.rb` setup file: the CLI loads it from the project root before + dispatch, so standalone `lingo` runs can configure credentials without + booting Rails. - Initial extraction from the `bin/translate` toolchain into a standalone gem. - `lingo` CLI with subcommands: `status`, `translate`, `validate`, `quality`, `fix-quality`, `accept-edits`, `hash`, `sync`. Legacy `--flag` forms still work diff --git a/README.md b/README.md index 3e706c3..05f13dd 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,21 @@ group :development do end ``` -RubyLLM reads provider credentials from ENV (e.g. `OPENAI_API_KEY`, -`ANTHROPIC_API_KEY`). Locallingo never stores keys. +Provider credentials come from ENV (e.g. `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`) +or from Ruby, for apps whose keys live elsewhere (Rails credentials, an app +config object, a vault): + +```ruby +Locallingo.configure do |config| + config.anthropic_api_key = "sk-ant-..." # a String… + config.openai_api_key = -> { AppConf.openai_key } # …or a lazy callable +end +``` + +Put that in a `.locallingo.rb` file next to `.locallingo.yml` and the `lingo` +CLI loads it on start — no Rails boot required. A key set via +`Locallingo.configure` wins over one set through `RubyLLM.configure`, which +wins over ENV. Locallingo never stores keys itself. ## Configuration diff --git a/docs/app/views/docs/pages/configuration.rb b/docs/app/views/docs/pages/configuration.rb index edb0f84..c366f5b 100644 --- a/docs/app/views/docs/pages/configuration.rb +++ b/docs/app/views/docs/pages/configuration.rb @@ -96,8 +96,13 @@ def erb DocsUI::Callout(:warning) do plain "Never put raw API keys in " code { ".locallingo.yml" } - plain ". The LLM provider reads its own credentials from the environment " - plain "via RubyLLM — locallingo never stores keys." + plain ". Credentials belong in ENV or in a " + code { ".locallingo.rb" } + plain " setup file calling " + code { "Locallingo.configure" } + plain " — see " + a(href: "/docs/providers") { "Providers & models" } + plain "." end end end diff --git a/docs/app/views/docs/pages/installation.rb b/docs/app/views/docs/pages/installation.rb index 1a10ef5..53abc3e 100644 --- a/docs/app/views/docs/pages/installation.rb +++ b/docs/app/views/docs/pages/installation.rb @@ -49,13 +49,25 @@ def binstub def credentials DocsUI::Section("Provider credentials") do md <<~'MD' - locallingo never stores API keys. RubyLLM reads them from the environment - based on the provider you configure — for example `OPENAI_API_KEY` for + locallingo never stores API keys. The simplest setup is an environment + variable named for your provider — for example `OPENAI_API_KEY` for OpenAI or `ANTHROPIC_API_KEY` for Anthropic. MD DocsUI::Code(<<~'BASH', filename: ".env") OPENAI_API_KEY=sk-... BASH + md <<~'MD' + If your key lives somewhere other than ENV, configure the gem from Ruby + in a `.locallingo.rb` file at your app root — the CLI loads it on start, + no Rails boot required. See + [Providers & models](/docs/providers) for the full credential + resolution order. + MD + DocsUI::Code(<<~'RUBY', filename: ".locallingo.rb") + Locallingo.configure do |config| + config.anthropic_api_key = -> { AppConf.anthropic_api_key } + end + RUBY DocsUI::Callout(:note) do plain "Only translation and the optional AI quality pass need credentials. " plain "`status`, `validate`, `sync`, and the static quality checks run " diff --git a/docs/app/views/docs/pages/providers.rb b/docs/app/views/docs/pages/providers.rb index 225566f..b6dc8f3 100644 --- a/docs/app/views/docs/pages/providers.rb +++ b/docs/app/views/docs/pages/providers.rb @@ -55,10 +55,43 @@ def choosing def credentials DocsUI::Section("Credentials") do md <<~'MD' - Credentials come from the environment, per RubyLLM's own configuration — - `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, and so on. - locallingo fails fast with a clear message when the configured provider's - key is missing, before making any network call. + locallingo looks for the configured provider's API key in three places, + in order — the first non-blank key wins: + + 1. **`Locallingo.configure`** — a key set on the gem itself. + 2. **`RubyLLM.configure`** — a key the host app set on RubyLLM directly + (a Rails initializer, typically). + 3. **ENV** — `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, + and so on. + + A plain ENV var is all most setups need. When your key lives somewhere + else — Rails credentials, an app config object, a vault — configure the + gem from Ruby. Values can be Strings or callables; callables are + resolved fresh on every LLM call, never cached: + MD + DocsUI::Code(<<~'RUBY', filename: "Ruby") + Locallingo.configure do |config| + config.anthropic_api_key = Rails.application.credentials.anthropic_api_key + config.openai_api_key = -> { AppConf.openai_api_key } # resolved lazily + end + RUBY + md <<~'MD' + ### Standalone CLI runs + + `lingo` doesn't boot Rails, so an initializer never runs for it. Put the + configure call in a `.locallingo.rb` file next to `.locallingo.yml` — + the CLI loads it before dispatch: + MD + DocsUI::Code(<<~'RUBY', filename: ".locallingo.rb") + require_relative "config/app_conf" + + Locallingo.configure do |config| + config.anthropic_api_key = -> { AppConf.anthropic_api_key } + end + RUBY + md <<~'MD' + locallingo fails fast with a clear message when no source yields a key, + before making any network call. MD DocsUI::Callout(:note) do plain "Only " diff --git a/lib/locallingo.rb b/lib/locallingo.rb index 4ef7103..76cf64f 100644 --- a/lib/locallingo.rb +++ b/lib/locallingo.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative "locallingo/version" +require_relative "locallingo/settings" require_relative "locallingo/configuration" require_relative "locallingo/json_extraction" require_relative "locallingo/key_flattener" @@ -35,4 +36,21 @@ class MissingCredentialsError < Error; end def self.configuration(root_path: Dir.pwd, package: nil) Configuration.load(root_path:, package:) end + + # Code-level settings (provider credentials) — see Locallingo::Settings. + def self.settings + @settings ||= Settings.new + end + + # Configure the gem from Ruby — the credentials path for apps whose keys + # don't live in ENV (call it from a Rails initializer or a `.locallingo.rb` + # setup file next to `.locallingo.yml`; the CLI loads the latter on start). + def self.configure + yield settings + settings + end + + def self.reset_settings! + @settings = nil + end end diff --git a/lib/locallingo/cli.rb b/lib/locallingo/cli.rb index 1c1211b..8326576 100644 --- a/lib/locallingo/cli.rb +++ b/lib/locallingo/cli.rb @@ -16,6 +16,11 @@ module Locallingo class CLI CLI_NAME = "lingo" + # Optional Ruby setup file loaded before dispatch — the hook for apps to + # configure credentials (via Locallingo.configure or RubyLLM.configure) + # without booting Rails. + SETUP_FILENAME = ".locallingo.rb" + # subcommand => the legacy flag it replaces COMMANDS = { "status" => "--status", @@ -52,6 +57,7 @@ def initialize(argv) def run command = resolve_command options = parse_options! + load_setup_file config = Locallingo.configuration(root_path: Dir.pwd, package: options[:package]) dispatch(command, config, options) rescue Locallingo::Error => e @@ -61,6 +67,13 @@ def run private + # An absent file is fine; errors in the file propagate loudly. `load` (not + # require) so repeated in-process invocations re-execute it. + def load_setup_file + path = File.join(Dir.pwd, SETUP_FILENAME) + load path if File.file?(path) + end + # Determine the subcommand, translating a leading legacy flag (with a # deprecation notice) and defaulting to `status`. def resolve_command diff --git a/lib/locallingo/providers/ruby_llm.rb b/lib/locallingo/providers/ruby_llm.rb index 3be1077..57f184c 100644 --- a/lib/locallingo/providers/ruby_llm.rb +++ b/lib/locallingo/providers/ruby_llm.rb @@ -31,14 +31,14 @@ def initialize(provider:) @provider = provider.to_sym end - # True when credentials for the configured provider are present in ENV. - # Unknown providers are assumed configured (RubyLLM may source the key - # elsewhere) rather than blocking. + # True when a key for the configured provider is found in any source + # (Locallingo settings, host RubyLLM config, ENV). Unknown providers are + # assumed configured (RubyLLM may source the key elsewhere) rather than + # blocking. def credentials? - env = CREDENTIAL_ENV[provider] - return true unless env + return true unless CREDENTIAL_ENV.key?(provider) - !ENV.fetch(env, "").to_s.strip.empty? + !resolved_api_key.nil? end # Raise a precise error when the provider has no credentials. @@ -46,8 +46,10 @@ def ensure_credentials! return if credentials? raise MissingCredentialsError, - "No credentials for provider #{provider.inspect} " \ - "(expected #{CREDENTIAL_ENV[provider]} in ENV)" + "No credentials for provider #{provider.inspect}. " \ + "Set #{CREDENTIAL_ENV[provider]} in ENV, call " \ + "Locallingo.configure { |c| c.#{provider}_api_key = ... }, or add a " \ + ".locallingo.rb setup file at the project root (loaded by the CLI)." end # Send +instructions+ (system prompt) + +payload+ (user message, JSON) to @@ -70,20 +72,55 @@ def chat(model:, instructions:, payload:) # RubyLLM does not read provider API keys from ENV on its own, so a # standalone CLI run (no Rails initializer to call RubyLLM.configure) - # would raise "Missing configuration for ". Fill the - # provider's key from ENV — unless the host app already configured - # one, which always wins. + # would raise "Missing configuration for ". Push the resolved + # key into RubyLLM's config: an explicit `Locallingo.configure` key wins + # (re-resolved every chat so callables stay live), then a key the host + # app already set, then the ENV fallback. def configure_credentials! - env = CREDENTIAL_ENV[provider] - return unless env + return unless CREDENTIAL_ENV.key?(provider) - setting = "#{provider}_api_key" config = ::RubyLLM.config + setting = key_setting return unless config.respond_to?(setting) && config.respond_to?("#{setting}=") - return unless config.public_send(setting).to_s.strip.empty? - key = ENV.fetch(env, "") - config.public_send("#{setting}=", key) unless key.strip.empty? + key = settings_api_key + return if key.nil? && !presence(config.public_send(setting)).nil? + + key ||= env_api_key + config.public_send("#{setting}=", key) unless key.nil? + end + + # Key resolution across sources, in precedence order. Used by + # #credentials? as a fail-fast check before any network call. + def resolved_api_key + settings_api_key || host_configured_api_key || env_api_key + end + + def settings_api_key + Locallingo.settings.api_key_for(provider) + end + + # A key the host app set via RubyLLM.configure — only inspected when + # ruby_llm is already loaded (we never require it just to peek). + def host_configured_api_key + return nil unless defined?(::RubyLLM) + + config = ::RubyLLM.config + return nil unless config.respond_to?(key_setting) + + presence(config.public_send(key_setting)) + end + + def env_api_key + env = CREDENTIAL_ENV[provider] + env ? presence(ENV.fetch(env, "")) : nil + end + + def key_setting = "#{provider}_api_key" + + def presence(value) + key = value.to_s.strip + key.empty? ? nil : key end end end diff --git a/lib/locallingo/settings.rb b/lib/locallingo/settings.rb new file mode 100644 index 0000000..72d391b --- /dev/null +++ b/lib/locallingo/settings.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Locallingo + # Code-level gem settings, configured via `Locallingo.configure`. Distinct + # from Locallingo::Configuration, which loads the `.locallingo.yml` project + # file — Settings holds what must never live in YAML: provider credentials. + # + # Locallingo.configure do |config| + # config.anthropic_api_key = ENV.fetch("MY_KEY") # a String… + # config.openai_api_key = -> { Vault.read("openai_key") } # …or a callable + # end + # + # Callables are resolved lazily on every use (never memoized), so keys can + # come from sources that aren't ready at configure time or that rotate. + class Settings + PROVIDERS = %i[openai anthropic gemini deepseek openrouter].freeze + + attr_accessor(*PROVIDERS.map { |name| :"#{name}_api_key" }) + + # The usable key for +provider+: callables are called, results stripped, + # and blank or unknown-provider values come back as nil. + def api_key_for(provider) + return nil unless PROVIDERS.include?(provider.to_sym) + + value = public_send("#{provider}_api_key") + value = value.call if value.respond_to?(:call) + key = value.to_s.strip + key.empty? ? nil : key + end + end +end diff --git a/lib/locallingo/version.rb b/lib/locallingo/version.rb index f9ab3c0..eaf5654 100644 --- a/lib/locallingo/version.rb +++ b/lib/locallingo/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Locallingo - VERSION = "0.2.2" + VERSION = "0.3.0" end diff --git a/spec/locallingo/cli_spec.rb b/spec/locallingo/cli_spec.rb index 61e64fc..84b0a67 100644 --- a/spec/locallingo/cli_spec.rb +++ b/spec/locallingo/cli_spec.rb @@ -83,4 +83,36 @@ def run_cli(root, argv) end end end + + describe ".locallingo.rb setup file" do + it "loads it before dispatch so it can configure credentials" do + with_app(config: { "target_locales" => %w[de] }, locales:) do |root| + File.write( + File.join(root, ".locallingo.rb"), + 'Locallingo.configure { |c| c.anthropic_api_key = "from-setup" }' + ) + + _out, _err, code = run_cli(root, %w[status]) + + expect(code).to eq(0) + expect(Locallingo.settings.anthropic_api_key).to eq("from-setup") + end + end + + it "runs silently without one" do + with_app(config: { "target_locales" => %w[de] }, locales:) do |root| + _out, err, code = run_cli(root, %w[status]) + expect(code).to eq(0) + expect(err).to be_empty + end + end + + it "propagates errors from the setup file instead of swallowing them" do + with_app(config: { "target_locales" => %w[de] }, locales:) do |root| + File.write(File.join(root, ".locallingo.rb"), "NoSuchConstant.boom!") + + expect { run_cli(root, %w[status]) }.to raise_error(NameError, /NoSuchConstant/) + end + end + end end diff --git a/spec/locallingo/providers/ruby_llm_spec.rb b/spec/locallingo/providers/ruby_llm_spec.rb index 171bb16..1d7751f 100644 --- a/spec/locallingo/providers/ruby_llm_spec.rb +++ b/spec/locallingo/providers/ruby_llm_spec.rb @@ -5,6 +5,63 @@ RSpec.describe Locallingo::Providers::RubyLLM do subject(:provider) { described_class.new(provider: :anthropic) } + around do |example| + original = RubyLLM.config.anthropic_api_key + example.run + ensure + RubyLLM.config.anthropic_api_key = original + end + + describe "#credentials?" do + before do + RubyLLM.config.anthropic_api_key = nil + stub_const("ENV", ENV.to_h.except("ANTHROPIC_API_KEY")) + end + + it "is true when only Locallingo settings holds a key" do + Locallingo.configure { |c| c.anthropic_api_key = "settings-key" } + expect(provider.credentials?).to be(true) + end + + it "is true when only the host RubyLLM config holds a key" do + RubyLLM.config.anthropic_api_key = "host-key" + expect(provider.credentials?).to be(true) + end + + it "is true when only ENV holds a key" do + stub_const("ENV", ENV.to_h.merge("ANTHROPIC_API_KEY" => "env-key")) + expect(provider.credentials?).to be(true) + end + + it "is false when every source is blank" do + stub_const("ENV", ENV.to_h.merge("ANTHROPIC_API_KEY" => " ")) + Locallingo.configure { |c| c.anthropic_api_key = -> { "" } } + expect(provider.credentials?).to be(false) + end + + it "stays permissive for providers without a known credential ENV var" do + expect(described_class.new(provider: :ollama).credentials?).to be(true) + end + + it "answers from ENV without ruby_llm loaded" do + hide_const("RubyLLM") + stub_const("ENV", ENV.to_h.merge("ANTHROPIC_API_KEY" => "env-key")) + expect(provider.credentials?).to be(true) + end + end + + describe "#ensure_credentials!" do + it "raises naming every way to provide a key" do + RubyLLM.config.anthropic_api_key = nil + stub_const("ENV", ENV.to_h.except("ANTHROPIC_API_KEY")) + + expect { provider.ensure_credentials! }.to raise_error( + Locallingo::MissingCredentialsError, + /ANTHROPIC_API_KEY.*Locallingo\.configure.*\.locallingo\.rb/m + ) + end + end + describe "#chat" do let(:conversation) { double } let(:response) { double(content: '{"greeting":"hallo"}') } @@ -14,18 +71,15 @@ allow(conversation).to receive_messages(with_instructions: conversation, ask: response) end - around do |example| - original = RubyLLM.config.anthropic_api_key - example.run - ensure - RubyLLM.config.anthropic_api_key = original + def chat! + provider.chat(model: "claude-x", instructions: "translate", payload: { "greeting" => "hello" }) end it "configures the provider API key from ENV before chatting" do RubyLLM.config.anthropic_api_key = nil stub_const("ENV", ENV.to_h.merge("ANTHROPIC_API_KEY" => "env-key-123")) - result = provider.chat(model: "claude-x", instructions: "translate", payload: { "greeting" => "hello" }) + result = chat! expect(RubyLLM.config.anthropic_api_key).to eq("env-key-123") expect(result).to eq({ "greeting" => "hallo" }) @@ -35,7 +89,7 @@ RubyLLM.config.anthropic_api_key = "explicit-app-key" stub_const("ENV", ENV.to_h.merge("ANTHROPIC_API_KEY" => "env-key-123")) - provider.chat(model: "claude-x", instructions: "translate", payload: { "greeting" => "hello" }) + chat! expect(RubyLLM.config.anthropic_api_key).to eq("explicit-app-key") end @@ -44,9 +98,28 @@ RubyLLM.config.anthropic_api_key = nil stub_const("ENV", ENV.to_h.except("ANTHROPIC_API_KEY")) - provider.chat(model: "claude-x", instructions: "translate", payload: { "greeting" => "hello" }) + chat! expect(RubyLLM.config.anthropic_api_key).to be_nil end + + it "pushes a Locallingo settings key over a host-configured one" do + RubyLLM.config.anthropic_api_key = "explicit-app-key" + Locallingo.configure { |c| c.anthropic_api_key = "settings-key" } + + chat! + + expect(RubyLLM.config.anthropic_api_key).to eq("settings-key") + end + + it "resolves a callable settings key at chat time" do + RubyLLM.config.anthropic_api_key = nil + stub_const("ENV", ENV.to_h.except("ANTHROPIC_API_KEY")) + Locallingo.configure { |c| c.anthropic_api_key = -> { "lazy-key" } } + + chat! + + expect(RubyLLM.config.anthropic_api_key).to eq("lazy-key") + end end end diff --git a/spec/locallingo/settings_spec.rb b/spec/locallingo/settings_spec.rb new file mode 100644 index 0000000..19daef0 --- /dev/null +++ b/spec/locallingo/settings_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +RSpec.describe Locallingo::Settings do + subject(:settings) { described_class.new } + + it "has an accessor pair for every supported provider" do + described_class::PROVIDERS.each do |provider| + settings.public_send("#{provider}_api_key=", "key-#{provider}") + expect(settings.public_send("#{provider}_api_key")).to eq("key-#{provider}") + end + end + + it "stays in sync with the provider credential map" do + expect(described_class::PROVIDERS) + .to match_array(Locallingo::Providers::RubyLLM::CREDENTIAL_ENV.keys) + end + + describe "#api_key_for" do + it "returns the configured key, stripped" do + settings.anthropic_api_key = " sk-ant-123 " + expect(settings.api_key_for(:anthropic)).to eq("sk-ant-123") + end + + it "returns nil when no key is set" do + expect(settings.api_key_for(:anthropic)).to be_nil + end + + it "returns nil for blank keys" do + settings.anthropic_api_key = " " + expect(settings.api_key_for(:anthropic)).to be_nil + end + + it "returns nil for unknown providers" do + expect(settings.api_key_for(:ollama)).to be_nil + end + + it "resolves a callable lazily on every call" do + keys = %w[first-key second-key].each + settings.anthropic_api_key = -> { keys.next } + + expect(settings.api_key_for(:anthropic)).to eq("first-key") + expect(settings.api_key_for(:anthropic)).to eq("second-key") + end + + it "returns nil when a callable resolves to a blank value" do + settings.anthropic_api_key = -> { "" } + expect(settings.api_key_for(:anthropic)).to be_nil + end + + it "lets a raising callable propagate" do + settings.anthropic_api_key = -> { raise "vault unreachable" } + expect { settings.api_key_for(:anthropic) }.to raise_error("vault unreachable") + end + end + + describe "Locallingo.configure" do + it "yields the memoized settings and returns them" do + returned = Locallingo.configure { |c| c.anthropic_api_key = "block-key" } + + expect(returned).to be(Locallingo.settings) + expect(Locallingo.settings.anthropic_api_key).to eq("block-key") + end + + it "memoizes settings across calls and resets with reset_settings!" do + first = Locallingo.settings + expect(Locallingo.settings).to be(first) + + Locallingo.reset_settings! + expect(Locallingo.settings).not_to be(first) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 8e84629..aa19fb9 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -18,4 +18,6 @@ Kernel.srand config.seed config.include LocaleFixtures + + config.after { Locallingo.reset_settings! } end