irregular6612 commited on
Commit
7e1ccef
·
1 Parent(s): 6de0428

feat(web): load API keys from git-ignored .env at startup (fixes ollama:*-cloud key error)

Browse files
proteus/env_loader.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Minimal, dependency-free ``.env`` loader.
2
+
3
+ The project keeps provider API keys (``OLLAMA_API_KEY``, ``OPENAI_API_KEY``,
4
+ ``ANTHROPIC_API_KEY``, …) in a git-ignored ``.env`` at the repo root. Nothing
5
+ loads it automatically, so a freshly launched web/CLI process doesn't see those
6
+ keys unless the user exported them by hand — which is why ``ollama:…-cloud``
7
+ fails with "API key must be provided".
8
+
9
+ ``load_dotenv()`` finds the nearest ``.env`` walking up from the current working
10
+ directory and copies its ``KEY=VALUE`` lines into ``os.environ``. Real
11
+ environment variables win by default (``override=False``), so an explicitly
12
+ exported key is never clobbered. No third-party dependency (keeps the offline
13
+ test suite dependency-free).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ from pathlib import Path
20
+
21
+
22
+ def _parse(text: str) -> dict[str, str]:
23
+ out: dict[str, str] = {}
24
+ for raw in text.splitlines():
25
+ line = raw.strip()
26
+ if not line or line.startswith("#") or "=" not in line:
27
+ continue
28
+ if line.lower().startswith("export "):
29
+ line = line[len("export "):].lstrip()
30
+ key, _, val = line.partition("=")
31
+ key = key.strip()
32
+ val = val.strip()
33
+ if val[:1] in {'"', "'"} and val[-1:] == val[:1]:
34
+ val = val[1:-1] # quoted: keep verbatim (may contain '#')
35
+ else:
36
+ cut = val.find(" #") # unquoted: drop a whitespace-led inline comment
37
+ if cut != -1:
38
+ val = val[:cut].rstrip()
39
+ if key:
40
+ out[key] = val
41
+ return out
42
+
43
+
44
+ def find_dotenv(start: str | os.PathLike[str] | None = None) -> Path | None:
45
+ """Return the nearest ``.env`` walking up from *start* (default CWD)."""
46
+ base = Path(start) if start is not None else Path.cwd()
47
+ base = base.resolve()
48
+ for d in (base, *base.parents):
49
+ candidate = d / ".env"
50
+ if candidate.is_file():
51
+ return candidate
52
+ return None
53
+
54
+
55
+ def load_dotenv(
56
+ start: str | os.PathLike[str] | None = None, *, override: bool = False
57
+ ) -> str | None:
58
+ """Load the nearest ``.env`` into ``os.environ``.
59
+
60
+ Args:
61
+ start: Directory to begin the upward search (default: CWD).
62
+ override: If True, ``.env`` values replace existing env vars. Default
63
+ False — a key already in the environment is left untouched.
64
+
65
+ Returns:
66
+ The path of the ``.env`` that was loaded, or ``None`` if none was found.
67
+ """
68
+ path = find_dotenv(start)
69
+ if path is None:
70
+ return None
71
+ for key, val in _parse(path.read_text(encoding="utf-8")).items():
72
+ if override or key not in os.environ:
73
+ os.environ[key] = val
74
+ return str(path)
proteus/web/local/__main__.py CHANGED
@@ -4,6 +4,7 @@ from __future__ import annotations
4
  import argparse
5
 
6
  import proteus.game.scenarios # noqa: F401 (registers scenarios before any request)
 
7
  from proteus.web.local.server import make_server
8
 
9
 
@@ -13,6 +14,12 @@ def main(argv: list[str] | None = None) -> int:
13
  parser.add_argument("--port", type=int, default=8000)
14
  args = parser.parse_args(argv)
15
 
 
 
 
 
 
 
16
  server = make_server(args.host, args.port)
17
  host, port = server.server_address[0], server.server_address[1]
18
  print(f"AgentnessBench — open http://{host}:{port}/ (Ctrl-C to stop)")
 
4
  import argparse
5
 
6
  import proteus.game.scenarios # noqa: F401 (registers scenarios before any request)
7
+ from proteus.env_loader import load_dotenv
8
  from proteus.web.local.server import make_server
9
 
10
 
 
14
  parser.add_argument("--port", type=int, default=8000)
15
  args = parser.parse_args(argv)
16
 
17
+ # Load provider API keys from the repo's git-ignored .env so cloud models
18
+ # (ollama:*-cloud, openai, anthropic, …) work without a manual `export`.
19
+ loaded = load_dotenv()
20
+ if loaded:
21
+ print(f"loaded API keys from {loaded}")
22
+
23
  server = make_server(args.host, args.port)
24
  host, port = server.server_address[0], server.server_address[1]
25
  print(f"AgentnessBench — open http://{host}:{port}/ (Ctrl-C to stop)")
tests/test_env_loader.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """env_loader: load the nearest .env without clobbering real env vars."""
2
+ import os
3
+
4
+ from proteus.env_loader import find_dotenv, load_dotenv
5
+
6
+
7
+ def test_loads_keys_without_overriding_existing(tmp_path, monkeypatch):
8
+ (tmp_path / ".env").write_text(
9
+ "# provider keys\n"
10
+ "OLLAMA_API_KEY=from_dotenv\n"
11
+ 'OPENAI_API_KEY="quoted_value"\n'
12
+ "export ANTHROPIC_API_KEY=exported_style # inline note\n"
13
+ "ALREADY_SET=should_not_win\n"
14
+ )
15
+ monkeypatch.setenv("ALREADY_SET", "real_env") # real env must win
16
+ for k in ("OLLAMA_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"):
17
+ monkeypatch.delenv(k, raising=False) # ensure .env values can land
18
+
19
+ loaded = load_dotenv(start=tmp_path)
20
+ assert loaded == str(tmp_path / ".env")
21
+ assert os.environ["OLLAMA_API_KEY"] == "from_dotenv"
22
+ assert os.environ["OPENAI_API_KEY"] == "quoted_value" # quotes stripped
23
+ assert os.environ["ANTHROPIC_API_KEY"] == "exported_style" # export + inline comment handled
24
+ assert os.environ["ALREADY_SET"] == "real_env" # not overridden
25
+
26
+
27
+ def test_finds_dotenv_walking_up(tmp_path):
28
+ (tmp_path / ".env").write_text("K=v\n")
29
+ deep = tmp_path / "a" / "b" / "c"
30
+ deep.mkdir(parents=True)
31
+ assert find_dotenv(start=deep) == tmp_path / ".env" # walks up to the root .env
32
+
33
+
34
+ def test_returns_none_when_absent(tmp_path):
35
+ assert load_dotenv(start=tmp_path) is None