diff --git a/README.md b/README.md index 02f8107..bde9f00 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,8 @@ Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-sk | `BUB_API_KEY` | — | Provider key (optional with `bub login openai`) | | `BUB_API_BASE` | — | Custom provider endpoint | | `BUB_API_FORMAT` | `completion` | `completion`, `responses`, or `messages` | +| `BUB_APP_URL` | `https://bub.build/` | App URL exposed to provider adapters when needed | +| `BUB_APP_NAME` | `Bub` | App name exposed to provider adapters when needed | | `BUB_MAX_STEPS` | `50` | Max tool-use loop iterations | | `BUB_MAX_TOKENS` | `1024` | Max tokens per model call | | `BUB_MODEL_TIMEOUT_SECONDS` | — | Model call timeout (seconds) | diff --git a/env.example b/env.example index 25a6ebf..f33af3d 100644 --- a/env.example +++ b/env.example @@ -31,6 +31,12 @@ # - messages: chat-completions-style messages API # BUB_API_FORMAT=completion +# Optional app identity used by provider adapters +# Override these in forks/white-label builds without changing code. +# Set to `null` or empty string to omit a value. +# BUB_APP_URL=https://bub.build/ +# BUB_APP_NAME=Bub + # --------------------------------------------------------------------------- # Channel manager # --------------------------------------------------------------------------- @@ -55,3 +61,5 @@ # --------------------------------------------------------------------------- # BUB_MODEL=openrouter:qwen/qwen3-coder-next # BUB_API_KEY=sk-or-... +# BUB_APP_URL=https://openclaw.ai +# BUB_APP_NAME=OpenClaw diff --git a/src/bub/builtin/agent.py b/src/bub/builtin/agent.py index 6986356..0701910 100644 --- a/src/bub/builtin/agent.py +++ b/src/bub/builtin/agent.py @@ -28,7 +28,6 @@ from bub.utils import workspace_from_state CONTINUE_PROMPT = "Continue the task." -DEFAULT_BUB_HEADERS = {"HTTP-Referer": "https://bub.build/", "X-Title": "Bub"} HINT_RE = re.compile(r"\$([A-Za-z0-9_.-]+)") @@ -222,7 +221,7 @@ async def _run_tools_once( allowed_tools: Collection[str] | None = None, allowed_skills: Collection[str] | None = None, ) -> ToolAutoResult: - extra_options = {"extra_headers": DEFAULT_BUB_HEADERS} if self.settings.model.startswith("openrouter:") else {} + extra_options = _provider_extra_options(self.settings) prompt_text = prompt if isinstance(prompt, str) else _extract_text_from_parts(prompt) if allowed_tools is not None: allowed_tools = {name.casefold() for name in allowed_tools} @@ -294,6 +293,17 @@ def _load_runtime_settings() -> AgentSettings: return AgentSettings.from_env() +def _provider_extra_options(settings: AgentSettings) -> dict[str, Any]: + extra_headers: dict[str, str] = {} + if settings.app_url: + extra_headers["HTTP-Referer"] = settings.app_url + if settings.app_name: + extra_headers["X-Title"] = settings.app_name + if not extra_headers: + return {} + return {"extra_headers": extra_headers} + + @dataclass(frozen=True) class Args: positional: list[str] diff --git a/src/bub/builtin/settings.py b/src/bub/builtin/settings.py index 54a361d..79184a3 100644 --- a/src/bub/builtin/settings.py +++ b/src/bub/builtin/settings.py @@ -11,6 +11,8 @@ DEFAULT_MODEL = "openrouter:qwen/qwen3-coder-next" DEFAULT_MAX_TOKENS = 1024 DEFAULT_HOME = pathlib.Path.home() / ".bub" +DEFAULT_APP_NAME = "Bub" +DEFAULT_APP_URL = "https://bub.build/" class AgentSettings(BaseSettings): @@ -28,6 +30,8 @@ class AgentSettings(BaseSettings): max_steps: int = 50 max_tokens: int = DEFAULT_MAX_TOKENS model_timeout_seconds: int | None = None + app_name: str | None = DEFAULT_APP_NAME + app_url: str | None = DEFAULT_APP_URL verbose: int = Field(default=0, description="Verbosity level for logging. Higher means more verbose.", ge=0, le=2) @classmethod diff --git a/tests/test_builtin_agent.py b/tests/test_builtin_agent.py index 407c0dd..d2d04a2 100644 --- a/tests/test_builtin_agent.py +++ b/tests/test_builtin_agent.py @@ -76,6 +76,7 @@ class _FakeTapeService: def __init__(self, fork_capture: _ForkCapture) -> None: self._fork = fork_capture self.run_tools_model: str | None = None + self.run_tools_extra_headers: dict[str, str] | None = None def session_tape(self, session_id: str, workspace: Any) -> MagicMock: tape = MagicMock() @@ -84,6 +85,7 @@ def session_tape(self, session_id: str, workspace: Any) -> MagicMock: async def fake_run_tools_async(**kwargs: Any) -> ToolAutoResult: self.run_tools_model = kwargs.get("model") + self.run_tools_extra_headers = kwargs.get("extra_headers") return ToolAutoResult(kind="text", text="done", tool_calls=[], tool_results=[], error=None) tape.run_tools_async = fake_run_tools_async @@ -159,3 +161,86 @@ async def test_agent_run_model_defaults_to_none() -> None: await agent.run(session_id="user/s1", prompt="hello", state={"_runtime_workspace": "/tmp"}) # noqa: S108 assert fake_tapes.run_tools_model is None + + +@pytest.mark.asyncio +async def test_agent_run_adds_openrouter_headers_from_settings() -> None: + agent = _make_agent() + agent.settings = AgentSettings( + model="openrouter:qwen/qwen3-coder-next", + api_key="k", + api_base="b", + app_url="https://openclaw.ai", + app_name="OpenClaw", + ) + fork_capture = _ForkCapture() + fake_tapes = _FakeTapeService(fork_capture) + agent.tapes = fake_tapes # type: ignore[assignment] + + await agent.run(session_id="user/s1", prompt="hello", state={"_runtime_workspace": "/tmp"}) # noqa: S108 + + assert fake_tapes.run_tools_extra_headers == { + "HTTP-Referer": "https://openclaw.ai", + "X-Title": "OpenClaw", + } + + +@pytest.mark.asyncio +async def test_agent_run_passes_identity_headers_even_with_runtime_model_override() -> None: + agent = _make_agent() + agent.settings = AgentSettings( + model="openai:gpt-5-codex", + api_key="k", + api_base="b", + app_url="https://openclaw.ai", + app_name="OpenClaw", + ) + fork_capture = _ForkCapture() + fake_tapes = _FakeTapeService(fork_capture) + agent.tapes = fake_tapes # type: ignore[assignment] + + await agent.run( + session_id="user/s1", + prompt="hello", + state={"_runtime_workspace": "/tmp"}, # noqa: S108 + model="openrouter:qwen/qwen3-coder-next", + ) + + assert fake_tapes.run_tools_extra_headers == { + "HTTP-Referer": "https://openclaw.ai", + "X-Title": "OpenClaw", + } + + +@pytest.mark.asyncio +async def test_agent_run_passes_default_identity_headers_for_non_openrouter_models() -> None: + agent = _make_agent() + fork_capture = _ForkCapture() + fake_tapes = _FakeTapeService(fork_capture) + agent.tapes = fake_tapes # type: ignore[assignment] + + await agent.run(session_id="user/s1", prompt="hello", state={"_runtime_workspace": "/tmp"}) # noqa: S108 + + assert fake_tapes.run_tools_extra_headers == { + "HTTP-Referer": "https://bub.build/", + "X-Title": "Bub", + } + + +@pytest.mark.asyncio +async def test_agent_run_omits_empty_identity_headers() -> None: + agent = _make_agent() + agent.settings = AgentSettings( + model="openrouter:qwen/qwen3-coder-next", + api_key="k", + api_base="b", + app_url=None, + app_name="", + ) + fork_capture = _ForkCapture() + fake_tapes = _FakeTapeService(fork_capture) + agent.tapes = fake_tapes # type: ignore[assignment] + + await agent.run(session_id="user/s1", prompt="hello", state={"_runtime_workspace": "/tmp"}) # noqa: S108 + + assert fake_tapes.run_tools_extra_headers is None diff --git a/tests/test_settings_from_env.py b/tests/test_settings_from_env.py index 0fe8bba..80c0a04 100644 --- a/tests/test_settings_from_env.py +++ b/tests/test_settings_from_env.py @@ -90,3 +90,30 @@ def test_from_env_os_environ_overrides_dotenv() -> None: assert isinstance(settings.api_key, dict) assert settings.api_key["openai"] == "sk-from-env" + + +def test_app_identity_has_defaults() -> None: + settings = _from_env_with({}) + + assert settings.app_name == "Bub" + assert settings.app_url == "https://bub.build/" + + +def test_from_env_app_identity_can_be_overridden() -> None: + settings = _from_env_with({ + "BUB_APP_URL": "https://openclaw.ai", + "BUB_APP_NAME": "OpenClaw", + }) + + assert settings.app_name == "OpenClaw" + assert settings.app_url == "https://openclaw.ai" + + +def test_from_env_app_identity_can_be_disabled() -> None: + settings = _from_env_with({ + "BUB_APP_URL": "null", + "BUB_APP_NAME": "", + }) + + assert settings.app_name == "" + assert settings.app_url is None