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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 4 additions & 3 deletions docs/embedding.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 13 additions & 3 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
54 changes: 41 additions & 13 deletions src/droste/clients/modelrelay.py
Original file line number Diff line number Diff line change
@@ -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``:

Expand All @@ -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
Expand Down Expand Up @@ -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, ...]
Expand Down Expand Up @@ -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 "")
Expand Down Expand Up @@ -365,23 +385,27 @@ 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,
reasoning_effort: str = "",
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,
Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand Down
20 changes: 15 additions & 5 deletions src/droste_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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,
)
Expand All @@ -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 (
Expand All @@ -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): ")
Expand All @@ -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"),
)
Expand Down
2 changes: 1 addition & 1 deletion src/droste_cli/credentials.py
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Loading