Skip to content
Open
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
8 changes: 8 additions & 0 deletions env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand All @@ -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
14 changes: 12 additions & 2 deletions src/bub/builtin/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_.-]+)")


Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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]
Expand Down
4 changes: 4 additions & 0 deletions src/bub/builtin/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand Down
85 changes: 85 additions & 0 deletions tests/test_builtin_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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
27 changes: 27 additions & 0 deletions tests/test_settings_from_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading