diff --git a/.env.example b/.env.example index f70f1d3..40e28b9 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,9 @@ # LLM routing: hosts + ordered backup chains (committed, no secrets inside). # When set, replaces OLLAMA_HOST / OLLAMA_API_KEY as the LLM config. # LLM_CONFIG_FILE=llm.json +# A second Ollama Cloud account is just another variable, referenced by name +# from llm.json as "api_key_env": "OLLAMA_API_KEY_2". +# OLLAMA_API_KEY_2= # ── LLM: OpenAI (optional) ─────────────────────────────────────────────────── OPENAI_API_KEY= diff --git a/.env.navi_code.example b/.env.navi_code.example index 81622b6..5f4676a 100644 --- a/.env.navi_code.example +++ b/.env.navi_code.example @@ -9,6 +9,9 @@ # LLM routing: hosts + ordered backup chains (committed, no secrets inside). # When set, replaces OLLAMA_HOST / OLLAMA_API_KEY as the LLM config. # LLM_CONFIG_FILE=llm.json +# A second Ollama Cloud account is just another variable, referenced by name +# from llm.json as "api_key_env": "OLLAMA_API_KEY_2". +# OLLAMA_API_KEY_2= # ── LLM: OpenAI (optional) ─────────────────────────────────────────────────── OPENAI_API_KEY= diff --git a/docs/config.md b/docs/config.md index 78ff0da..18ac2c5 100644 --- a/docs/config.md +++ b/docs/config.md @@ -61,10 +61,14 @@ } ``` -- **`targets`** — named hosts. `api_key_env` names an environment variable and is - read from `os.environ` **or** from the settings loaded out of `.env` (the - latter is not exported to the process environment), so the deploy branch's - `.env` does not change. A literal `api_key` is also accepted. +- **`targets`** — named hosts. `api_key_env` names an environment variable and + is read from `os.environ` **or** from the `.env` file itself (the process + environment does not receive it), so the deploy branch's `.env` does not + change. Any name works — that is how a second Ollama Cloud account joins a + route: give it its own target and its own variable, e.g. `cloud2` with + `"api_key_env": "OLLAMA_API_KEY_2"`, then list both in the chain. A literal + `api_key` is also accepted, but `llm.json` is committed: keep secrets in + `.env` and reference them by name. - **`routes`** — ordered chains; **the first entry is the primary**, the rest are backups. `context_window` caps `num_ctx` for that model and drives the compressor's window, and `options` are merged over the global Ollama options. diff --git a/navi/llm/config.py b/navi/llm/config.py index af77087..837f731 100644 --- a/navi/llm/config.py +++ b/navi/llm/config.py @@ -49,6 +49,33 @@ """``llm.json`` is present but cannot be used. Raised at startup.""" +def _env_file_value(env_name: str) -> str: + """Read one variable out of the same ``.env`` pydantic-settings uses. + + The file is read directly because the settings object only exposes the + fields it declares: a second cloud account's ``OLLAMA_API_KEY_2`` is a name + nobody mapped to a field, and it still has to resolve. + """ + from dotenv import dotenv_values + + try: + from navi.config import Settings + + configured = Settings.model_config.get("env_file", ".env") + except Exception: # settings are optional for this lookup + configured = ".env" + names = configured if isinstance(configured, (list, tuple)) else [configured] + for name in names: + # Relative to the working directory, exactly as pydantic-settings + # resolves it: if the server cannot see that file, neither can this. + path = Path(str(name)).expanduser() + if path.is_file(): + value = dotenv_values(path).get(env_name) + if value: + return value + return "" + + def _resolve_secret(env_name: str) -> str: """Look a key up by environment-variable name. @@ -56,11 +83,16 @@ the *settings object*, it does not export those values to the process environment. On production the ollama key lives in ``.env`` and nowhere else, so a plain ``os.environ.get`` would silently hand the cloud target - an empty key and every request would 401. + an empty key and every request would 401. Reading the file covers any name + — several accounts, each with its own variable — and the settings fields + stay as a last resort for tests and for a key injected as a field. """ value = os.environ.get(env_name, "") if value: return value + value = _env_file_value(env_name) + if value: + return value from navi.config import settings known = { diff --git a/tests/unit/llm/test_config.py b/tests/unit/llm/test_config.py index 461845c..423c06a 100644 --- a/tests/unit/llm/test_config.py +++ b/tests/unit/llm/test_config.py @@ -104,12 +104,56 @@ assert config.targets["cloud"].api_key == "from-env" +def test_any_variable_name_resolves_from_the_dotenv_file(tmp_path, monkeypatch): + """A second cloud account's key has no Settings field — only a name in + .env. Hard-coding the two known names left it empty and every call 401.""" + monkeypatch.delenv("OLLAMA_API_KEY_2", raising=False) + (tmp_path / ".env").write_text("OLLAMA_API_KEY_2=second-account\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + payload = { + "targets": {"cloud2": {"host": "https://ollama.com", "api_key_env": "OLLAMA_API_KEY_2"}}, + "routes": {"default": [{"model": "m", "target": "cloud2"}]}, + } + + config = load_llm_config(_write(tmp_path, payload)) + + assert config.targets["cloud2"].api_key == "second-account" + + +def test_the_environment_wins_over_the_dotenv_file(tmp_path, monkeypatch): + (tmp_path / ".env").write_text("TEST_LLM_KEY=from-file\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("TEST_LLM_KEY", "from-env") + + config = load_llm_config(_write(tmp_path, BASE)) + + assert config.targets["cloud"].api_key == "from-env" + + +def test_a_malformed_dotenv_line_does_not_stop_the_lookup(tmp_path, monkeypatch): + """Production's .env has one such line; it must not hide the keys below.""" + monkeypatch.delenv("OLLAMA_API_KEY_2", raising=False) + (tmp_path / ".env").write_text( + "ljap nonsense\nOLLAMA_API_KEY_2=still-found\n", encoding="utf-8" + ) + monkeypatch.chdir(tmp_path) + payload = { + "targets": {"cloud2": {"host": "https://ollama.com", "api_key_env": "OLLAMA_API_KEY_2"}}, + "routes": {"default": [{"model": "m", "target": "cloud2"}]}, + } + + config = load_llm_config(_write(tmp_path, payload)) + + assert config.targets["cloud2"].api_key == "still-found" + + def test_an_api_key_falls_back_to_settings_when_the_env_is_not_exported( tmp_path, monkeypatch ): """pydantic-settings reads .env into the settings object, not os.environ.""" import navi.config as config_mod + monkeypatch.chdir(tmp_path) # no .env here: the file must not answer monkeypatch.delenv("OLLAMA_API_KEY", raising=False) monkeypatch.setattr( config_mod, "settings", config_mod.Settings(_env_file=None, ollama_api_key="from-dotenv")