Skip to content

Commit b468efd

Browse files
committed
Merge branch 'main' of github.com:bubbuild/bub
2 parents 78ca80f + ed0205e commit b468efd

9 files changed

Lines changed: 115 additions & 18 deletions

File tree

README.md

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -103,15 +103,16 @@ Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-sk
103103

104104
## Configuration
105105

106-
| Variable | Default | Description |
107-
| --------------------------- | ---------------------------------- | ----------------------------------------------- |
108-
| `BUB_MODEL` | `openrouter:qwen/qwen3-coder-next` | Model identifier |
109-
| `BUB_API_KEY` || Provider key (optional with `bub login openai`) |
110-
| `BUB_API_BASE` || Custom provider endpoint |
111-
| `BUB_API_FORMAT` | `completion` | `completion`, `responses`, or `messages` |
112-
| `BUB_MAX_STEPS` | `50` | Max tool-use loop iterations |
113-
| `BUB_MAX_TOKENS` | `1024` | Max tokens per model call |
114-
| `BUB_MODEL_TIMEOUT_SECONDS` || Model call timeout (seconds) |
106+
| Variable | Default | Description |
107+
|----------|---------|-------------|
108+
| `BUB_MODEL` | `openrouter:qwen/qwen3-coder-next` | Model identifier |
109+
| `BUB_API_KEY` || Provider key (optional with `bub login openai`) |
110+
| `BUB_API_BASE` || Custom provider endpoint |
111+
| `BUB_API_FORMAT` | `completion` | `completion`, `responses`, or `messages` |
112+
| `BUB_CLIENT_ARGS` | `{"extra_headers":{"HTTP-Referer":"https://bub.build/","X-Title":"Bub"}}` | JSON object forwarded to the underlying model client |
113+
| `BUB_MAX_STEPS` | `50` | Max tool-use loop iterations |
114+
| `BUB_MAX_TOKENS` | `1024` | Max tokens per model call |
115+
| `BUB_MODEL_TIMEOUT_SECONDS` || Model call timeout (seconds) |
115116

116117
## Background
117118

env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@
3131
# - messages: chat-completions-style messages API
3232
# BUB_API_FORMAT=completion
3333

34+
# Optional JSON object forwarded to the underlying model client.
35+
# Default in code includes Bub attribution headers for compatible providers.
36+
# Set to `null` to disable, or provide your own JSON object.
37+
# BUB_CLIENT_ARGS={"extra_headers":{"HTTP-Referer":"https://bub.build/","X-Title":"Bub"}}
38+
3439
# ---------------------------------------------------------------------------
3540
# Channel manager
3641
# ---------------------------------------------------------------------------
@@ -55,3 +60,4 @@
5560
# ---------------------------------------------------------------------------
5661
# BUB_MODEL=openrouter:qwen/qwen3-coder-next
5762
# BUB_API_KEY=sk-or-...
63+
# BUB_CLIENT_ARGS={"extra_headers":{"HTTP-Referer":"https://openclaw.ai","X-Title":"OpenClaw"}}

src/bub/builtin/agent.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
from bub.utils import workspace_from_state
2929

3030
CONTINUE_PROMPT = "Continue the task."
31-
DEFAULT_BUB_HEADERS = {"HTTP-Referer": "https://bub.build/", "X-Title": "Bub"}
3231
HINT_RE = re.compile(r"\$([A-Za-z0-9_.-]+)")
3332

3433

@@ -222,7 +221,6 @@ async def _run_tools_once(
222221
allowed_tools: Collection[str] | None = None,
223222
allowed_skills: Collection[str] | None = None,
224223
) -> ToolAutoResult:
225-
extra_options = {"extra_headers": DEFAULT_BUB_HEADERS} if self.settings.model.startswith("openrouter:") else {}
226224
prompt_text = prompt if isinstance(prompt, str) else _extract_text_from_parts(prompt)
227225
if allowed_tools is not None:
228226
allowed_tools = {name.casefold() for name in allowed_tools}
@@ -240,7 +238,6 @@ async def _run_tools_once(
240238
max_tokens=self.settings.max_tokens,
241239
tools=model_tools(tools),
242240
model=model,
243-
**extra_options,
244241
)
245242

