Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 7 additions & 2 deletions docs/app/views/docs/pages/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 14 additions & 2 deletions docs/app/views/docs/pages/installation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down
41 changes: 37 additions & 4 deletions docs/app/views/docs/pages/providers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down
18 changes: 18 additions & 0 deletions lib/locallingo.rb
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
13 changes: 13 additions & 0 deletions lib/locallingo/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
71 changes: 54 additions & 17 deletions lib/locallingo/providers/ruby_llm.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,23 +31,25 @@ 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.
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
Expand All @@ -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 <provider>". Fill the
# provider's key from ENV — unless the host app already configured
# one, which always wins.
# would raise "Missing configuration for <provider>". 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
Expand Down
31 changes: 31 additions & 0 deletions lib/locallingo/settings.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion lib/locallingo/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Locallingo
VERSION = "0.2.2"
VERSION = "0.3.0"
end
32 changes: 32 additions & 0 deletions spec/locallingo/cli_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading