diff --git a/README.md b/README.md index e0bf7384..da99edce 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,10 @@ Create a `.env` file in the project root (or export these in your shell): ```bash HF_TOKEN= # HF Router inference + Hub actions GITHUB_TOKEN= +HF_BILL_TO= # optional: bill Inference Providers usage to an org ``` -All API-based model calls go through Hugging Face [Inference Providers](https://huggingface.co/docs/inference-providers/en/index), so your `HF_TOKEN` must be allowed to make Inference Provider calls. If no `HF_TOKEN` is set, the CLI will prompt you to paste one on first launch unless you start on a local model. To get a `GITHUB_TOKEN` follow the tutorial [here](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token). See the [local models section below](#local-models) for instructions on using agents that run on your hardware. +All API-based model calls go through Hugging Face [Inference Providers](https://huggingface.co/docs/inference-providers/en/index), so your `HF_TOKEN` must be allowed to make Inference Provider calls. Set `HF_BILL_TO` to an org you belong to to charge inference to that org's credits instead of your personal monthly allowance — this sends an `X-HF-Bill-To` header on every router call (main agent, research sub-agent, and compaction alike). If no `HF_TOKEN` is set, the CLI will prompt you to paste one on first launch unless you start on a local model. To get a `GITHUB_TOKEN` follow the tutorial [here](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token). See the [local models section below](#local-models) for instructions on using agents that run on your hardware. ### Usage diff --git a/agent/config.py b/agent/config.py index c6784db9..9ec46ef8 100644 --- a/agent/config.py +++ b/agent/config.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import Any, Literal, Union -from dotenv import load_dotenv +from dotenv import find_dotenv, load_dotenv from fastmcp.mcp_config import ( RemoteMCPServer, StdioMCPServer, @@ -207,10 +207,12 @@ def load_config( Use ${VAR_NAME} in your JSON for any secret. Automatically loads from .env file. """ - # Load .env from project root first (so it works from any directory), - # then CWD .env can override if present + # Repo .env first, then let the launch directory's .env fill in any gaps. + # find_dotenv needs usecwd=True to look at the launch CWD, not this file. load_dotenv(_PROJECT_ROOT / ".env") - load_dotenv(override=False) + cwd_dotenv = find_dotenv(usecwd=True) + if cwd_dotenv: + load_dotenv(cwd_dotenv, override=False) raw_config = _load_json_config(Path(config_path)) if include_user_defaults: diff --git a/agent/core/llm_params.py b/agent/core/llm_params.py index d2f821c2..f4dcccec 100644 --- a/agent/core/llm_params.py +++ b/agent/core/llm_params.py @@ -33,6 +33,18 @@ def _resolve_hf_router_token(session_hf_token: str | None = None) -> str | None: # an accepted-looking value, so this stays intentionally small and generic. _HF_EFFORTS = {"low", "medium", "high"} +# When ``HF_BILL_TO`` names an org you belong to, the router charges that org's +# credits instead of your personal allowance — same header huggingface_hub's +# ``bill_to=`` sets, applied here because we go through LiteLLM, not InferenceClient. +HF_BILL_TO_ENV = "HF_BILL_TO" +HF_BILL_TO_HEADER = "X-HF-Bill-To" + + +def _resolve_hf_bill_to() -> str | None: + """Org to bill HF Inference Providers usage to, or None if unset.""" + value = os.environ.get(HF_BILL_TO_ENV) + return value.strip() or None if value else None + def _hf_router_effort_level(reasoning_effort: str) -> str: level = "low" if reasoning_effort == "minimal" else reasoning_effort @@ -120,6 +132,10 @@ def _resolve_llm_params( 1. session.hf_token — the user's own token (CLI / OAuth / cache file). 2. huggingface_hub cache — ``HF_TOKEN`` / ``HUGGING_FACE_HUB_TOKEN`` / local ``hf auth login`` cache. + + When ``HF_BILL_TO`` is set, an ``X-HF-Bill-To`` header is attached to + HF-router calls so Inference Providers usage is charged to that org's + credits instead of the token owner's personal monthly allowance. """ normalized_model = strip_huggingface_model_prefix(model_name) or model_name @@ -136,6 +152,9 @@ def _resolve_llm_params( "api_base": HF_ROUTER_BASE_URL, "api_key": api_key, } + bill_to = _resolve_hf_bill_to() + if bill_to: + params["extra_headers"] = {HF_BILL_TO_HEADER: bill_to} if reasoning_effort: hf_level = _hf_router_effort_level(reasoning_effort) if hf_level not in _HF_EFFORTS: diff --git a/tests/unit/test_llm_params.py b/tests/unit/test_llm_params.py index a985025a..59793e48 100644 --- a/tests/unit/test_llm_params.py +++ b/tests/unit/test_llm_params.py @@ -2,6 +2,8 @@ from agent.core.hf_tokens import resolve_hf_request_token from agent.core.llm_params import ( + HF_BILL_TO_ENV, + HF_BILL_TO_HEADER, UnsupportedEffortError, _resolve_hf_router_token, _resolve_llm_params, @@ -9,6 +11,12 @@ from agent.core.model_ids import HF_ROUTER_BASE_URL +@pytest.fixture(autouse=True) +def _clear_bill_to(monkeypatch): + """Keep the developer's own HF_BILL_TO out of the default-behavior tests.""" + monkeypatch.delenv(HF_BILL_TO_ENV, raising=False) + + def test_hf_router_params_for_default_model_uses_session_token(): params = _resolve_llm_params( "anthropic/claude-opus-4.8:fal-ai", @@ -63,13 +71,39 @@ def test_router_params_fall_back_to_hf_cache_when_session_token_missing(monkeypa assert "extra_headers" not in params -def test_router_params_never_set_bill_to_headers(): +def test_router_params_omit_bill_to_header_when_env_unset(): params = _resolve_llm_params("moonshotai/Kimi-K2.7-Code", "session-token") assert params["api_key"] == "session-token" assert "extra_headers" not in params +def test_router_params_add_bill_to_header_when_env_set(monkeypatch): + monkeypatch.setenv(HF_BILL_TO_ENV, "my-org") + + params = _resolve_llm_params("moonshotai/Kimi-K2.7-Code", "session-token") + + assert params["extra_headers"] == {HF_BILL_TO_HEADER: "my-org"} + + +def test_router_params_strip_whitespace_and_ignore_blank_bill_to(monkeypatch): + monkeypatch.setenv(HF_BILL_TO_ENV, " my-org ") + assert _resolve_llm_params("moonshotai/Kimi-K2.7-Code")["extra_headers"] == { + HF_BILL_TO_HEADER: "my-org" + } + + monkeypatch.setenv(HF_BILL_TO_ENV, " ") + assert "extra_headers" not in _resolve_llm_params("moonshotai/Kimi-K2.7-Code") + + +def test_local_model_params_never_get_bill_to_header(monkeypatch): + monkeypatch.setenv(HF_BILL_TO_ENV, "my-org") + + params = _resolve_llm_params("ollama/llama3.1:8b") + + assert "extra_headers" not in params + + def test_huggingface_prefix_is_stripped_for_router_calls(): params = _resolve_llm_params("huggingface/openai/gpt-5.5:fal-ai")