bub 0.3.1__tar.gz → 0.3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {bub-0.3.1 → bub-0.3.2}/PKG-INFO +27 -18
- {bub-0.3.1 → bub-0.3.2}/README.md +26 -17
- {bub-0.3.1 → bub-0.3.2}/pyproject.toml +1 -1
- {bub-0.3.1 → bub-0.3.2}/src/bub/__init__.py +1 -1
- {bub-0.3.1 → bub-0.3.2}/src/bub/builtin/agent.py +2 -6
- bub-0.3.2/src/bub/builtin/settings.py +71 -0
- {bub-0.3.1 → bub-0.3.2}/src/bub/builtin/shell_manager.py +4 -0
- {bub-0.3.1 → bub-0.3.2}/src/bub/builtin/tools.py +2 -5
- {bub-0.3.1 → bub-0.3.2}/src/bub/framework.py +4 -0
- {bub-0.3.1 → bub-0.3.2}/src/bub/skills.py +1 -1
- {bub-0.3.1 → bub-0.3.2}/src/bub/tools.py +45 -0
- {bub-0.3.1 → bub-0.3.2}/src/skills/telegram/SKILL.md +0 -1
- {bub-0.3.1 → bub-0.3.2}/tests/test_builtin_tools.py +27 -3
- bub-0.3.2/tests/test_settings.py +134 -0
- {bub-0.3.1 → bub-0.3.2}/tests/test_subagent_tool.py +48 -0
- {bub-0.3.1 → bub-0.3.2}/tests/test_tools.py +31 -1
- bub-0.3.1/src/bub/builtin/settings.py +0 -63
- bub-0.3.1/tests/test_settings_from_env.py +0 -92
- {bub-0.3.1 → bub-0.3.2}/LICENSE +0 -0
- {bub-0.3.1 → bub-0.3.2}/src/bub/__main__.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/src/bub/builtin/__init__.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/src/bub/builtin/auth.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/src/bub/builtin/cli.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/src/bub/builtin/context.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/src/bub/builtin/hook_impl.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/src/bub/builtin/store.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/src/bub/builtin/tape.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/src/bub/channels/__init__.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/src/bub/channels/base.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/src/bub/channels/cli/__init__.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/src/bub/channels/cli/renderer.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/src/bub/channels/handler.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/src/bub/channels/manager.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/src/bub/channels/message.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/src/bub/channels/telegram.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/src/bub/envelope.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/src/bub/hook_runtime.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/src/bub/hookspecs.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/src/bub/types.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/src/bub/utils.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/src/skills/README.md +0 -0
- {bub-0.3.1 → bub-0.3.2}/src/skills/gh/SKILL.md +0 -0
- {bub-0.3.1 → bub-0.3.2}/src/skills/skill-creator/SKILL.md +0 -0
- {bub-0.3.1 → bub-0.3.2}/src/skills/skill-creator/license.txt +0 -0
- {bub-0.3.1 → bub-0.3.2}/src/skills/skill-creator/scripts/init_skill.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/src/skills/skill-creator/scripts/quick_validate.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/src/skills/telegram/scripts/telegram_edit.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/src/skills/telegram/scripts/telegram_send.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/tests/test_builtin_agent.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/tests/test_builtin_cli.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/tests/test_builtin_hook_impl.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/tests/test_channels.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/tests/test_cli_help.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/tests/test_envelope.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/tests/test_file_tape_store_entry_ids.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/tests/test_fork_store_merge_back.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/tests/test_framework.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/tests/test_hook_runtime.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/tests/test_image_message.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/tests/test_skills.py +0 -0
- {bub-0.3.1 → bub-0.3.2}/tests/test_utils.py +0 -0
{bub-0.3.1 → bub-0.3.2}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: bub
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: A common shape for agents that live alongside people.
|
|
5
5
|
Author-Email: Chojan Shang <psiace@apache.org>, Frost Ming <me@frostming.com>, Yihong <zouzou0208@gmail.com>
|
|
6
6
|
Classifier: Intended Audience :: Developers
|
|
@@ -31,7 +31,16 @@ Description-Content-Type: text/markdown
|
|
|
31
31
|
|
|
32
32
|
# Bub
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
<div align="center">
|
|
35
|
+
|
|
36
|
+
<picture>
|
|
37
|
+
<source srcset="https://bub.build/dark.png" media="(prefers-color-scheme: dark)">
|
|
38
|
+
<img alt="Bub logo" src="https://bub.build/light.png" width="200">
|
|
39
|
+
</picture>
|
|
40
|
+
|
|
41
|
+
<p><strong>A common shape for agents that live alongside people.</strong></p>
|
|
42
|
+
|
|
43
|
+
</div>
|
|
35
44
|
|
|
36
45
|
Bub started in group chats. Not as a demo or a personal assistant, but as a teammate that had to coexist with real humans and other agents in the same messy conversations — concurrent tasks, incomplete context, and nobody waiting.
|
|
37
46
|
|
|
@@ -113,27 +122,27 @@ See the [Extension Guide](https://bub.build/extension-guide/) for hook semantics
|
|
|
113
122
|
|
|
114
123
|
## CLI
|
|
115
124
|
|
|
116
|
-
| Command
|
|
117
|
-
|
|
118
|
-
| `bub chat`
|
|
119
|
-
| `bub run MESSAGE`
|
|
120
|
-
| `bub gateway`
|
|
121
|
-
| `bub login openai` | OpenAI Codex OAuth
|
|
122
|
-
| `bub hooks`
|
|
125
|
+
| Command | Description |
|
|
126
|
+
| ------------------ | --------------------------------- |
|
|
127
|
+
| `bub chat` | Interactive REPL |
|
|
128
|
+
| `bub run MESSAGE` | One-shot turn |
|
|
129
|
+
| `bub gateway` | Channel listener (Telegram, etc.) |
|
|
130
|
+
| `bub login openai` | OpenAI Codex OAuth |
|
|
131
|
+
| `bub hooks` | Print hook-to-plugin bindings |
|
|
123
132
|
|
|
124
133
|
Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-skill`, `,fs.read path=README.md`).
|
|
125
134
|
|
|
126
135
|
## Configuration
|
|
127
136
|
|
|
128
|
-
| Variable
|
|
129
|
-
|
|
130
|
-
| `BUB_MODEL`
|
|
131
|
-
| `BUB_API_KEY`
|
|
132
|
-
| `BUB_API_BASE`
|
|
133
|
-
| `BUB_API_FORMAT`
|
|
134
|
-
| `BUB_MAX_STEPS`
|
|
135
|
-
| `BUB_MAX_TOKENS`
|
|
136
|
-
| `BUB_MODEL_TIMEOUT_SECONDS` | —
|
|
137
|
+
| Variable | Default | Description |
|
|
138
|
+
| --------------------------- | ---------------------------------- | ----------------------------------------------- |
|
|
139
|
+
| `BUB_MODEL` | `openrouter:qwen/qwen3-coder-next` | Model identifier |
|
|
140
|
+
| `BUB_API_KEY` | — | Provider key (optional with `bub login openai`) |
|
|
141
|
+
| `BUB_API_BASE` | — | Custom provider endpoint |
|
|
142
|
+
| `BUB_API_FORMAT` | `completion` | `completion`, `responses`, or `messages` |
|
|
143
|
+
| `BUB_MAX_STEPS` | `50` | Max tool-use loop iterations |
|
|
144
|
+
| `BUB_MAX_TOKENS` | `1024` | Max tokens per model call |
|
|
145
|
+
| `BUB_MODEL_TIMEOUT_SECONDS` | — | Model call timeout (seconds) |
|
|
137
146
|
|
|
138
147
|
## Background
|
|
139
148
|
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
# Bub
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<div align="center">
|
|
4
|
+
|
|
5
|
+
<picture>
|
|
6
|
+
<source srcset="https://bub.build/dark.png" media="(prefers-color-scheme: dark)">
|
|
7
|
+
<img alt="Bub logo" src="https://bub.build/light.png" width="200">
|
|
8
|
+
</picture>
|
|
9
|
+
|
|
10
|
+
<p><strong>A common shape for agents that live alongside people.</strong></p>
|
|
11
|
+
|
|
12
|
+
</div>
|
|
4
13
|
|
|
5
14
|
Bub started in group chats. Not as a demo or a personal assistant, but as a teammate that had to coexist with real humans and other agents in the same messy conversations — concurrent tasks, incomplete context, and nobody waiting.
|
|
6
15
|
|
|
@@ -82,27 +91,27 @@ See the [Extension Guide](https://bub.build/extension-guide/) for hook semantics
|
|
|
82
91
|
|
|
83
92
|
## CLI
|
|
84
93
|
|
|
85
|
-
| Command
|
|
86
|
-
|
|
87
|
-
| `bub chat`
|
|
88
|
-
| `bub run MESSAGE`
|
|
89
|
-
| `bub gateway`
|
|
90
|
-
| `bub login openai` | OpenAI Codex OAuth
|
|
91
|
-
| `bub hooks`
|
|
94
|
+
| Command | Description |
|
|
95
|
+
| ------------------ | --------------------------------- |
|
|
96
|
+
| `bub chat` | Interactive REPL |
|
|
97
|
+
| `bub run MESSAGE` | One-shot turn |
|
|
98
|
+
| `bub gateway` | Channel listener (Telegram, etc.) |
|
|
99
|
+
| `bub login openai` | OpenAI Codex OAuth |
|
|
100
|
+
| `bub hooks` | Print hook-to-plugin bindings |
|
|
92
101
|
|
|
93
102
|
Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-skill`, `,fs.read path=README.md`).
|
|
94
103
|
|
|
95
104
|
## Configuration
|
|
96
105
|
|
|
97
|
-
| Variable
|
|
98
|
-
|
|
99
|
-
| `BUB_MODEL`
|
|
100
|
-
| `BUB_API_KEY`
|
|
101
|
-
| `BUB_API_BASE`
|
|
102
|
-
| `BUB_API_FORMAT`
|
|
103
|
-
| `BUB_MAX_STEPS`
|
|
104
|
-
| `BUB_MAX_TOKENS`
|
|
105
|
-
| `BUB_MODEL_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_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
115
|
|
|
107
116
|
## Background
|
|
108
117
|
|
|
@@ -18,7 +18,7 @@ from loguru import logger
|
|
|
18
18
|
from republic import LLM, AsyncTapeStore, TapeContext, ToolAutoResult, ToolContext
|
|
19
19
|
from republic.tape import InMemoryTapeStore, Tape
|
|
20
20
|
|
|
21
|
-
from bub.builtin.settings import AgentSettings
|
|
21
|
+
from bub.builtin.settings import AgentSettings, load_settings
|
|
22
22
|
from bub.builtin.store import ForkTapeStore
|
|
23
23
|
from bub.builtin.tape import TapeService
|
|
24
24
|
from bub.framework import BubFramework
|
|
@@ -36,7 +36,7 @@ class Agent:
|
|
|
36
36
|
"""Agent that processes prompts using hooks and tools. Backed by republic."""
|
|
37
37
|
|
|
38
38
|
def __init__(self, framework: BubFramework) -> None:
|
|
39
|
-
self.settings =
|
|
39
|
+
self.settings = load_settings()
|
|
40
40
|
self.framework = framework
|
|
41
41
|
|
|
42
42
|
@cached_property
|
|
@@ -290,10 +290,6 @@ def _build_llm(settings: AgentSettings, tape_store: AsyncTapeStore, tape_context
|
|
|
290
290
|
)
|
|
291
291
|
|
|
292
292
|
|
|
293
|
-
def _load_runtime_settings() -> AgentSettings:
|
|
294
|
-
return AgentSettings.from_env()
|
|
295
|
-
|
|
296
|
-
|
|
297
293
|
@dataclass(frozen=True)
|
|
298
294
|
class Args:
|
|
299
295
|
positional: list[str]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import pathlib
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from pydantic import Field
|
|
11
|
+
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, YamlConfigSettingsSource
|
|
12
|
+
|
|
13
|
+
DEFAULT_MODEL = "openrouter:qwen/qwen3-coder-next"
|
|
14
|
+
DEFAULT_MAX_TOKENS = 1024
|
|
15
|
+
DEFAULT_HOME = pathlib.Path.home() / ".bub"
|
|
16
|
+
DEFAULT_CONFIG_FILE = DEFAULT_HOME / "config.yml"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def provider_specific(setting_name: str) -> Callable[[], dict[str, str] | None]:
|
|
20
|
+
def default_factory() -> dict[str, str] | None:
|
|
21
|
+
setting_regex = re.compile(rf"^BUB_(.+)_{setting_name.upper()}$")
|
|
22
|
+
loaded_env = os.environ
|
|
23
|
+
result: dict[str, str] = {}
|
|
24
|
+
for key, value in loaded_env.items():
|
|
25
|
+
if value is None:
|
|
26
|
+
continue
|
|
27
|
+
if match := setting_regex.match(key):
|
|
28
|
+
provider = match.group(1).lower()
|
|
29
|
+
result[provider] = value
|
|
30
|
+
return result or None
|
|
31
|
+
|
|
32
|
+
return default_factory
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AgentSettings(BaseSettings):
|
|
36
|
+
"""Configuration settings for the Agent."""
|
|
37
|
+
|
|
38
|
+
model_config = SettingsConfigDict(env_prefix="BUB_", env_parse_none_str="null", extra="ignore")
|
|
39
|
+
home: pathlib.Path = Field(default=DEFAULT_HOME)
|
|
40
|
+
model: str = DEFAULT_MODEL
|
|
41
|
+
fallback_models: list[str] | None = None
|
|
42
|
+
api_key: str | dict[str, str] | None = Field(default_factory=provider_specific("api_key"))
|
|
43
|
+
api_base: str | dict[str, str] | None = Field(default_factory=provider_specific("api_base"))
|
|
44
|
+
api_format: Literal["completion", "responses", "messages"] = "completion"
|
|
45
|
+
max_steps: int = 50
|
|
46
|
+
max_tokens: int = DEFAULT_MAX_TOKENS
|
|
47
|
+
model_timeout_seconds: int | None = None
|
|
48
|
+
verbose: int = Field(default=0, description="Verbosity level for logging. Higher means more verbose.", ge=0, le=2)
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def settings_customise_sources(
|
|
52
|
+
cls,
|
|
53
|
+
settings_cls: type[BaseSettings],
|
|
54
|
+
init_settings: PydanticBaseSettingsSource,
|
|
55
|
+
env_settings: PydanticBaseSettingsSource,
|
|
56
|
+
dotenv_settings: PydanticBaseSettingsSource,
|
|
57
|
+
file_secret_settings: PydanticBaseSettingsSource,
|
|
58
|
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
59
|
+
home = os.getenv("BUB_HOME", str(DEFAULT_HOME))
|
|
60
|
+
return (
|
|
61
|
+
init_settings,
|
|
62
|
+
env_settings,
|
|
63
|
+
dotenv_settings,
|
|
64
|
+
YamlConfigSettingsSource(settings_cls, yaml_file=pathlib.Path(home) / "config.yml"),
|
|
65
|
+
file_secret_settings,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@lru_cache(maxsize=1)
|
|
70
|
+
def load_settings() -> AgentSettings:
|
|
71
|
+
return AgentSettings()
|
|
@@ -53,6 +53,9 @@ class ShellManager:
|
|
|
53
53
|
except KeyError as exc:
|
|
54
54
|
raise KeyError(f"unknown shell id: {shell_id}") from exc
|
|
55
55
|
|
|
56
|
+
def release(self, shell_id: str) -> ManagedShell | None:
|
|
57
|
+
return self._shells.pop(shell_id, None)
|
|
58
|
+
|
|
56
59
|
async def terminate(self, shell_id: str) -> ManagedShell:
|
|
57
60
|
shell = self.get(shell_id)
|
|
58
61
|
if shell.returncode is not None:
|
|
@@ -80,6 +83,7 @@ class ShellManager:
|
|
|
80
83
|
for task in shell.read_tasks:
|
|
81
84
|
with contextlib.suppress(asyncio.CancelledError):
|
|
82
85
|
await task
|
|
86
|
+
self._shells.pop(shell.shell_id, None)
|
|
83
87
|
|
|
84
88
|
async def _drain_stream(
|
|
85
89
|
self,
|
|
@@ -11,7 +11,7 @@ from republic import AsyncTapeStore, TapeQuery, ToolContext
|
|
|
11
11
|
|
|
12
12
|
from bub.builtin.shell_manager import shell_manager
|
|
13
13
|
from bub.skills import discover_skills
|
|
14
|
-
from bub.tools import
|
|
14
|
+
from bub.tools import resolve_tool_names, tool
|
|
15
15
|
|
|
16
16
|
if TYPE_CHECKING:
|
|
17
17
|
from bub.builtin.agent import Agent
|
|
@@ -263,10 +263,7 @@ async def run_subagent(param: SubAgentInput, *, context: ToolContext) -> str:
|
|
|
263
263
|
else:
|
|
264
264
|
subagent_session = param.session
|
|
265
265
|
state = {**context.state, "session_id": subagent_session}
|
|
266
|
-
|
|
267
|
-
allowed_tools = set(param.allowed_tools) - {"subagent"}
|
|
268
|
-
else:
|
|
269
|
-
allowed_tools = set(REGISTRY.keys()) - {"subagent"}
|
|
266
|
+
allowed_tools = resolve_tool_names(param.allowed_tools or None, exclude={"subagent"})
|
|
270
267
|
return await agent.run(
|
|
271
268
|
session_id=subagent_session,
|
|
272
269
|
prompt=param.prompt,
|
|
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any
|
|
|
8
8
|
|
|
9
9
|
import pluggy
|
|
10
10
|
import typer
|
|
11
|
+
from dotenv import load_dotenv
|
|
11
12
|
from loguru import logger
|
|
12
13
|
from republic import AsyncTapeStore, TapeContext
|
|
13
14
|
from republic.tape import TapeStore
|
|
@@ -21,6 +22,9 @@ if TYPE_CHECKING:
|
|
|
21
22
|
from bub.channels.base import Channel
|
|
22
23
|
|
|
23
24
|
|
|
25
|
+
load_dotenv()
|
|
26
|
+
|
|
27
|
+
|
|
24
28
|
@dataclass(frozen=True)
|
|
25
29
|
class PluginStatus:
|
|
26
30
|
is_success: bool
|
|
@@ -172,7 +172,7 @@ def render_skills_prompt(skills: list[SkillMetadata], expanded_skills: Collectio
|
|
|
172
172
|
for skill in skills:
|
|
173
173
|
line = f"- {skill.name}: {skill.description}"
|
|
174
174
|
if skill.name in expanded_skills:
|
|
175
|
-
line += f" Location: {skill.location}"
|
|
175
|
+
line += f"\n Location: {skill.location}"
|
|
176
176
|
body = skill.body()
|
|
177
177
|
if body:
|
|
178
178
|
line += f"\n{body}"
|
|
@@ -136,6 +136,51 @@ def _to_model_name(name: str) -> str:
|
|
|
136
136
|
return name.replace(".", "_")
|
|
137
137
|
|
|
138
138
|
|
|
139
|
+
def _tool_name_index() -> dict[str, str]:
|
|
140
|
+
real_names = {tool_name.casefold(): tool_name for tool_name in REGISTRY}
|
|
141
|
+
alias_names = {_to_model_name(tool_name).casefold(): tool_name for tool_name in REGISTRY}
|
|
142
|
+
return {**alias_names, **real_names}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def resolve_tool_name(name: str) -> str | None:
|
|
146
|
+
"""Resolve a user/model-provided tool name to the runtime registry name."""
|
|
147
|
+
key = name.strip().casefold()
|
|
148
|
+
if not key:
|
|
149
|
+
return None
|
|
150
|
+
return _tool_name_index().get(key)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _resolve_explicit_tool_names(names: Iterable[str]) -> tuple[set[str], set[str]]:
|
|
154
|
+
resolved: set[str] = set()
|
|
155
|
+
unknown: set[str] = set()
|
|
156
|
+
for name in names:
|
|
157
|
+
normalized_name = name.strip()
|
|
158
|
+
if resolved_name := resolve_tool_name(normalized_name):
|
|
159
|
+
resolved.add(resolved_name)
|
|
160
|
+
else:
|
|
161
|
+
unknown.add(normalized_name)
|
|
162
|
+
return resolved, unknown
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _raise_unknown_tool_names(names: set[str]) -> None:
|
|
166
|
+
formatted = ", ".join(sorted(repr(name) for name in names))
|
|
167
|
+
raise ValueError(f"unknown tool name(s): {formatted}")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def resolve_tool_names(names: Iterable[str] | None = None, *, exclude: Iterable[str] = ()) -> set[str]:
|
|
171
|
+
"""Resolve tool names from either runtime names or model-facing aliases."""
|
|
172
|
+
excluded, unknown_excluded = _resolve_explicit_tool_names(exclude)
|
|
173
|
+
if unknown_excluded:
|
|
174
|
+
_raise_unknown_tool_names(unknown_excluded)
|
|
175
|
+
if names is None:
|
|
176
|
+
return set(REGISTRY) - excluded
|
|
177
|
+
|
|
178
|
+
resolved, unknown = _resolve_explicit_tool_names(names)
|
|
179
|
+
if unknown:
|
|
180
|
+
_raise_unknown_tool_names(unknown)
|
|
181
|
+
return resolved - excluded
|
|
182
|
+
|
|
183
|
+
|
|
139
184
|
def model_tools(tools: Iterable[Tool]) -> list[Tool]:
|
|
140
185
|
"""Helper to convert a list of Tool instances into a format accepted by LLMs."""
|
|
141
186
|
return [replace(tool, name=_to_model_name(tool.name)) for tool in tools]
|
|
@@ -9,6 +9,8 @@ from republic import ToolContext
|
|
|
9
9
|
from republic.core.errors import ErrorKind
|
|
10
10
|
from republic.tools.executor import ToolExecutor
|
|
11
11
|
|
|
12
|
+
import bub.builtin.tools as builtin_tools
|
|
13
|
+
from bub.builtin.shell_manager import ShellManager
|
|
12
14
|
from bub.builtin.tools import bash, bash_output, kill_bash
|
|
13
15
|
|
|
14
16
|
|
|
@@ -27,6 +29,28 @@ async def test_bash_returns_stdout_for_foreground_command(tmp_path) -> None:
|
|
|
27
29
|
assert result == "hello"
|
|
28
30
|
|
|
29
31
|
|
|
32
|
+
@pytest.mark.asyncio
|
|
33
|
+
async def test_foreground_bash_releases_shell_from_shell_manager(tmp_path, monkeypatch) -> None:
|
|
34
|
+
manager = ShellManager()
|
|
35
|
+
monkeypatch.setattr(builtin_tools, "shell_manager", manager)
|
|
36
|
+
|
|
37
|
+
result = await bash.run(cmd=_python_shell("print('hello')"), context=_tool_context(tmp_path))
|
|
38
|
+
|
|
39
|
+
assert result == "hello"
|
|
40
|
+
assert manager._shells == {}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.mark.asyncio
|
|
44
|
+
async def test_foreground_bash_releases_shell_when_command_fails(tmp_path, monkeypatch) -> None:
|
|
45
|
+
manager = ShellManager()
|
|
46
|
+
monkeypatch.setattr(builtin_tools, "shell_manager", manager)
|
|
47
|
+
|
|
48
|
+
with pytest.raises(RuntimeError, match="command exited with code"):
|
|
49
|
+
await bash.run(cmd=_python_shell("import sys; sys.exit(2)"), context=_tool_context(tmp_path))
|
|
50
|
+
|
|
51
|
+
assert manager._shells == {}
|
|
52
|
+
|
|
53
|
+
|
|
30
54
|
@pytest.mark.asyncio
|
|
31
55
|
async def test_bash_non_zero_exit_is_returned_as_tool_error(tmp_path) -> None:
|
|
32
56
|
command = _python_shell("import sys; print('boom'); sys.exit(7)")
|
|
@@ -71,7 +95,7 @@ async def test_background_bash_exposes_output_via_bash_output(tmp_path) -> None:
|
|
|
71
95
|
|
|
72
96
|
|
|
73
97
|
@pytest.mark.asyncio
|
|
74
|
-
async def
|
|
98
|
+
async def test_kill_bash_terminates_background_process_and_releases_shell(tmp_path) -> None:
|
|
75
99
|
started = await bash.run(
|
|
76
100
|
cmd=_python_shell("import time; time.sleep(10)"),
|
|
77
101
|
background=True,
|
|
@@ -80,11 +104,11 @@ async def test_kill_bash_terminates_background_process(tmp_path) -> None:
|
|
|
80
104
|
shell_id = started.removeprefix("started: ").strip()
|
|
81
105
|
|
|
82
106
|
killed = await kill_bash.run(shell_id=shell_id)
|
|
83
|
-
output = await bash_output.run(shell_id=shell_id)
|
|
84
107
|
|
|
85
108
|
assert killed.startswith(f"id: {shell_id}\nstatus: exited\nexit_code: ")
|
|
86
109
|
assert "exit_code: null" not in killed
|
|
87
|
-
|
|
110
|
+
with pytest.raises(KeyError, match="unknown shell id"):
|
|
111
|
+
await bash_output.run(shell_id=shell_id)
|
|
88
112
|
|
|
89
113
|
|
|
90
114
|
@pytest.mark.asyncio
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
from bub.builtin.settings import AgentSettings, load_settings
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _settings_with_env(env: dict[str, str]) -> AgentSettings:
|
|
10
|
+
with patch.dict("os.environ", env, clear=True):
|
|
11
|
+
return AgentSettings()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _write_config(home: Path, content: str) -> None:
|
|
15
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
(home / "config.yml").write_text(content, encoding="utf-8")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_settings_single_api_key_and_base() -> None:
|
|
20
|
+
settings = _settings_with_env({"BUB_API_KEY": "sk-test", "BUB_API_BASE": "https://api.example.com"})
|
|
21
|
+
|
|
22
|
+
assert isinstance(settings.api_key, str)
|
|
23
|
+
assert isinstance(settings.api_base, str)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_settings_per_provider_keys() -> None:
|
|
27
|
+
settings = _settings_with_env({
|
|
28
|
+
"BUB_OPENAI_API_KEY": "sk-openai",
|
|
29
|
+
"BUB_OPENAI_API_BASE": "https://api.openai.com",
|
|
30
|
+
"BUB_ANTHROPIC_API_KEY": "sk-anthropic",
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
assert isinstance(settings.api_key, dict)
|
|
34
|
+
assert settings.api_key["openai"] == "sk-openai"
|
|
35
|
+
assert settings.api_key["anthropic"] == "sk-anthropic"
|
|
36
|
+
assert isinstance(settings.api_base, dict)
|
|
37
|
+
assert settings.api_base["openai"] == "https://api.openai.com"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_settings_no_keys_returns_none() -> None:
|
|
41
|
+
settings = _settings_with_env({})
|
|
42
|
+
|
|
43
|
+
assert settings.api_key is None
|
|
44
|
+
assert settings.api_base is None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_settings_provider_names_are_lowercased() -> None:
|
|
48
|
+
settings = _settings_with_env({"BUB_OPENROUTER_API_KEY": "sk-or"})
|
|
49
|
+
|
|
50
|
+
assert isinstance(settings.api_key, dict)
|
|
51
|
+
assert "openrouter" in settings.api_key
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_settings_mixed_single_key_with_per_provider_base() -> None:
|
|
55
|
+
settings = _settings_with_env({
|
|
56
|
+
"BUB_API_KEY": "sk-global",
|
|
57
|
+
"BUB_OPENAI_API_BASE": "https://api.openai.com",
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
assert settings.api_key == "sk-global"
|
|
61
|
+
assert isinstance(settings.api_base, dict)
|
|
62
|
+
assert settings.api_base["openai"] == "https://api.openai.com"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_settings_load_values_from_yaml(tmp_path: Path) -> None:
|
|
66
|
+
_write_config(
|
|
67
|
+
tmp_path,
|
|
68
|
+
"""
|
|
69
|
+
model: openai:gpt-5
|
|
70
|
+
fallback_models:
|
|
71
|
+
- openai:gpt-4o-mini
|
|
72
|
+
max_steps: 77
|
|
73
|
+
api_key:
|
|
74
|
+
openai: sk-yaml
|
|
75
|
+
api_base:
|
|
76
|
+
openai: https://api.openai.com
|
|
77
|
+
""".strip(),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
with patch.dict("os.environ", {"BUB_HOME": str(tmp_path)}, clear=True):
|
|
81
|
+
settings = AgentSettings()
|
|
82
|
+
|
|
83
|
+
assert settings.model == "openai:gpt-5"
|
|
84
|
+
assert settings.fallback_models == ["openai:gpt-4o-mini"]
|
|
85
|
+
assert settings.max_steps == 77
|
|
86
|
+
assert settings.api_key == {"openai": "sk-yaml"}
|
|
87
|
+
assert settings.api_base == {"openai": "https://api.openai.com"}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_env_settings_override_yaml(tmp_path: Path) -> None:
|
|
91
|
+
_write_config(
|
|
92
|
+
tmp_path,
|
|
93
|
+
"""
|
|
94
|
+
model: openai:gpt-5
|
|
95
|
+
api_key: sk-yaml
|
|
96
|
+
max_steps: 77
|
|
97
|
+
""".strip(),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
with patch.dict(
|
|
101
|
+
"os.environ",
|
|
102
|
+
{
|
|
103
|
+
"BUB_HOME": str(tmp_path),
|
|
104
|
+
"BUB_MODEL": "anthropic:claude-3-7-sonnet",
|
|
105
|
+
"BUB_API_KEY": "sk-env",
|
|
106
|
+
"BUB_MAX_STEPS": "12",
|
|
107
|
+
},
|
|
108
|
+
clear=True,
|
|
109
|
+
):
|
|
110
|
+
settings = AgentSettings()
|
|
111
|
+
|
|
112
|
+
assert settings.model == "anthropic:claude-3-7-sonnet"
|
|
113
|
+
assert settings.api_key == "sk-env"
|
|
114
|
+
assert settings.max_steps == 12
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_load_settings_reads_yaml_from_bub_home(tmp_path: Path) -> None:
|
|
118
|
+
_write_config(
|
|
119
|
+
tmp_path,
|
|
120
|
+
"""
|
|
121
|
+
model: openrouter:qwen/qwen3-coder-next
|
|
122
|
+
api_format: responses
|
|
123
|
+
""".strip(),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
load_settings.cache_clear()
|
|
127
|
+
try:
|
|
128
|
+
with patch.dict("os.environ", {"BUB_HOME": str(tmp_path)}, clear=True):
|
|
129
|
+
settings = load_settings()
|
|
130
|
+
finally:
|
|
131
|
+
load_settings.cache_clear()
|
|
132
|
+
|
|
133
|
+
assert settings.model == "openrouter:qwen/qwen3-coder-next"
|
|
134
|
+
assert settings.api_format == "responses"
|
|
@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock
|
|
|
6
6
|
import pytest
|
|
7
7
|
|
|
8
8
|
from bub.builtin.tools import run_subagent
|
|
9
|
+
from bub.tools import REGISTRY, tool
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
class FakeContext:
|
|
@@ -94,3 +95,50 @@ async def test_subagent_default_session_when_missing() -> None:
|
|
|
94
95
|
|
|
95
96
|
call_kwargs = agent.run.call_args.kwargs
|
|
96
97
|
assert call_kwargs["session_id"] == "temp/unknown"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@pytest.mark.asyncio
|
|
101
|
+
async def test_subagent_empty_allowed_tools_defaults_to_all_non_subagent_tools() -> None:
|
|
102
|
+
tool_name = "tests.allowed_tool_default"
|
|
103
|
+
REGISTRY.pop(tool_name, None)
|
|
104
|
+
|
|
105
|
+
@tool(name=tool_name)
|
|
106
|
+
def allowed_tool_default() -> str:
|
|
107
|
+
return "ok"
|
|
108
|
+
|
|
109
|
+
agent = FakeAgent()
|
|
110
|
+
ctx = FakeContext({"_runtime_agent": agent, "session_id": "user/abc"})
|
|
111
|
+
|
|
112
|
+
await run_subagent.run(prompt="task", allowed_tools=[], context=ctx)
|
|
113
|
+
|
|
114
|
+
allowed_tools = agent.run.call_args.kwargs["allowed_tools"]
|
|
115
|
+
assert tool_name in allowed_tools
|
|
116
|
+
assert "subagent" not in allowed_tools
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@pytest.mark.asyncio
|
|
120
|
+
async def test_subagent_resolves_model_tool_aliases_to_runtime_names() -> None:
|
|
121
|
+
tool_name = "tests.resolve_subagent"
|
|
122
|
+
REGISTRY.pop(tool_name, None)
|
|
123
|
+
|
|
124
|
+
@tool(name=tool_name)
|
|
125
|
+
def resolve_subagent() -> str:
|
|
126
|
+
return "ok"
|
|
127
|
+
|
|
128
|
+
agent = FakeAgent()
|
|
129
|
+
ctx = FakeContext({"_runtime_agent": agent, "session_id": "user/abc"})
|
|
130
|
+
|
|
131
|
+
await run_subagent.run(prompt="task", allowed_tools=[" tests_resolve_subagent "], context=ctx)
|
|
132
|
+
|
|
133
|
+
assert agent.run.call_args.kwargs["allowed_tools"] == {tool_name}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@pytest.mark.asyncio
|
|
137
|
+
async def test_subagent_rejects_unknown_allowed_tools() -> None:
|
|
138
|
+
agent = FakeAgent()
|
|
139
|
+
ctx = FakeContext({"_runtime_agent": agent, "session_id": "user/abc"})
|
|
140
|
+
|
|
141
|
+
with pytest.raises(ValueError, match="tests_missing_tool"):
|
|
142
|
+
await run_subagent.run(prompt="task", allowed_tools=[" tests_missing_tool "], context=ctx)
|
|
143
|
+
|
|
144
|
+
agent.run.assert_not_called()
|
|
@@ -6,7 +6,7 @@ import pytest
|
|
|
6
6
|
from loguru import logger
|
|
7
7
|
from pydantic import BaseModel
|
|
8
8
|
|
|
9
|
-
from bub.tools import REGISTRY, model_tools, render_tools_prompt, tool
|
|
9
|
+
from bub.tools import REGISTRY, model_tools, render_tools_prompt, resolve_tool_names, tool
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class EchoInput(BaseModel):
|
|
@@ -123,3 +123,33 @@ def test_render_tools_prompt_renders_available_tools_block() -> None:
|
|
|
123
123
|
|
|
124
124
|
def test_render_tools_prompt_returns_empty_string_for_empty_input() -> None:
|
|
125
125
|
assert render_tools_prompt([]) == ""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_resolve_tool_names_accepts_runtime_names_and_model_aliases() -> None:
|
|
129
|
+
dotted_name = "tests.resolve_alias"
|
|
130
|
+
underscored_name = "tests_with_underscore"
|
|
131
|
+
REGISTRY.pop(dotted_name, None)
|
|
132
|
+
REGISTRY.pop(underscored_name, None)
|
|
133
|
+
|
|
134
|
+
@tool(name=dotted_name)
|
|
135
|
+
def resolve_alias() -> str:
|
|
136
|
+
return "alias"
|
|
137
|
+
|
|
138
|
+
@tool(name=underscored_name)
|
|
139
|
+
def resolve_runtime_name() -> str:
|
|
140
|
+
return "runtime"
|
|
141
|
+
|
|
142
|
+
assert resolve_tool_names([" tests_resolve_alias ", " tests_with_underscore "], exclude={" subagent "}) == {
|
|
143
|
+
dotted_name,
|
|
144
|
+
underscored_name,
|
|
145
|
+
}
|
|
146
|
+
assert dotted_name not in resolve_tool_names(None, exclude={" tests_resolve_alias "})
|
|
147
|
+
assert resolve_tool_names(None, exclude={" tests_resolve_alias "}) >= {underscored_name}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_resolve_tool_names_rejects_unknown_names() -> None:
|
|
151
|
+
with pytest.raises(ValueError, match="tests_missing_tool"):
|
|
152
|
+
resolve_tool_names([" tests_missing_tool "])
|
|
153
|
+
|
|
154
|
+
with pytest.raises(ValueError, match="tests_missing_tool"):
|
|
155
|
+
resolve_tool_names(None, exclude={" tests_missing_tool "})
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
import pathlib
|
|
5
|
-
import re
|
|
6
|
-
from typing import Literal
|
|
7
|
-
|
|
8
|
-
from pydantic import Field
|
|
9
|
-
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
10
|
-
|
|
11
|
-
DEFAULT_MODEL = "openrouter:qwen/qwen3-coder-next"
|
|
12
|
-
DEFAULT_MAX_TOKENS = 1024
|
|
13
|
-
DEFAULT_HOME = pathlib.Path.home() / ".bub"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
class AgentSettings(BaseSettings):
|
|
17
|
-
"""Configuration settings for the Agent, loaded from environment variables with prefix BUB_ or from a .env file."""
|
|
18
|
-
|
|
19
|
-
model_config = SettingsConfigDict(env_prefix="BUB_", env_parse_none_str="null", extra="ignore", env_file=".env")
|
|
20
|
-
|
|
21
|
-
home: pathlib.Path = Field(default=DEFAULT_HOME)
|
|
22
|
-
|
|
23
|
-
model: str = DEFAULT_MODEL
|
|
24
|
-
fallback_models: list[str] | None = None
|
|
25
|
-
api_key: str | dict[str, str] | None = None
|
|
26
|
-
api_base: str | dict[str, str] | None = None
|
|
27
|
-
api_format: Literal["completion", "responses", "messages"] = "completion"
|
|
28
|
-
max_steps: int = 50
|
|
29
|
-
max_tokens: int = DEFAULT_MAX_TOKENS
|
|
30
|
-
model_timeout_seconds: int | None = None
|
|
31
|
-
verbose: int = Field(default=0, description="Verbosity level for logging. Higher means more verbose.", ge=0, le=2)
|
|
32
|
-
|
|
33
|
-
@classmethod
|
|
34
|
-
def from_env(cls) -> AgentSettings:
|
|
35
|
-
from dotenv import dotenv_values
|
|
36
|
-
|
|
37
|
-
key_regex = re.compile(r"^BUB_(.+)_API_KEY$")
|
|
38
|
-
base_regex = re.compile(r"^BUB_(.+)_API_BASE$")
|
|
39
|
-
|
|
40
|
-
loaded_env = dotenv_values(".env")
|
|
41
|
-
loaded_env.update(os.environ)
|
|
42
|
-
|
|
43
|
-
api_key: str | dict[str, str] | None = loaded_env.get("BUB_API_KEY")
|
|
44
|
-
api_base: str | dict[str, str] | None = loaded_env.get("BUB_API_BASE")
|
|
45
|
-
if api_key and api_base:
|
|
46
|
-
return cls()
|
|
47
|
-
|
|
48
|
-
if api_key is None:
|
|
49
|
-
api_key = {}
|
|
50
|
-
if api_base is None:
|
|
51
|
-
api_base = {}
|
|
52
|
-
|
|
53
|
-
for key, value in loaded_env.items():
|
|
54
|
-
if value is None:
|
|
55
|
-
continue
|
|
56
|
-
if isinstance(api_key, dict) and (match := key_regex.match(key)):
|
|
57
|
-
provider = match.group(1).lower()
|
|
58
|
-
api_key[provider] = value
|
|
59
|
-
if isinstance(api_base, dict) and (match := base_regex.match(key)):
|
|
60
|
-
provider = match.group(1).lower()
|
|
61
|
-
api_base[provider] = value
|
|
62
|
-
|
|
63
|
-
return cls(api_key=api_key or None, api_base=api_base or None)
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from unittest.mock import patch
|
|
4
|
-
|
|
5
|
-
from bub.builtin.settings import AgentSettings
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def _from_env_with(env: dict[str, str]) -> AgentSettings:
|
|
9
|
-
"""Call AgentSettings.from_env() with a controlled env, bypassing .env file and real os.environ."""
|
|
10
|
-
with (
|
|
11
|
-
patch("dotenv.dotenv_values", return_value={}),
|
|
12
|
-
patch.dict("os.environ", env, clear=True),
|
|
13
|
-
):
|
|
14
|
-
return AgentSettings.from_env()
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def test_from_env_single_api_key_and_base() -> None:
|
|
18
|
-
"""When BUB_API_KEY and BUB_API_BASE are both set, return plain AgentSettings."""
|
|
19
|
-
settings = _from_env_with({"BUB_API_KEY": "sk-test", "BUB_API_BASE": "https://api.example.com"})
|
|
20
|
-
|
|
21
|
-
assert isinstance(settings.api_key, str)
|
|
22
|
-
assert isinstance(settings.api_base, str)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def test_from_env_per_provider_keys() -> None:
|
|
26
|
-
"""When per-provider BUB_<PROVIDER>_API_KEY vars are set, build a dict."""
|
|
27
|
-
settings = _from_env_with({
|
|
28
|
-
"BUB_OPENAI_API_KEY": "sk-openai",
|
|
29
|
-
"BUB_OPENAI_API_BASE": "https://api.openai.com",
|
|
30
|
-
"BUB_ANTHROPIC_API_KEY": "sk-anthropic",
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
assert isinstance(settings.api_key, dict)
|
|
34
|
-
assert settings.api_key["openai"] == "sk-openai"
|
|
35
|
-
assert settings.api_key["anthropic"] == "sk-anthropic"
|
|
36
|
-
assert isinstance(settings.api_base, dict)
|
|
37
|
-
assert settings.api_base["openai"] == "https://api.openai.com"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def test_from_env_no_keys_returns_none() -> None:
|
|
41
|
-
"""When no API key env vars are present, api_key and api_base are None."""
|
|
42
|
-
settings = _from_env_with({})
|
|
43
|
-
|
|
44
|
-
assert settings.api_key is None
|
|
45
|
-
assert settings.api_base is None
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def test_from_env_provider_names_are_lowercased() -> None:
|
|
49
|
-
"""Provider names extracted from env vars should be lowercased."""
|
|
50
|
-
settings = _from_env_with({"BUB_OPENROUTER_API_KEY": "sk-or"})
|
|
51
|
-
|
|
52
|
-
assert isinstance(settings.api_key, dict)
|
|
53
|
-
assert "openrouter" in settings.api_key
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def test_from_env_mixed_single_key_with_per_provider_base() -> None:
|
|
57
|
-
"""When BUB_API_KEY is set but BUB_API_BASE is not, key stays string, base becomes dict."""
|
|
58
|
-
settings = _from_env_with({
|
|
59
|
-
"BUB_API_KEY": "sk-global",
|
|
60
|
-
"BUB_OPENAI_API_BASE": "https://api.openai.com",
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
# api_key is a plain string (from BUB_API_KEY), base is a dict
|
|
64
|
-
assert settings.api_key == "sk-global"
|
|
65
|
-
assert isinstance(settings.api_base, dict)
|
|
66
|
-
assert settings.api_base["openai"] == "https://api.openai.com"
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def test_from_env_dotenv_file_values_used() -> None:
|
|
70
|
-
"""Values from .env file are used when present."""
|
|
71
|
-
dotenv_data = {"BUB_OPENAI_API_KEY": "sk-from-dotenv"}
|
|
72
|
-
with (
|
|
73
|
-
patch("dotenv.dotenv_values", return_value=dotenv_data),
|
|
74
|
-
patch.dict("os.environ", {}, clear=True),
|
|
75
|
-
):
|
|
76
|
-
settings = AgentSettings.from_env()
|
|
77
|
-
|
|
78
|
-
assert isinstance(settings.api_key, dict)
|
|
79
|
-
assert settings.api_key["openai"] == "sk-from-dotenv"
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def test_from_env_os_environ_overrides_dotenv() -> None:
|
|
83
|
-
"""os.environ should override values from .env file."""
|
|
84
|
-
dotenv_data = {"BUB_OPENAI_API_KEY": "sk-from-dotenv"}
|
|
85
|
-
with (
|
|
86
|
-
patch("dotenv.dotenv_values", return_value=dotenv_data),
|
|
87
|
-
patch.dict("os.environ", {"BUB_OPENAI_API_KEY": "sk-from-env"}, clear=True),
|
|
88
|
-
):
|
|
89
|
-
settings = AgentSettings.from_env()
|
|
90
|
-
|
|
91
|
-
assert isinstance(settings.api_key, dict)
|
|
92
|
-
assert settings.api_key["openai"] == "sk-from-env"
|
{bub-0.3.1 → bub-0.3.2}/LICENSE
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|