diff --git a/README.md b/README.md index 008e187..504a06a 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,9 @@ tail -5000 app.log | droste "why did it crash?" ``` For a one-off run, use `uvx droste` instead of installing it. The command also -supports your own OpenAI-compatible or Anthropic endpoint. See the -[quickstart](docs/quickstart.md) for credentials, directories, SQLite, and -useful flags. +supports an existing ModelRelay key or your own OpenAI-compatible or Anthropic +endpoint. See the [quickstart](docs/quickstart.md) for credentials, directories, +SQLite, and useful flags. ## Good fits diff --git a/docs/embedding.md b/docs/embedding.md index e97eb4b..8291d1f 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -8,9 +8,10 @@ An embedded run has four shared pieces: a `Budget`, an `ExecutionContext`, model clients, and an execution environment. Construct the context and environment from the same `EnvironmentConfig`; conflicting budgets fail before inference. -`OpenAICompatClient` reads `OPENAI_API_KEY` and `OPENAI_BASE_URL`. Anthropic and -ModelRelay clients are also included. Explicit constructor arguments override -environment values. +`OpenAICompatClient` reads `OPENAI_API_KEY` and `OPENAI_BASE_URL`. +`ModelRelayClient` and `ModelRelaySubcallClient` read `MODELRELAY_API_KEY` and +`MODELRELAY_BASE_URL`. Anthropic clients are also included. Explicit constructor +arguments override environment values. ## Compute and local execution limits diff --git a/docs/quickstart.md b/docs/quickstart.md index d6c5f9d..ffcd2c7 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -14,12 +14,22 @@ droste login and CI, set a provider key directly: ```bash -export OPENAI_API_KEY=sk-... +export MODELRELAY_API_KEY=mr_sk_... +# Or: export OPENAI_API_KEY=sk-... # Or: export ANTHROPIC_API_KEY=sk-ant-... ``` -Set `OPENAI_BASE_URL` when using another OpenAI-compatible endpoint. You can -also pass `--api-key` and `--base-url` for one run. +ModelRelay uses its native endpoint by default; override it with +`MODELRELAY_BASE_URL` when needed. Set `OPENAI_BASE_URL` when using another +OpenAI-compatible endpoint. You can also pass `--provider`, `--api-key`, and +`--base-url` for one run. `mr_sk_*` keys passed through `--api-key` select the +native ModelRelay provider automatically. + +For explicit provider selection: + +```bash +droste "..." ./data --provider modelrelay --api-key mr_sk_... --model MODEL +``` For a one-off run without installation: diff --git a/pyproject.toml b/pyproject.toml index 9598003..561c4d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "droste" -version = "0.25.0" +version = "0.26.0" description = "Recursive analysis engine for data too large for a context window, built with Recursive Language Models (RLMs)" readme = "README.md" requires-python = ">=3.11" diff --git a/src/droste/clients/modelrelay.py b/src/droste/clients/modelrelay.py index 6fbf65f..0df83e9 100644 --- a/src/droste/clients/modelrelay.py +++ b/src/droste/clients/modelrelay.py @@ -1,8 +1,9 @@ -"""Native ModelRelay clients for the logged-in path. +"""Native ModelRelay clients for logged-in and direct-key paths. -`droste login` stores a ModelRelay API key; these clients run the loop against -ModelRelay's native `POST /responses` — the same endpoint the platform's hosted -runner ultimately talks to — with no OpenAI-compat shim in between. +`droste login` can store a ModelRelay API key, and callers can pass one directly +or set ``MODELRELAY_API_KEY``. These clients run the loop against ModelRelay's +native `POST /responses` — the same endpoint the platform's hosted runner +ultimately talks to — with no OpenAI-compat shim in between. Two classes, one per protocol, mirroring the BYOK pair in ``openai_compat``: @@ -21,11 +22,16 @@ ``reasoning_effort``/``max_output_tokens`` explicitly to override. Dependency-free by design: urllib only, like the rest of the engine. + +Config resolution: explicit constructor arguments win, then +``MODELRELAY_API_KEY`` / ``MODELRELAY_BASE_URL``, then the public ModelRelay +base URL. """ from __future__ import annotations import json +import os import threading import urllib.error import urllib.request @@ -62,9 +68,23 @@ from .useragent import USER_AGENT DEFAULT_MODELRELAY_BASE_URL = "https://api.modelrelay.ai/api/v1" +MODELRELAY_KEY_PREFIX = "mr_sk_" _STREAM_ACCEPT = 'application/x-ndjson; profile="responses-stream/v2"' +def resolve_modelrelay_api_key(api_key: str | None = None) -> str: + """Resolve an explicit key before the standard ModelRelay environment key.""" + + return str(api_key if api_key is not None else os.environ.get("MODELRELAY_API_KEY", "")) + + +def resolve_modelrelay_base_url(base_url: str | None = None) -> str: + """Resolve an explicit endpoint before the standard ModelRelay environment endpoint.""" + + resolved = base_url or os.environ.get("MODELRELAY_BASE_URL") or DEFAULT_MODELRELAY_BASE_URL + return str(resolved).rstrip("/") + + @dataclass(frozen=True, slots=True) class _NativeBatchResult: results: tuple[str, ...] @@ -199,7 +219,7 @@ def __init__( label: str, on_dispatch: Callable[[], None] | None = None, ) -> None: - base = str(base_url or DEFAULT_MODELRELAY_BASE_URL).rstrip("/") + base = resolve_modelrelay_base_url(base_url) self._base_url = base self._url = base + "/responses" self._api_key = str(api_key or "") @@ -365,7 +385,7 @@ def __init__( *, model: str = "", base_url: str | None = None, - api_key: str = "", + api_key: str | None = None, temperature: float | None = None, stop: list[str] | None = None, max_output_tokens: int = 0, @@ -373,15 +393,19 @@ def __init__( timeout: float = DEFAULT_TIMEOUT_SECONDS, on_delta: Any | None = None, ) -> None: - if not api_key: - raise ValueError("api_key is required (run `droste login`)") + resolved_api_key = resolve_modelrelay_api_key(api_key) + if not resolved_api_key: + raise ValueError( + "api_key is required (pass it explicitly, set MODELRELAY_API_KEY, " + "or run `droste login`)" + ) self._accounting_lock = threading.Lock() self._total_usage = TokenUsage(0, 0, 0, observation_basis=UsageObservationBasis.EXACT) self._has_usage_observation = False self._root_requests_issued = 0 self._transport = _ResponsesTransport( base_url=base_url, - api_key=api_key, + api_key=resolved_api_key, timeout=timeout, label="root llm", on_dispatch=self._account_root_request, @@ -509,7 +533,7 @@ def __init__( model: str, context: ExecutionContext, base_url: str | None = None, - api_key: str = "", + api_key: str | None = None, max_output_tokens: int = DEFAULT_SUBCALL_OUTPUT_TOKENS, temperature: float | None = None, reasoning_effort: str = "none", @@ -518,13 +542,17 @@ def __init__( ) -> None: if not model: raise ValueError("model is required") - if not api_key: - raise ValueError("api_key is required (run `droste login`)") + resolved_api_key = resolve_modelrelay_api_key(api_key) + if not resolved_api_key: + raise ValueError( + "api_key is required (pass it explicitly, set MODELRELAY_API_KEY, " + "or run `droste login`)" + ) if max_output_tokens < 0: raise ValueError("max_output_tokens must be >= 0 (0 disables the bound)") resolved_concurrency = validate_subcall_concurrency(max_parallel) self._transport = _ResponsesTransport( - base_url=base_url, api_key=api_key, timeout=timeout, label="llm_query" + base_url=base_url, api_key=resolved_api_key, timeout=timeout, label="llm_query" ) self._model = str(model) self._context = context diff --git a/src/droste_cli/auth.py b/src/droste_cli/auth.py index 67ea0e8..6107a1b 100644 --- a/src/droste_cli/auth.py +++ b/src/droste_cli/auth.py @@ -40,6 +40,10 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any, Callable +from droste.clients.modelrelay import ( + MODELRELAY_KEY_PREFIX, + resolve_modelrelay_base_url, +) from droste.clients.useragent import USER_AGENT from .credentials import ( @@ -357,7 +361,7 @@ def _prompt_choice() -> str: print( "droste: choose how to run:\n" " 1) ModelRelay - free credits, GitHub sign-in, no key (recommended)\n" - " 2) your own API key (any OpenAI-compatible endpoint, or Anthropic)", + " 2) your own API key (ModelRelay, OpenAI-compatible, or Anthropic)", file=sys.stderr, flush=True, ) @@ -373,7 +377,12 @@ def _setup_byok() -> None: """Store a bring-your-own-key choice in the credentials file.""" import getpass - env_key = os.environ.get("OPENAI_API_KEY") or os.environ.get("ANTHROPIC_API_KEY") or "" + env_key = ( + os.environ.get("MODELRELAY_API_KEY") + or os.environ.get("OPENAI_API_KEY") + or os.environ.get("ANTHROPIC_API_KEY") + or "" + ) api_key = "" if env_key: if _ask(f"use {_mask_key(env_key)} from your environment? [Y/n]: ").lower() not in ( @@ -389,8 +398,9 @@ def _setup_byok() -> None: if not api_key: raise AuthError("no API key entered") - base = "" - if not api_key.startswith("sk-ant-"): + is_modelrelay = api_key.startswith(MODELRELAY_KEY_PREFIX) + base = resolve_modelrelay_base_url() if is_modelrelay else "" + if not api_key.startswith("sk-ant-") and not is_modelrelay: base = _ask("API base URL [https://api.openai.com/v1]: ").rstrip("/") model = _ask("default model (e.g. gpt-5.2-mini): ") @@ -401,7 +411,7 @@ def _setup_byok() -> None: Credentials( api_key=api_key, base_url=base, - provider="byok", + provider="modelrelay" if is_modelrelay else "byok", default_model=model, created_at=datetime.now(timezone.utc).isoformat(timespec="seconds"), ) diff --git a/src/droste_cli/credentials.py b/src/droste_cli/credentials.py index b275fc1..0845af6 100644 --- a/src/droste_cli/credentials.py +++ b/src/droste_cli/credentials.py @@ -1,4 +1,4 @@ -"""Stored ModelRelay credentials for the logged-in path. +"""Stored provider credentials for CLI runs. One file, one credential: ``$XDG_CONFIG_HOME/droste/credentials.json`` (default ``~/.config/droste/credentials.json``), written atomically at 0600. diff --git a/src/droste_cli/main.py b/src/droste_cli/main.py index 25eee7c..11b0463 100644 --- a/src/droste_cli/main.py +++ b/src/droste_cli/main.py @@ -21,9 +21,9 @@ data source (read-only policy as a guardrail, not a boundary; OS permissions are the boundary). -Pointing --base-url at ModelRelay lights up the platform features (validated -SQL policies, server-enforced subcall cost controls, audit); it is documented, -not required. `droste` is the engine CLI; `mrl` remains the platform CLI. +ModelRelay is a native provider selected by ``droste login``, +``MODELRELAY_API_KEY``, an ``mr_sk_*`` key, or ``--provider modelrelay``. +`droste` is the engine CLI; `mrl` remains the platform CLI. """ from __future__ import annotations @@ -67,8 +67,8 @@ NO_CREDENTIALS_MESSAGE = ( "no credentials. run `droste login` to choose how to run (free credits, " - "or your own key); scripts can pass --api-key or set OPENAI_API_KEY / " - "ANTHROPIC_API_KEY" + "or your own key); scripts can pass --api-key or set MODELRELAY_API_KEY / " + "OPENAI_API_KEY / ANTHROPIC_API_KEY" ) @@ -96,8 +96,8 @@ def build_parser() -> argparse.ArgumentParser: epilog=( "setup: droste login chooses how to run (ModelRelay free credits, " "or your own key) and stores it; droste whoami / droste logout. " - "--api-key / --base-url override for a single run; scripts can " - "set OPENAI_API_KEY / ANTHROPIC_API_KEY. embedders: droste " + "--provider / --api-key / --base-url override for a single run; scripts can " + "set MODELRELAY_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY. embedders: droste " "relay-path prints the bundled Deno relay directory." ), ) @@ -114,15 +114,27 @@ def build_parser() -> argparse.ArgumentParser: default=os.environ.get("DROSTE_MODEL", ""), help="root model id (env: DROSTE_MODEL)", ) + parser.add_argument( + "--provider", + choices=("modelrelay", "openai", "anthropic"), + default=None, + help="model provider (otherwise inferred from credentials and key prefixes)", + ) parser.add_argument( "--base-url", default=None, - help="OpenAI-compatible endpoint base URL (env: OPENAI_BASE_URL; default: api.openai.com/v1)", + help=( + "provider endpoint base URL (env: MODELRELAY_BASE_URL or OPENAI_BASE_URL; " + "provider default when omitted)" + ), ) parser.add_argument( "--api-key", default=None, - help="API key (env: OPENAI_API_KEY; the flag overrides the env)", + help=( + "provider API key (env: MODELRELAY_API_KEY, OPENAI_API_KEY, or " + "ANTHROPIC_API_KEY; the flag overrides the env)" + ), ) parser.add_argument( "--subcall-model", default="", help="model for llm_query subcalls (default: --model)" @@ -272,20 +284,32 @@ def progress(self, status: str) -> None: def select_provider(args: argparse.Namespace) -> str: """Fact-based provider detection — no guessing, documented order: - 1. An explicit endpoint (``--base-url`` or ``OPENAI_BASE_URL``) always - means the OpenAI-compatible client: the user chose where to go. - 2. ``--api-key sk-ant-…`` is an Anthropic key (their published prefix). - 3. Any other explicit ``--api-key`` is OpenAI-compatible. - 4. A ``claude-*`` model with ``ANTHROPIC_API_KEY`` set goes to Anthropic + 1. ``--provider`` is authoritative. + 2. Explicit ``mr_sk_*`` and ``sk-ant-*`` keys select their native clients. + 3. Any other explicit key or endpoint is OpenAI-compatible. + 4. ``MODELRELAY_API_KEY`` selects ModelRelay before ambient compat settings. + 5. A ``claude-*`` model with ``ANTHROPIC_API_KEY`` set goes to Anthropic even when ``OPENAI_API_KEY`` is also set (the model names the vendor). - 5. Otherwise ``OPENAI_API_KEY`` wins, then ``ANTHROPIC_API_KEY``. + 6. Otherwise ``OPENAI_API_KEY`` wins, then ``ANTHROPIC_API_KEY``. """ from droste.clients.anthropic import ANTHROPIC_KEY_PREFIX + from droste.clients.modelrelay import MODELRELAY_KEY_PREFIX - if args.base_url or os.environ.get("OPENAI_BASE_URL"): - return "openai" + explicit_provider = getattr(args, "provider", None) + if explicit_provider: + return str(explicit_provider) if args.api_key is not None: - return "anthropic" if args.api_key.startswith(ANTHROPIC_KEY_PREFIX) else "openai" + if args.api_key.startswith(MODELRELAY_KEY_PREFIX): + return "modelrelay" + if args.api_key.startswith(ANTHROPIC_KEY_PREFIX): + return "anthropic" + return "openai" + if args.base_url: + return "openai" + if os.environ.get("MODELRELAY_API_KEY") or os.environ.get("MODELRELAY_BASE_URL"): + return "modelrelay" + if os.environ.get("OPENAI_BASE_URL"): + return "openai" anthropic_key = os.environ.get("ANTHROPIC_API_KEY", "") has_anthropic = anthropic_key.startswith(ANTHROPIC_KEY_PREFIX) if str(args.model).startswith("claude") and has_anthropic: @@ -297,26 +321,62 @@ def select_provider(args: argparse.Namespace) -> str: return "openai" +def _modelrelay_credentials( + api_key: str | None = None, + base_url: str | None = None, +) -> Credentials: + from droste.clients.modelrelay import ( + resolve_modelrelay_api_key, + resolve_modelrelay_base_url, + ) + + resolved_api_key = resolve_modelrelay_api_key(api_key) + if not resolved_api_key: + raise CLIError( + "no ModelRelay API key: pass --api-key, set MODELRELAY_API_KEY, or run `droste login`" + ) + return Credentials( + api_key=resolved_api_key, + base_url=resolve_modelrelay_base_url(base_url), + provider="modelrelay", + ) + + def resolve_run_target(args: argparse.Namespace) -> tuple[str, Credentials | None]: """Credential resolution: - 1. Per-run flags (``--base-url`` / ``--api-key``) always win: an explicit + 1. Per-run flags (``--provider`` / ``--base-url`` / ``--api-key``) always win: an explicit flag is a statement about THIS run. 2. Stored credentials — set up once via `droste login` (ModelRelay with free credits, or your own key). Choosing how droste runs is a deliberate setup step, never a side effect of exported env vars. 3. No credentials on an interactive terminal: run the same chooser in-line, then continue the run. - 4. Non-interactive (scripts/CI): env keys (``OPENAI_BASE_URL`` / - ``OPENAI_API_KEY`` / ``ANTHROPIC_API_KEY``) as a fallback, else a + 4. Non-interactive (scripts/CI): ModelRelay, OpenAI-compatible, or Anthropic + environment credentials as a fallback, else a terse error pointing at `droste login`. """ - if args.base_url is not None or args.api_key is not None: - return select_provider(args), None + explicit_transport = args.base_url is not None or args.api_key is not None + if explicit_transport: + provider = select_provider(args) + if provider == "modelrelay": + return provider, _modelrelay_credentials(args.api_key, args.base_url) + return provider, None try: creds = load_credentials() except CredentialsError as exc: raise CLIError(str(exc)) from exc + explicit_provider = getattr(args, "provider", None) + if explicit_provider is not None: + if explicit_provider == "modelrelay": + if os.environ.get("MODELRELAY_API_KEY"): + return explicit_provider, _modelrelay_credentials() + if creds is not None and creds.provider == "modelrelay": + return explicit_provider, creds + return explicit_provider, _modelrelay_credentials() + # Selecting another provider explicitly bypasses a stored ModelRelay + # login. Its native client resolves the corresponding environment key. + return explicit_provider, None if creds is None and sys.stdin.isatty() and sys.stderr.isatty(): from . import auth @@ -339,16 +399,21 @@ def resolve_run_target(args: argparse.Namespace) -> tuple[str, Credentials | Non args.base_url = creds.base_url or DEFAULT_BASE_URL return "openai", None return "modelrelay", creds - # ANY set env key counts — including a malformed one. A bad - # ANTHROPIC_API_KEY must fail loudly on the BYOK path, not be masked - # by the no-credentials error. - env_byok = ( - bool(os.environ.get("OPENAI_BASE_URL")) + # Any configured provider variable counts, including a malformed one. + # Fail loudly on the selected provider path instead of masking it with the + # generic no-credentials error. + env_provider = ( + bool(os.environ.get("MODELRELAY_API_KEY")) + or bool(os.environ.get("MODELRELAY_BASE_URL")) + or bool(os.environ.get("OPENAI_BASE_URL")) or bool(os.environ.get("OPENAI_API_KEY")) or bool(os.environ.get("ANTHROPIC_API_KEY")) ) - if env_byok: - return select_provider(args), None + if env_provider: + provider = select_provider(args) + if provider == "modelrelay": + return provider, _modelrelay_credentials() + return provider, None raise CLIError(NO_CREDENTIALS_MESSAGE) @@ -453,7 +518,7 @@ def run_ask(args: argparse.Namespace) -> int: "no API key: set OPENAI_API_KEY or ANTHROPIC_API_KEY, or pass " "--api-key (custom --base-url endpoints may run keyless)" ) - elif args.reasoning_effort: + elif provider == "anthropic" and args.reasoning_effort: raise CLIError( "--reasoning-effort is not an Anthropic API parameter; Claude " "thinking is controlled via the API's thinking params" @@ -527,6 +592,7 @@ def _trace_sink(event: dict) -> None: root = AnthropicClient( model=args.model, + base_url=args.base_url, api_key=args.api_key, on_delta=echo, ) @@ -535,6 +601,7 @@ def _trace_sink(event: dict) -> None: subcalls = AnthropicSubcallClient( model=args.subcall_model or args.model, context=exec_context, + base_url=args.base_url, api_key=args.api_key, max_output_tokens=budget.subcall_output_tokens, max_parallel=rollout.concurrency, diff --git a/tests/conftest.py b/tests/conftest.py index da05a06..436353c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,13 @@ def _isolated_credentials(tmp_path_factory, monkeypatch): config_home = tmp_path_factory.mktemp("xdg-config") monkeypatch.setenv("XDG_CONFIG_HOME", str(config_home)) - for var in ("OPENAI_API_KEY", "OPENAI_BASE_URL", "ANTHROPIC_API_KEY", "DROSTE_MODEL"): + for var in ( + "MODELRELAY_API_KEY", + "MODELRELAY_BASE_URL", + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "ANTHROPIC_API_KEY", + "DROSTE_MODEL", + ): monkeypatch.delenv(var, raising=False) yield diff --git a/tests/test_anthropic_client.py b/tests/test_anthropic_client.py index 12f0d15..5ba5d8d 100644 --- a/tests/test_anthropic_client.py +++ b/tests/test_anthropic_client.py @@ -709,7 +709,14 @@ def test_batch_bounded_concurrency(stub): def _clean_provider_env(monkeypatch): - for var in ("OPENAI_API_KEY", "OPENAI_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL"): + for var in ( + "MODELRELAY_API_KEY", + "MODELRELAY_BASE_URL", + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "ANTHROPIC_API_KEY", + "ANTHROPIC_BASE_URL", + ): monkeypatch.delenv(var, raising=False) @@ -719,7 +726,7 @@ def test_select_provider_matrix(monkeypatch): from droste_cli.main import select_provider def args(**kw): - defaults = {"base_url": None, "api_key": None, "model": "m"} + defaults = {"provider": None, "base_url": None, "api_key": None, "model": "m"} defaults.update(kw) return argparse.Namespace(**defaults) @@ -732,7 +739,12 @@ def args(**kw): monkeypatch.delenv("OPENAI_BASE_URL") # Key prefix is the fact. assert select_provider(args(api_key="sk-ant-abc")) == "anthropic" + assert select_provider(args(api_key="mr_sk_abc")) == "modelrelay" assert select_provider(args(api_key="sk-proj-abc")) == "openai" + assert select_provider(args(provider="modelrelay", api_key="custom")) == "modelrelay" + monkeypatch.setenv("MODELRELAY_API_KEY", "mr_sk_env") + assert select_provider(args()) == "modelrelay" + monkeypatch.delenv("MODELRELAY_API_KEY") # claude-* model + anthropic key beats a coexisting OPENAI_API_KEY. monkeypatch.setenv("OPENAI_API_KEY", "sk-oai") assert select_provider(args(model="claude-opus-4-8")) == "anthropic" diff --git a/tests/test_auth_login.py b/tests/test_auth_login.py index feb3d83..284f3b1 100644 --- a/tests/test_auth_login.py +++ b/tests/test_auth_login.py @@ -447,6 +447,23 @@ def test_login_chooser_byok_imports_env_key(fake_platform, monkeypatch, capsys): assert "sk-env-key-123456" not in err # never echo the full key +def test_login_chooser_imports_existing_modelrelay_key(fake_platform, monkeypatch): + _fake_tty(monkeypatch) + monkeypatch.setenv("MODELRELAY_API_KEY", "mr_sk_existing") + monkeypatch.setenv("MODELRELAY_BASE_URL", fake_platform.base_url) + # choice 2, accept env key, model; ModelRelay does not ask for an OpenAI base URL. + _feed_inputs(monkeypatch, ["2", "", "root-model"]) + + assert auth.run_login(fake_platform.base_url) == 0 + + creds = load_credentials() + assert creds is not None + assert creds.provider == "modelrelay" + assert creds.api_key == "mr_sk_existing" + assert creds.base_url == fake_platform.base_url + assert creds.default_model == "root-model" + + def test_login_chooser_default_choice_is_modelrelay(fake_platform, monkeypatch): _fake_tty(monkeypatch) _feed_inputs(monkeypatch, [""]) # Enter = choice 1 diff --git a/tests/test_droste_cli.py b/tests/test_droste_cli.py index 8cc0ad4..284b9ce 100644 --- a/tests/test_droste_cli.py +++ b/tests/test_droste_cli.py @@ -36,6 +36,8 @@ def test_parse_flags_and_positionals(): "what changed?", "--model", "m", + "--provider", + "modelrelay", "--base-url", "http://x/v1", "--api-key", @@ -64,6 +66,7 @@ def test_parse_flags_and_positionals(): ] ) assert args.inputs == ["a.txt", "b.txt", "what changed?"] + assert args.provider == "modelrelay" assert (args.model, args.base_url, args.api_key) == ("m", "http://x/v1", "k") assert (args.subcall_model, args.subcall_output_tokens) == ("sm", 512) assert args.reasoning_effort == "low" @@ -751,6 +754,73 @@ def test_env_keys_used_when_not_logged_in(stub_server, tmp_path, monkeypatch, ca assert capsys.readouterr().out.strip() == "byok content" +def test_modelrelay_env_key_runs_native_client(stub_native_server, tmp_path, monkeypatch, capsys): + monkeypatch.setenv("MODELRELAY_API_KEY", "mr_sk_env") + monkeypatch.setenv("MODELRELAY_BASE_URL", stub_native_server.base_url) + doc = tmp_path / "doc.txt" + doc.write_text("modelrelay env content") + stub_native_server.root_responses = [ANSWER_FROM_FILE] + + exit_code = main([str(doc), "q", "--model", "root-model"]) + + assert exit_code == 0 + assert capsys.readouterr().out.strip() == "modelrelay env content" + assert stub_native_server.headers[0].get("x-modelrelay-api-key") == "mr_sk_env" + + +def test_modelrelay_key_flag_selects_native_client(stub_native_server, tmp_path, capsys): + doc = tmp_path / "doc.txt" + doc.write_text("modelrelay flag content") + stub_native_server.root_responses = [ANSWER_FROM_FILE] + + exit_code = main( + [ + str(doc), + "q", + "--model", + "root-model", + "--api-key", + "mr_sk_flag", + "--base-url", + stub_native_server.base_url, + ] + ) + + assert exit_code == 0 + assert capsys.readouterr().out.strip() == "modelrelay flag content" + assert stub_native_server.headers[0].get("x-modelrelay-api-key") == "mr_sk_flag" + + +def test_explicit_modelrelay_provider_accepts_nonstandard_key_prefix( + stub_native_server, tmp_path, capsys +): + doc = tmp_path / "doc.txt" + doc.write_text("explicit provider content") + stub_native_server.root_responses = [ANSWER_FROM_FILE] + + exit_code = main( + [ + str(doc), + "q", + "--model", + "root-model", + "--provider", + "modelrelay", + "--api-key", + "development-key", + "--base-url", + stub_native_server.base_url, + "--reasoning-effort", + "low", + ] + ) + + assert exit_code == 0 + assert capsys.readouterr().out.strip() == "explicit provider content" + assert stub_native_server.headers[0].get("x-modelrelay-api-key") == "development-key" + assert stub_native_server.requests[0]["reasoning_effort"] == "low" + + def test_malformed_env_key_without_login_fails_loudly(tmp_path, monkeypatch, capsys): # A set-but-malformed key still selects the BYOK path (when not logged # in): fail loudly there, never mask it with the no-credentials error. @@ -774,6 +844,19 @@ def test_explicit_model_beats_credentials_default(stub_native_server, tmp_path, assert stub_native_server.requests[0]["model"] == "override-model" +def test_explicit_modelrelay_provider_reuses_stored_login(stub_native_server, tmp_path, capsys): + _write_credentials(stub_native_server.base_url) + doc = tmp_path / "doc.txt" + doc.write_text("stored provider content") + stub_native_server.root_responses = [ANSWER_FROM_FILE] + + exit_code = main([str(doc), "q", "--provider", "modelrelay"]) + + assert exit_code == 0 + assert capsys.readouterr().out.strip() == "stored provider content" + assert stub_native_server.headers[0].get("x-modelrelay-api-key") == "mr_sk_stored" + + def test_corrupt_credentials_file_is_actionable(tmp_path, capsys): import os diff --git a/tests/test_modelrelay_client.py b/tests/test_modelrelay_client.py index bdd1444..d7cd70d 100644 --- a/tests/test_modelrelay_client.py +++ b/tests/test_modelrelay_client.py @@ -488,6 +488,23 @@ def test_root_http_error_is_bounded(stub_native): assert client.root_requests_issued == 1 +def test_clients_read_modelrelay_environment(stub_native, monkeypatch): + monkeypatch.setenv("MODELRELAY_API_KEY", "mr_sk_env") + monkeypatch.setenv("MODELRELAY_BASE_URL", stub_native.base_url) + + root = ModelRelayClient(model="root-model") + subcalls = ModelRelaySubcallClient( + model="sub-model", + context=create_execution_context(), + ) + + assert root.responses_create([{"role": "user", "content": "q"}], model="") == "hi" + assert subcalls.llm_query("q") == "echo: q" + assert all( + headers.get("x-modelrelay-api-key") == "mr_sk_env" for headers in stub_native.headers + ) + + def test_root_transport_failure_counts_dispatched_request(monkeypatch): client = ModelRelayClient(model="root-model", api_key="mr_sk_t") diff --git a/uv.lock b/uv.lock index e6bf16e..c243746 100644 --- a/uv.lock +++ b/uv.lock @@ -562,7 +562,7 @@ wheels = [ [[package]] name = "droste" -version = "0.25.0" +version = "0.26.0" source = { editable = "." } [package.optional-dependencies]