246243
def _system_prompt(self, prompt: str, state: State, allowed_skills: set[str] | None = None) -> str:
@@ -284,6 +281,7 @@ def _build_llm(settings: AgentSettings, tape_store: AsyncTapeStore, tape_context
284281
fallback_models=settings.fallback_models,
285282
api_key_resolver=openai_codex_oauth_resolver(),
286283
tape_store=tape_store,
284+
client_args=settings.client_args,
287285
api_format=settings.api_format,
288286
context=tape_context,
289287
verbose=settings.verbose,

src/bub/builtin/settings.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import re
66
from collections.abc import Callable
77
from functools import lru_cache
8-
from typing import Literal
8+
from typing import Any, Literal
99

1010
from pydantic import Field
1111
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, YamlConfigSettingsSource
@@ -32,6 +32,10 @@ def default_factory() -> dict[str, str] | None:
3232
return default_factory
3333

3434

35+
def default_client_args() -> dict[str, Any]:
36+
return {"extra_headers": {"HTTP-Referer": "https://bub.build/", "X-Title": "Bub"}}
37+
38+
3539
class AgentSettings(BaseSettings):
3640
"""Configuration settings for the Agent."""
3741

@@ -45,6 +49,7 @@ class AgentSettings(BaseSettings):
4549
max_steps: int = 50
4650
max_tokens: int = DEFAULT_MAX_TOKENS
4751
model_timeout_seconds: int | None = None
52+
client_args: dict[str, Any] | None = Field(default_factory=default_client_args)
4853
verbose: int = Field(default=0, description="Verbosity level for logging. Higher means more verbose.", ge=0, le=2)
4954

5055
@classmethod

src/bub/builtin/store.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def __init__(self, parent: AsyncTapeStore | TapeStore) -> None:
3737

3838
@property
3939
def _current(self) -> TapeStore:
40-
return current_store.get(_emtpy_store)
40+
return current_store.get(_empty_store)
4141

4242
@property
4343
def _fork_tape(self) -> str | None:
@@ -52,7 +52,7 @@ async def list_tapes(self) -> list[str]:
5252

5353
async def reset(self, tape: str) -> None:
5454
self._current.reset(tape)
55-
if self._current is _emtpy_store or self._fork_tape != tape:
55+
if self._current is _empty_store or self._fork_tape != tape:
5656
await self._parent.reset(tape)
5757
return
5858
current_tape_was_reset.set(True)
@@ -138,7 +138,7 @@ def append(self, tape: str, entry: TapeEntry) -> None:
138138
pass
139139

140140

141-
_emtpy_store = EmptyTapeStore()
141+
_empty_store = EmptyTapeStore()
142142

143143

144144
class FileTapeStore(InMemoryQueryMixin):

src/bub/builtin/tools.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,9 @@ async def tape_search(param: SearchInput, *, context: ToolContext) -> str:
206206
if "[tape.search]" in entry_str:
207207
continue
208208
lines.append(entry_str)
209-
return f"[tape.search]: {len(entries)} matches" + "".join(f"\n{line}" for line in lines)
209+
return f"[tape.search]: {len(lines)} matches ({len(entries) - len(lines)} filtered)" + "".join(
210+
f"\n{line}" for line in lines
211+
)
210212

211213

212214
@tool(context=True, name="tape.reset")

tests/test_builtin_agent.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,22 @@ def __init__(self, *args: object, **kwargs: object) -> None:
2626
monkeypatch.setattr(agent_module, "LLM", FakeLLM)
2727
monkeypatch.setattr(openai_codex, "openai_codex_oauth_resolver", lambda: resolver)
2828

29-
settings = AgentSettings(model="openai:gpt-5-codex", api_key=None, api_base=None)
29+
settings = AgentSettings(
30+
model="openai:gpt-5-codex",
31+
api_key=None,
32+
api_base=None,
33+
client_args={"extra_headers": {"HTTP-Referer": "https://openclaw.ai", "X-Title": "OpenClaw"}},
34+
)
3035
tape_store = object()
3136

3237
agent_module._build_llm(settings, tape_store, "ctx")
3338

3439
assert captured["args"] == ("openai:gpt-5-codex",)
3540
assert captured["kwargs"]["api_key"] is None
3641
assert captured["kwargs"]["api_base"] is None
42+
assert captured["kwargs"]["client_args"] == {
43+
"extra_headers": {"HTTP-Referer": "https://openclaw.ai", "X-Title": "OpenClaw"},
44+
}
3745
assert captured["kwargs"]["api_key_resolver"] is resolver
3846
assert captured["kwargs"]["tape_store"] is tape_store
3947
assert captured["kwargs"]["context"] == "ctx"

tests/test_settings.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ def test_settings_no_keys_returns_none() -> None:
4242

4343
assert settings.api_key is None
4444
assert settings.api_base is None
45+
assert settings.client_args == {"extra_headers": {"HTTP-Referer": "https://bub.build/", "X-Title": "Bub"}}
4546

4647

4748
def test_settings_provider_names_are_lowercased() -> None:
@@ -74,6 +75,10 @@ def test_settings_load_values_from_yaml(tmp_path: Path) -> None:
7475
openai: sk-yaml
7576
api_base:
7677
openai: https://api.openai.com
78+
client_args:
79+
extra_headers:
80+
HTTP-Referer: https://openclaw.ai
81+
X-Title: OpenClaw
7782
""".strip(),
7883
)
7984

@@ -85,6 +90,9 @@ def test_settings_load_values_from_yaml(tmp_path: Path) -> None:
8590
assert settings.max_steps == 77
8691
assert settings.api_key == {"openai": "sk-yaml"}
8792
assert settings.api_base == {"openai": "https://api.openai.com"}
93+
assert settings.client_args == {
94+
"extra_headers": {"HTTP-Referer": "https://openclaw.ai", "X-Title": "OpenClaw"},
95+
}
8896

8997

9098
def test_env_settings_override_yaml(tmp_path: Path) -> None:
@@ -94,6 +102,10 @@ def test_env_settings_override_yaml(tmp_path: Path) -> None:
94102
model: openai:gpt-5
95103
api_key: sk-yaml
96104
max_steps: 77
105+
client_args:
106+
extra_headers:
107+
HTTP-Referer: https://yaml.example
108+
X-Title: YAML App
97109
""".strip(),
98110
)
99111

