diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fb5733c --- /dev/null +++ b/Makefile @@ -0,0 +1,46 @@ +# ────────────────────────────────────────────────────────────────────────────── +# Agentic Discovery Platform — one-command setup & run (Mac / Linux). +# +# make # list targets +# make setup # Python env (v1/) + explorer deps +# make run # OFFLINE demo: golden replay → built explorer → opens it (no key, no cost) +# +# This is a thin wrapper over the cross-platform runner `tasks.py` — ONE source of +# truth, so `make ` (here) and `python tasks.py ` (Windows) do the same thing. +# Windows users: run `python tasks.py setup` / `python tasks.py run` directly (make +# isn't native on Windows). See the README. +# ────────────────────────────────────────────────────────────────────────────── + +SHELL := /bin/bash + +# Repo root = this Makefile's dir, resolved via the shell so a path with spaces +# (".../Agentic Discovery Platform") survives. Pick python3, else python. +ROOT := $(shell cd "$(dir $(lastword $(MAKEFILE_LIST)))" && pwd) +PY := $(shell command -v python3 || command -v python) + +# Domain for the convenience targets (override: `make run DOMAIN=p2p`). +DOMAIN ?= o2c + +# Every target just forwards to tasks.py with the chosen domain. The runner handles +# the uv/npm invocations, the stale-VIRTUAL_ENV fix, prereq checks, and browser-open. +TASK = "$(PY)" "$(ROOT)/tasks.py" + +.DEFAULT_GOAL := help +.PHONY: help setup run live console report ui open doctor test clean distclean + +help: + @if [ -z "$(PY)" ]; then \ + echo "✗ No Python found on PATH (need python3 or python). Install Python 3.11+."; exit 1; fi + @$(TASK) help + +setup: ; @$(TASK) setup --domain $(DOMAIN) +run: ; @$(TASK) run --domain $(DOMAIN) +live: ; @$(TASK) live --domain $(DOMAIN) +console: ; @$(TASK) console --domain $(DOMAIN) +report: ; @$(TASK) report --domain $(DOMAIN) +ui: ; @$(TASK) ui --domain $(DOMAIN) +open: ; @$(TASK) open --domain $(DOMAIN) +doctor: ; @$(TASK) doctor --domain $(DOMAIN) +test: ; @$(TASK) test --domain $(DOMAIN) +clean: ; @$(TASK) clean --domain $(DOMAIN) +distclean: ; @$(TASK) distclean --domain $(DOMAIN) diff --git a/README.md b/README.md index 8102360..3e0facf 100644 --- a/README.md +++ b/README.md @@ -92,15 +92,54 @@ Full rationale: [`v1/docs/`](v1/docs/) · competitive positioning: [`research/`] ## Quickstart +First time on this repo? Two commands from the root — **no API key, no cost**. + +**Prerequisites:** [`uv`](https://docs.astral.sh/uv/getting-started/installation/) (Python env manager) +and [Node.js 20+](https://nodejs.org) (`npm`). The setup step tells you if either is missing. + +**macOS / Linux:** +```bash +make setup # Python env (v1/) + explorer deps +make run # OFFLINE demo: golden replay → builds the explorer → opens the report suite +``` + +**Windows** (`make` isn't native — use the cross-platform runner; identical behaviour): +```powershell +python tasks.py setup +python tasks.py run +``` + +`run` is a deterministic **golden replay** (the saved run, rendered offline), so it needs no +credentials and can't hit the "no cached response" wall. Run `make` (or `python tasks.py`) with no +target to list every task. + +Want a **live** run (real agent, spends credits)? Add a key first: + +```bash +# put ANTHROPIC_API_KEY=… (or the AZURE_OPENAI_* vars) in v1/.env — setup seeds it from the template +make doctor # (or: python tasks.py doctor) — verify the provider is reachable (one tiny call) +make live # the real pipeline — minutes, costs credits +make console # OR: the interactive 6-stage Console (backend + UI dev server) +``` + +> A live run **preflights your credentials**: if `v1/.env` has no real key (or still has the +> `.env.example` placeholder), it stops immediately with a one-line fix instead of a traceback or a +> misleading "use the golden run" message. With no key, use `make run` for the full offline demo. + +Run on any domain by dropping its documents in `v1/inputs//`, then +`make run DOMAIN=` (or `python tasks.py run --domain `). + +
Manual equivalent (no make / no tasks.py) + ```bash cd v1 uv sync # env + deps -cp .env.example .env # add ANTHROPIC_API_KEY (or Azure vars) -uv run python scripts/doctor.py # check connectivity -uv run python run.py --domain o2c --auto-resolve # → opens out/o2c/index.html +cp .env.example .env # add ANTHROPIC_API_KEY (or Azure vars) for live runs +uv run python run.py --domain o2c --golden --auto-resolve # offline demo → opens out/o2c/index.html +uv run python scripts/doctor.py # (live only) check connectivity +uv run python run.py --domain o2c --auto-resolve # live run (needs a key) ``` - -Run on any domain by dropping its documents in `v1/inputs//`. +
## Develop diff --git a/tasks.py b/tasks.py new file mode 100644 index 0000000..aad34bb --- /dev/null +++ b/tasks.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""Cross-platform setup & run for the Agentic Discovery Platform (Mac / Linux / Windows). + +`make` is not native on Windows, so this is the portable entrypoint — the Makefile just delegates +here, keeping ONE source of truth. Pure standard library, no third-party deps. + + python tasks.py # list the tasks (same as `help`) + python tasks.py setup # Python env (v1/, via uv) + explorer deps (npm) + python tasks.py run # OFFLINE demo: golden replay -> build UI -> open it (no key, no cost) + python tasks.py live # LIVE run: real agent pipeline (needs credentials; spends credits) + python tasks.py console # interactive 6-stage Console (backend + UI dev server) + python tasks.py report|ui|open|doctor|test|clean|distclean + + python tasks.py run --domain p2p # any task takes --domain (default: o2c) + +Why this exists: a first-timer hit two setup traps — a stale VIRTUAL_ENV confusing `uv`, and a +keyless live run failing with a misleading message. This drives uv with the stray VIRTUAL_ENV +stripped and pins it to v1/, and the default `run` is an offline golden replay that needs no key. +""" +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +import webbrowser +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +V1 = ROOT / "v1" +UI = ROOT / "explorer" + + +# ── helpers ──────────────────────────────────────────────────────────────────── +def _have(tool: str) -> bool: + return shutil.which(tool) is not None + + +def _require(tool: str, hint: str) -> None: + if not _have(tool): + sys.exit(f"✗ '{tool}' is not installed.\n {hint}") + + +def _uv_env() -> dict: + """Env for uv calls: drop a stray VIRTUAL_ENV so uv always targets v1/.venv (not whatever venv + the operator happens to have activated — the exact 'VIRTUAL_ENV does not match .venv' trap).""" + env = os.environ.copy() + env.pop("VIRTUAL_ENV", None) + return env + + +def _run(cmd: list[str], cwd: Path, env: dict | None = None) -> None: + """Run a command (arg list — handles spaces in paths on every OS) and fail loudly on error.""" + print(f"→ {' '.join(cmd)} (in {cwd})") + proc = subprocess.run(cmd, cwd=str(cwd), env=env) + if proc.returncode != 0: + sys.exit(proc.returncode) + + +def _uv(args: list[str], cwd: Path = V1) -> None: + _require("uv", "Install it: curl -LsSf https://astral.sh/uv/install.sh | sh " + "(Windows: https://docs.astral.sh/uv/getting-started/installation/)") + _run(["uv", *args], cwd=cwd, env=_uv_env()) + + +def _npm(args: list[str], cwd: Path = UI) -> None: + # On Windows npm is a .cmd shim; shutil.which finds it and subprocess runs it without shell=True. + _require("npm", "Install Node.js 20+ from https://nodejs.org") + npm = shutil.which("npm") or "npm" + _run([npm, *args], cwd=cwd) + + +# ── tasks ─────────────────────────────────────────────────────────────────────── +def setup(_a) -> None: + setup_py(_a) + setup_ui(_a) + print("\n✓ Setup complete. Next: python tasks.py run (offline demo — no API key needed)") + + +def setup_py(_a) -> None: + print("→ Python: creating v1/.venv and installing deps (this can take a minute)…") + _uv(["sync"]) + env_file = V1 / ".env" + if not env_file.exists(): + shutil.copyfile(V1 / ".env.example", env_file) + print("→ Created v1/.env from the template. Add your ANTHROPIC_API_KEY for live runs.") + print("✓ Python environment ready.") + + +def setup_ui(_a) -> None: + print("→ Explorer: installing node_modules…") + _npm(["install"]) + print("✓ Explorer dependencies ready.") + + +def report(a) -> None: + """Golden replay = the saved run rendered offline. No network, no key, no cost.""" + print(f"→ Rendering the golden report suite for '{a.domain}' (offline replay)…") + _uv(["run", "python", "run.py", "--domain", a.domain, "--golden", "--auto-resolve"]) + + +def ui(_a) -> None: + print("→ Building the explorer SPA…") + _npm(["run", "build"]) + print("✓ Explorer built → explorer/dist/") + + +def run(a) -> None: + report(a) + ui(a) + print(f"\n✓ Offline demo ready for '{a.domain}'.") + print(f" • Report suite: v1/out/{a.domain}/index.html") + print(" • Explorer SPA: explorer/dist/index.html") + open_report(a) + + +def live(a) -> None: + """LIVE run. run.py itself preflights credentials and prints an actionable message if missing, + so we don't duplicate the check here — just announce and hand off.""" + print(f"→ LIVE run for '{a.domain}' — this takes minutes and spends API credits…") + _uv(["run", "python", "run.py", "--domain", a.domain, "--fresh", "--auto-resolve"]) + + +def console(_a) -> None: + """Start the backend (server.py) then the Vite dev server. Ctrl-C stops both.""" + _require("uv", "Install uv first: https://docs.astral.sh/uv/") + _require("npm", "Install Node.js 20+ from https://nodejs.org") + print("→ Starting Discovery Console backend on http://127.0.0.1:8742 …") + backend = subprocess.Popen(["uv", "run", "python", "server.py"], cwd=str(V1), env=_uv_env()) + try: + print("→ Starting the explorer dev server (Ctrl-C to stop both)…") + npm = shutil.which("npm") or "npm" + subprocess.run([npm, "run", "dev"], cwd=str(UI)) + finally: + backend.terminate() + try: + backend.wait(timeout=10) + except subprocess.TimeoutExpired: + backend.kill() + + +def doctor(_a) -> None: + print("→ Checking provider connectivity (makes one tiny live call)…") + _uv(["run", "python", "scripts/doctor.py"]) + + +def open_report(a) -> None: + f = V1 / "out" / a.domain / "index.html" + if f.exists(): + webbrowser.open(f.as_uri()) + print(f"→ Opened {f}") + else: + print(f"No report yet for '{a.domain}'. Run 'python tasks.py report' (or 'run') first.") + + +def test(_a) -> None: + print("→ Running the Python test suite + type-check…") + _uv(["run", "pytest"]) + _uv(["run", "pyrefly", "check", "discovery", "run.py", "scripts"]) + + +def clean(_a) -> None: + print("→ Removing generated build output (keeps env, golden, and reports)…") + shutil.rmtree(UI / "dist", ignore_errors=True) + for pc in V1.rglob("__pycache__"): + shutil.rmtree(pc, ignore_errors=True) + print("✓ Clean.") + + +def distclean(a) -> None: + clean(a) + print("→ Full reset: removing v1/.venv and explorer/node_modules…") + shutil.rmtree(V1 / ".venv", ignore_errors=True) + shutil.rmtree(UI / "node_modules", ignore_errors=True) + print("✓ Done. Re-run 'python tasks.py setup' to rebuild.") + + +TASKS = { + "setup": (setup, "Install everything (Python env in v1/ + explorer deps)."), + "run": (run, "OFFLINE demo: golden replay + build UI + open it. No API key, no cost."), + "live": (live, "LIVE run: real agent pipeline (needs credentials; spends credits)."), + "console": (console, "Start the Discovery Console (backend + UI dev server)."), + "report": (report, "(Re)generate the golden report suite for a domain -> v1/out//."), + "ui": (ui, "Build the explorer SPA into explorer/dist/."), + "open": (open_report, "Open the most recently built report suite in your browser."), + "doctor": (doctor, "Check that your API provider/credentials are wired (one tiny call)."), + "test": (test, "Run the Python test suite + type-check."), + "clean": (clean, "Remove generated build output (dist/, __pycache__) — keeps env + golden."), + "distclean": (distclean, "Also remove the venvs and node_modules (full reset)."), +} + + +def _help() -> None: + print("\n Agentic Discovery Platform — python tasks.py ") + print(" " + "─" * 50) + for name, (_fn, desc) in TASKS.items(): + print(f" {name:10s} {desc}") + print("\n Vars: --domain o2c|p2p (default: o2c)") + print(" First time? → python tasks.py setup && python tasks.py run\n") + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(add_help=False) + ap.add_argument("task", nargs="?", default="help") + ap.add_argument("--domain", default="o2c") + a = ap.parse_args(argv) + if a.task in ("help", "-h", "--help"): + _help() + return 0 + entry = TASKS.get(a.task) + if entry is None: + print(f"unknown task: {a.task!r}") + _help() + return 2 + entry[0](a) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/v1/README.md b/v1/README.md index 8b23981..129c35d 100644 --- a/v1/README.md +++ b/v1/README.md @@ -50,27 +50,51 @@ v1/ # VERIFIED_NUMBERS.md — independently verified O2C figures ``` -## Setup (uv-based) +## Setup + +**Easiest — from the repo root** (drives this engine + the explorer; no key needed for the demo): + +```bash +make setup && make run # macOS / Linux +python tasks.py setup && python tasks.py run # Windows (or anywhere; identical behaviour) +``` + +`run` is an offline **golden replay** — no credentials, no cost. See the root [README](../README.md#quickstart). + +**Engine only (uv-based, from `v1/`):** ```bash cd v1 uv sync # creates .venv and installs deps + dev tools (pytest, pyrefly) -cp .env.example .env # then add your ANTHROPIC_API_KEY (or the Azure vars) -uv run python scripts/doctor.py # verify provider connectivity +cp .env.example .env # only needed for LIVE runs — add your ANTHROPIC_API_KEY (or the Azure vars) +uv run python scripts/doctor.py # (live only) verify provider connectivity ``` -## Usage (once creds are set) +## Usage + +**Offline demo (no key, no cost) — start here:** + +```bash +uv run python run.py --domain o2c --golden --auto-resolve # replay the saved run offline → out/o2c/ +``` + +**Live runs (generate everything live — need credentials in `v1/.env`):** ```bash -uv run python run.py --domain o2c # live run (generates everything live) +uv run python run.py --domain o2c # live run (interactive SME resolve) uv run python run.py --domain o2c --auto-resolve # non-interactive uv run python run.py --domain o2c --use-fixture # pre-built O2C fixture — the full reference-depth suite -uv run python run.py --domain o2c --golden # replay a saved run offline uv run python run.py --domain o2c --refresh # diff against the previous run (new/resolved/changed) uv run python run.py --domain o2c --no-verify # skip the adversarial verification pass uv run python run.py --domain p2p --auto-resolve # any other domain — generated live ``` +> **Live runs preflight your credentials.** If `v1/.env` has no real key (or still has the +> `.env.example` `sk-ant-...` placeholder), the run stops immediately with a one-line fix and a +> pointer to `--golden` — instead of a traceback or a misleading "no cached response" message. +> The on-disk `.cache/` is gitignored, so a fresh clone has nothing to replay *live*; the committed +> `golden/` is what `--golden` replays. + > **Reference-depth O2C suite.** `--use-fixture` renders the hand-grounded O2C fixture — the full > reference-grade suite (per-report cover + own TOC, the channel-mix / lead-time / credit-band / > collections / EDI-connection / top-account tables, the five pain-point detail tables, the evidence diff --git a/v1/discovery/env.py b/v1/discovery/env.py index a05b4e9..f195905 100644 --- a/v1/discovery/env.py +++ b/v1/discovery/env.py @@ -23,3 +23,29 @@ def load_env(path: Path | None = None) -> None: key, value = key.strip(), value.strip().strip('"').strip("'") if key and key not in os.environ: # don't clobber an explicit export os.environ[key] = value + + +# The literal placeholder shipped in .env.example — a freshly-copied .env still has this, and it is +# NOT a usable credential. Treat it as "missing" so a first run fails the preflight cleanly instead +# of handing a bogus key to the SDK. +_ANTHROPIC_PLACEHOLDER = "sk-ant-..." + + +def missing_credentials(provider: str | None = None) -> list[str]: + """Return the env vars the given provider needs but does not have (empty list == ready to call). + + Mirrors scripts/doctor.py so the preflight and the doctor agree. The Anthropic placeholder from + .env.example counts as missing. Does NOT make a network call — presence only. + """ + provider = (provider or os.environ.get("DISCOVERY_PROVIDER", "anthropic")).lower() + if provider == "azure": + required = ["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_API_KEY", + "AZURE_OPENAI_DEPLOYMENT", "AZURE_OPENAI_API_VERSION"] + return [v for v in required if not os.environ.get(v)] + key = os.environ.get("ANTHROPIC_API_KEY", "") + return [] if key and key != _ANTHROPIC_PLACEHOLDER else ["ANTHROPIC_API_KEY"] + + +def credentials_present(provider: str | None = None) -> bool: + """True when the selected provider has all the env vars it needs to make a live call.""" + return not missing_credentials(provider) diff --git a/v1/discovery/llm.py b/v1/discovery/llm.py index 9282b17..86cb663 100644 --- a/v1/discovery/llm.py +++ b/v1/discovery/llm.py @@ -90,8 +90,9 @@ def complete(self, system: str, prompt: str, *, model: str | None = None, if self.offline: raise LLMError( - f"offline mode: no cached response for this call (key={key}). " - "Run online once to populate the cache, or use the golden run." + f"offline mode (DISCOVERY_OFFLINE=1): no cached response for this call (key={key}). " + "This is cache-only mode — it does NOT call the provider. If you meant to run live, " + "unset DISCOVERY_OFFLINE and set your API key; for the demo, use --golden." ) response = self._call_provider(system, prompt, model, max_tokens) @@ -120,8 +121,9 @@ def messages_with_tools(self, *, system: str, messages: list, tools: list, return ToolTurn.from_json(cached) if self.offline: raise LLMError( - f"offline mode: no cached tool-turn for this state (key={key}). " - "Run online once to populate the cache, or use the scripted/golden path." + f"offline mode (DISCOVERY_OFFLINE=1): no cached tool-turn for this state (key={key}). " + "This is cache-only mode — it does NOT call the provider. If you meant to run live, " + "unset DISCOVERY_OFFLINE and set your API key; for the demo, use --golden." ) turn = self._call_anthropic_tools(system, messages, tools, model, max_tokens) self._write_cache(key, system, json.dumps(messages, sort_keys=True, ensure_ascii=False), @@ -143,6 +145,26 @@ def _temp_kwargs(model: str) -> dict: deprecated = ("claude-opus-4-8",) return {} if any(model.startswith(d) for d in deprecated) else {"temperature": 0} + @staticmethod + def _provider_error(provider: str, e: Exception) -> LLMError: + """Turn any provider-SDK failure into one clean, actionable LLMError (no traceback leak). + + The most common first-run failure is no/invalid credentials: the Anthropic SDK raises a + TypeError ("Could not resolve authentication method") when no key is set, or an + AuthenticationError on a bad key. Either way, point the operator at the real fix instead of + surfacing a stack trace or the misleading 'offline / use golden' message.""" + name = type(e).__name__ + msg = str(e) or name + auth = "authentication" in msg.lower() or "api_key" in msg.lower() or name in ( + "AuthenticationError", "PermissionDeniedError") + if auth: + keyvar = "AZURE_OPENAI_API_KEY" if provider == "azure" else "ANTHROPIC_API_KEY" + return LLMError( + f"{provider} credentials rejected or missing ({name}). Check {keyvar} in v1/.env " + "(verify with: uv run python scripts/doctor.py), or run with --golden for the " + "offline demo.") + return LLMError(f"{provider} call failed ({name}): {msg}") + def _call_anthropic_tools(self, system, messages, tools, model, max_tokens): try: import anthropic @@ -150,12 +172,17 @@ def _call_anthropic_tools(self, system, messages, tools, model, max_tokens): raise LLMError("pip install anthropic") from e if self._client is None: self._client = anthropic.Anthropic() - # we never stream, so .create() returns a Message (not a Stream); narrow for the checker - msg: Any = self._client.messages.create( - model=model, max_tokens=max_tokens, **self._temp_kwargs(model), - system=system, tools=tools, tool_choice={"type": "auto"}, - messages=messages, - ) + try: + # we never stream, so .create() returns a Message (not a Stream); narrow for the checker + msg: Any = self._client.messages.create( + model=model, max_tokens=max_tokens, **self._temp_kwargs(model), + system=system, tools=tools, tool_choice={"type": "auto"}, + messages=messages, + ) + except LLMError: + raise + except Exception as e: # pragma: no cover - provider/network failure, exercised live only + raise self._provider_error(self.provider, e) from e blocks = [] for b in msg.content: if b.type == "text": @@ -182,13 +209,16 @@ def _call_anthropic(self, system: str, prompt: str, model: str, max_tokens: int) raise LLMError("pip install anthropic") from e if self._client is None: self._client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY - msg: Any = self._client.messages.create( # non-streaming -> Message; narrow for the checker - model=model, - max_tokens=max_tokens, - **self._temp_kwargs(model), - system=system, - messages=[{"role": "user", "content": prompt}], - ) + try: + msg: Any = self._client.messages.create( # non-streaming -> Message; narrow for the checker + model=model, + max_tokens=max_tokens, + **self._temp_kwargs(model), + system=system, + messages=[{"role": "user", "content": prompt}], + ) + except Exception as e: # pragma: no cover - provider/network failure, exercised live only + raise self._provider_error(self.provider, e) from e return "".join(b.text for b in msg.content if getattr(b, "type", "") == "text") def _call_azure(self, system: str, prompt: str, model: str, max_tokens: int) -> str: diff --git a/v1/run.py b/v1/run.py index e515429..73e8515 100644 --- a/v1/run.py +++ b/v1/run.py @@ -79,6 +79,21 @@ def main(argv=None) -> int: if args.golden: _activate_golden_cache(args.domain) os.environ["DISCOVERY_OFFLINE"] = "1" + else: + # LIVE path (anything but --golden) needs provider credentials. Check BEFORE doing any work + # so a first-timer gets one clear, actionable line instead of either a deep SDK traceback + # ("Could not resolve authentication method") or the misleading cache-miss/offline message. + missing = env.missing_credentials(os.environ.get("DISCOVERY_PROVIDER")) + if missing: + provider = os.environ.get("DISCOVERY_PROVIDER", "anthropic").lower() + print(f"error: this is a LIVE run but the '{provider}' provider has no usable " + f"credentials (missing/placeholder: {', '.join(missing)}).") + print(" Fix one of these, then re-run:") + print(" • Add your key to v1/.env (cp .env.example .env, then fill it in),") + print(" • verify it with: uv run python scripts/doctor.py") + print(" Or run the OFFLINE demo with no key and no cost:") + print(f" uv run python run.py --domain {args.domain} --golden --auto-resolve") + return 2 if args.fresh: os.environ["DISCOVERY_NO_CACHE"] = "1" print(" (fresh run: bypassing the LLM cache — this will take minutes and spend credits)") diff --git a/v1/tests/test_env.py b/v1/tests/test_env.py new file mode 100644 index 0000000..3a2205e --- /dev/null +++ b/v1/tests/test_env.py @@ -0,0 +1,89 @@ +"""Coverage for discovery/env.py credential preflight (missing_credentials / credentials_present). + +env.py is omitted from the coverage gate (IO glue), but this preflight is the thing that turns a +keyless first run from a stack trace / misleading 'use golden' message into one clear line — so it +is worth guarding directly. All offline; env is isolated per-test via monkeypatch. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +from discovery import env # noqa: E402 + +_ALL_CRED_VARS = [ + "DISCOVERY_PROVIDER", "ANTHROPIC_API_KEY", + "AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_API_KEY", + "AZURE_OPENAI_DEPLOYMENT", "AZURE_OPENAI_API_VERSION", +] + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch): + """Start every test from a known-empty credential environment.""" + for v in _ALL_CRED_VARS: + monkeypatch.delenv(v, raising=False) + + +def test_anthropic_missing_when_unset(): + assert env.missing_credentials("anthropic") == ["ANTHROPIC_API_KEY"] + assert env.credentials_present("anthropic") is False + + +def test_anthropic_placeholder_counts_as_missing(monkeypatch): + # the literal value shipped in .env.example must NOT pass — a freshly-copied .env has it + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-...") + assert env.missing_credentials("anthropic") == ["ANTHROPIC_API_KEY"] + assert env.credentials_present("anthropic") is False + + +def test_anthropic_real_key_passes(monkeypatch): + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-real-looking-value") + assert env.missing_credentials("anthropic") == [] + assert env.credentials_present("anthropic") is True + + +def test_azure_requires_all_four(monkeypatch): + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "abc") + monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://x.openai.azure.com") + # deployment + version still missing + missing = env.missing_credentials("azure") + assert "AZURE_OPENAI_DEPLOYMENT" in missing + assert "AZURE_OPENAI_API_VERSION" in missing + assert env.credentials_present("azure") is False + + +def test_azure_full_set_passes(monkeypatch): + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "abc") + monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://x.openai.azure.com") + monkeypatch.setenv("AZURE_OPENAI_DEPLOYMENT", "my-deployment") + monkeypatch.setenv("AZURE_OPENAI_API_VERSION", "2024-10-21") + assert env.missing_credentials("azure") == [] + assert env.credentials_present("azure") is True + + +def test_provider_defaults_to_env_then_anthropic(monkeypatch): + # no provider arg, no DISCOVERY_PROVIDER -> defaults to anthropic + assert env.missing_credentials() == ["ANTHROPIC_API_KEY"] + # honour DISCOVERY_PROVIDER when no explicit arg is passed + monkeypatch.setenv("DISCOVERY_PROVIDER", "azure") + assert "AZURE_OPENAI_API_KEY" in env.missing_credentials() + + +def test_load_env_does_not_clobber_existing(monkeypatch, tmp_path): + monkeypatch.setenv("ANTHROPIC_API_KEY", "already-set") + p = tmp_path / ".env" + p.write_text('ANTHROPIC_API_KEY="from-file"\n# comment\nNO_EQUALS_LINE\nDISCOVERY_PROVIDER=azure\n') + env.load_env(p) + import os + assert os.environ["ANTHROPIC_API_KEY"] == "already-set" # explicit env wins + assert os.environ["DISCOVERY_PROVIDER"] == "azure" # new key loaded, quotes stripped + + +def test_load_env_missing_file_is_noop(tmp_path): + env.load_env(tmp_path / "does-not-exist.env") # must not raise