@@ -103,6 +115,7 @@ def test_env_settings_override_yaml(tmp_path: Path) -> None:
103115
"BUB_HOME": str(tmp_path),
104116
"BUB_MODEL": "anthropic:claude-3-7-sonnet",
105117
"BUB_API_KEY": "sk-env",
118+
"BUB_CLIENT_ARGS": '{"extra_headers":{"HTTP-Referer":"https://env.example","X-Title":"Env App"}}',
106119
"BUB_MAX_STEPS": "12",
107120
},
108121
clear=True,
@@ -112,6 +125,15 @@ def test_env_settings_override_yaml(tmp_path: Path) -> None:
112125
assert settings.model == "anthropic:claude-3-7-sonnet"
113126
assert settings.api_key == "sk-env"
114127
assert settings.max_steps == 12
128+
assert settings.client_args == {
129+
"extra_headers": {"HTTP-Referer": "https://env.example", "X-Title": "Env App"},
130+
}
131+
132+
133+
def test_settings_client_args_can_be_disabled() -> None:
134+
settings = _settings_with_env({"BUB_CLIENT_ARGS": "null"})
135+
136+
assert settings.client_args is None
115137

116138

117139
def test_load_settings_reads_yaml_from_bub_home(tmp_path: Path) -> None:

tests/test_tape_search_output.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
5+
import pytest
6+
from republic import ToolContext
7+
8+
import bub.builtin.tools as builtin_tools
9+
from bub.builtin.tools import tape_search
10+
11+
12+
@dataclass(frozen=True)
13+
class _FakeEntry:
14+
date: str
15+
payload: object
16+
17+
18+
class _FakeTapes:
19+
def __init__(self, entries: list[_FakeEntry]) -> None:
20+
self._entries = entries
21+
self._store = object()
22+
23+
async def search(self, _query: object) -> list[_FakeEntry]:
24+
return list(self._entries)
25+
26+
27+
class _FakeAgent:
28+
def __init__(self, entries: list[_FakeEntry]) -> None:
29+
self.tapes = _FakeTapes(entries)
30+
31+
32+
@pytest.mark.asyncio
33+
async def test_tape_search_reports_shown_matches_and_filtered_count(monkeypatch) -> None:
34+
entries = [
35+
_FakeEntry(date="2026-01-01T00:00:00Z", payload={"content": "ok"}),
36+
_FakeEntry(date="2026-01-01T00:00:01Z", payload={"content": "[tape.search]: 1 matches"}),
37+
]
38+
monkeypatch.setattr(builtin_tools, "_get_agent", lambda _context: _FakeAgent(entries))
39+
40+
output = await tape_search.run(query="x", context=ToolContext(tape="tape", run_id="run", state={}))
41+
42+
assert output.splitlines()[0] == "[tape.search]: 1 matches (1 filtered)"
43+
44+
45+
@pytest.mark.asyncio
46+
async def test_tape_search_reports_zero_filtered_explicitly(monkeypatch) -> None:
47+
entries = [
48+
_FakeEntry(date="2026-01-01T00:00:00Z", payload={"content": "a"}),
49+
_FakeEntry(date="2026-01-01T00:00:01Z", payload={"content": "b"}),
50+
]
51+
monkeypatch.setattr(builtin_tools, "_get_agent", lambda _context: _FakeAgent(entries))
52+
53+
output = await tape_search.run(query="x", context=ToolContext(tape="tape", run_id="run", state={}))
54+
55+
assert output.splitlines()[0] == "[tape.search]: 2 matches (0 filtered)"

0 commit comments

Comments
 (0)