feat(pi): support egress injected api keys
lint / lint (push) Successful in 1m38s
test / unit (pull_request) Successful in 31s
test / integration (pull_request) Successful in 17s

This commit is contained in:
2026-06-09 05:56:39 -04:00
parent 5ea9fda69b
commit c8b5ba3812
5 changed files with 112 additions and 19 deletions
+33 -16
View File
@@ -32,7 +32,7 @@ if TYPE_CHECKING:
_DEFAULT_BASE_URL = "http://ollama:11434/v1" _DEFAULT_BASE_URL = "http://ollama:11434/v1"
_DEFAULT_MODEL = "qwen2.5-coder:7b" _DEFAULT_MODEL = "qwen2.5-coder:7b"
_PROVIDER_NAME = "ollama" _DEFAULT_PROVIDER_NAME = "ollama"
def _skills_dir(guest_home: str) -> str: def _skills_dir(guest_home: str) -> str:
@@ -56,10 +56,16 @@ def _settings_value(
return default if value is None else value return default if value is None else value
def _pi_models_json(settings: dict[str, object]) -> tuple[dict[str, object], str]: def _pi_models_json(
settings: dict[str, object],
) -> tuple[dict[str, object], str, str]:
provider_name = str(
_settings_value(settings, "provider", _DEFAULT_PROVIDER_NAME)
)
base_url = str(_settings_value(settings, "base_url", _DEFAULT_BASE_URL)) base_url = str(_settings_value(settings, "base_url", _DEFAULT_BASE_URL))
api = str(_settings_value(settings, "api", "openai-completions")) api = str(_settings_value(settings, "api", "openai-completions"))
api_key = str(_settings_value(settings, "api_key", "ollama")) api_key = settings.get("api_key")
api_key_env = str(settings.get("api_key_env", ""))
models_raw = _settings_value(settings, "models", [_DEFAULT_MODEL]) models_raw = _settings_value(settings, "models", [_DEFAULT_MODEL])
models = [str(model) for model in models_raw] # type: ignore[union-attr] models = [str(model) for model in models_raw] # type: ignore[union-attr]
supports_developer_role = bool( supports_developer_role = bool(
@@ -68,21 +74,27 @@ def _pi_models_json(settings: dict[str, object]) -> tuple[dict[str, object], str
supports_reasoning_effort = bool( supports_reasoning_effort = bool(
_settings_value(settings, "supports_reasoning_effort", False) _settings_value(settings, "supports_reasoning_effort", False)
) )
provider: dict[str, object] = {
"baseUrl": base_url,
"api": api,
"compat": {
"supportsDeveloperRole": supports_developer_role,
"supportsReasoningEffort": supports_reasoning_effort,
},
"models": [{"id": model} for model in models],
}
if api_key is not None:
provider["apiKey"] = str(api_key)
elif api_key_env:
provider["apiKey"] = "egress-placeholder"
elif provider_name == _DEFAULT_PROVIDER_NAME:
provider["apiKey"] = "ollama"
payload: dict[str, object] = { payload: dict[str, object] = {
"providers": { "providers": {
_PROVIDER_NAME: { provider_name: provider,
"baseUrl": base_url,
"api": api,
"apiKey": api_key,
"compat": {
"supportsDeveloperRole": supports_developer_role,
"supportsReasoningEffort": supports_reasoning_effort,
},
"models": [{"id": model} for model in models],
}
} }
} }
return payload, base_url return payload, base_url, api_key_env
def _route_host(base_url: str) -> str: def _route_host(base_url: str) -> str:
@@ -133,12 +145,13 @@ class PiAgentProvider(AgentProvider):
guest_home = self.guest_home guest_home = self.guest_home
settings = dict(provider_settings or {}) settings = dict(provider_settings or {})
models_payload, base_url = _pi_models_json(settings) models_payload, base_url, api_key_env = _pi_models_json(settings)
models_file = state_dir / "pi-models.json" models_file = state_dir / "pi-models.json"
models_file.write_text(json.dumps(models_payload, indent=2) + "\n") models_file.write_text(json.dumps(models_payload, indent=2) + "\n")
models_file.chmod(0o600) models_file.chmod(0o600)
has_prompt = prompt_file.exists() and bool(prompt_file.read_text()) has_prompt = prompt_file.exists() and bool(prompt_file.read_text())
auth_scheme = "Bearer" if api_key_env else ""
return AgentProvisionPlan( return AgentProvisionPlan(
template=_RUNTIME.template, template=_RUNTIME.template,
command=_RUNTIME.command, command=_RUNTIME.command,
@@ -152,7 +165,11 @@ class PiAgentProvider(AgentProvider):
has_prompt=has_prompt, has_prompt=has_prompt,
dirs=(AgentProvisionDir(f"{guest_home}/.pi/agent"),), dirs=(AgentProvisionDir(f"{guest_home}/.pi/agent"),),
files=(AgentProvisionFile(models_file, _models_path(guest_home)),), files=(AgentProvisionFile(models_file, _models_path(guest_home)),),
egress_routes=(EgressRoute(host=_route_host(base_url)),), egress_routes=(EgressRoute(
host=_route_host(base_url),
auth_scheme=auth_scheme,
token_ref=api_key_env,
),),
) )
def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None: def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None:
+8 -1
View File
@@ -206,9 +206,11 @@ def _parse_provider_settings(
) )
settings = as_json_object(raw, f"bottle '{bottle_name}' agent_provider.settings") settings = as_json_object(raw, f"bottle '{bottle_name}' agent_provider.settings")
allowed = { allowed = {
"provider",
"base_url", "base_url",
"api", "api",
"api_key", "api_key",
"api_key_env",
"models", "models",
"supports_developer_role", "supports_developer_role",
"supports_reasoning_effort", "supports_reasoning_effort",
@@ -219,13 +221,18 @@ def _parse_provider_settings(
f"bottle '{bottle_name}' agent_provider.settings has unknown " f"bottle '{bottle_name}' agent_provider.settings has unknown "
f"key {key!r}; allowed: {', '.join(sorted(allowed))}" f"key {key!r}; allowed: {', '.join(sorted(allowed))}"
) )
for key in ("base_url", "api", "api_key"): for key in ("provider", "base_url", "api", "api_key", "api_key_env"):
value = settings.get(key) value = settings.get(key)
if value is not None and (not isinstance(value, str) or not value): if value is not None and (not isinstance(value, str) or not value):
raise ManifestError( raise ManifestError(
f"bottle '{bottle_name}' agent_provider.settings.{key} must " f"bottle '{bottle_name}' agent_provider.settings.{key} must "
"be a non-empty string" "be a non-empty string"
) )
if settings.get("api_key") is not None and settings.get("api_key_env") is not None:
raise ManifestError(
f"bottle '{bottle_name}' agent_provider.settings may set either "
"api_key or api_key_env, not both"
)
models = settings.get("models") models = settings.get("models")
if models is not None: if models is not None:
if not isinstance(models, list) or not models: if not isinstance(models, list) or not models:
+14 -2
View File
@@ -13,8 +13,9 @@ provider settings file that targets an unauthenticated Ollama-compatible server.
The default settings assume an Ollama server at `http://ollama:11434/v1`, using The default settings assume an Ollama server at `http://ollama:11434/v1`, using
the `openai-completions` API with a dummy API key because Ollama ignores it. the `openai-completions` API with a dummy API key because Ollama ignores it.
Users can override the base URL, model list, API key, API type, and compatibility Users can override the provider id, base URL, model list, API key, API-key env
flags through a new `agent_provider.settings` object. reference, API type, and compatibility flags through a new
`agent_provider.settings` object.
## Problem ## Problem
@@ -60,8 +61,10 @@ supported for built-in `pi`.
Supported keys: Supported keys:
- `base_url`: string, defaults to `http://ollama:11434/v1` - `base_url`: string, defaults to `http://ollama:11434/v1`
- `provider`: string, defaults to `ollama`
- `api`: string, defaults to `openai-completions` - `api`: string, defaults to `openai-completions`
- `api_key`: string, defaults to `ollama` - `api_key`: string, defaults to `ollama`
- `api_key_env`: string, optional host env var name for egress auth injection
- `models`: non-empty array of strings, defaults to `["qwen2.5-coder:7b"]` - `models`: non-empty array of strings, defaults to `["qwen2.5-coder:7b"]`
- `supports_developer_role`: boolean, defaults to `false` - `supports_developer_role`: boolean, defaults to `false`
- `supports_reasoning_effort`: boolean, defaults to `false` - `supports_reasoning_effort`: boolean, defaults to `false`
@@ -70,6 +73,15 @@ The snake-case manifest keys are converted into Pi's JSON field names:
`baseUrl`, `apiKey`, `supportsDeveloperRole`, and `baseUrl`, `apiKey`, `supportsDeveloperRole`, and
`supportsReasoningEffort`. `supportsReasoningEffort`.
`api_key` and `api_key_env` are mutually exclusive. When targeting a hosted
provider through bot-bottle's egress sidecar, omit `api_key` and set
`api_key_env` to the host env var that holds the API key. The generated
`models.json` receives only an `egress-placeholder` API key, and the egress
route injects the real `Authorization` header from the sidecar env. For example,
OpenRouter can use provider id `openrouter` with
`api_key_env: OPENROUTER_API_KEY`, keeping the key out of the agent env and
`models.json`.
### Provider ### Provider
`PiAgentProvider.provision_plan` writes `models.json` into the per-launch state `PiAgentProvider.provision_plan` writes `models.json` into the per-launch state
+32
View File
@@ -322,6 +322,38 @@ class TestAgentProviderRuntime(unittest.TestCase):
self.assertTrue(provider["compat"]["supportsDeveloperRole"]) self.assertTrue(provider["compat"]["supportsDeveloperRole"])
self.assertTrue(provider["compat"]["supportsReasoningEffort"]) self.assertTrue(provider["compat"]["supportsReasoningEffort"])
def test_pi_plan_can_target_openrouter_with_egress_injected_api_key(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = build_agent_provision_plan(
template="pi",
dockerfile="",
state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
provider_settings={
"provider": "openrouter",
"base_url": "https://openrouter.ai/api/v1",
"api": "openai-completions",
"api_key_env": "OPENROUTER_API_KEY",
"models": ["google/gemma-4-26b-a4b-it:free"],
"supports_reasoning_effort": True,
},
)
models = json.loads(Path(tmp, "pi-models.json").read_text())
provider = models["providers"]["openrouter"]
self.assertEqual("https://openrouter.ai/api/v1", provider["baseUrl"])
self.assertEqual("openai-completions", provider["api"])
self.assertEqual("egress-placeholder", provider["apiKey"])
self.assertEqual(
[{"id": "google/gemma-4-26b-a4b-it:free"}],
provider["models"],
)
self.assertEqual("openrouter.ai", plan.egress_routes[0].host)
self.assertEqual("Bearer", plan.egress_routes[0].auth_scheme)
self.assertEqual("OPENROUTER_API_KEY", plan.egress_routes[0].token_ref)
self.assertNotIn("OPENROUTER_API_KEY", plan.guest_env)
self.assertTrue(provider["compat"]["supportsReasoningEffort"])
def test_pi_prompt_mode_uses_print_flag(self): def test_pi_prompt_mode_uses_print_flag(self):
self.assertEqual( self.assertEqual(
["-p", "Read and follow the instructions in /home/node/.bot-bottle-prompt.txt."], ["-p", "Read and follow the instructions in /home/node/.bot-bottle-prompt.txt."],
+25
View File
@@ -115,6 +115,7 @@ class TestAgentProviderHostCredentials(unittest.TestCase):
b = _provider_config_bottle({ b = _provider_config_bottle({
"template": "pi", "template": "pi",
"settings": { "settings": {
"provider": "ollama",
"base_url": "http://ollama:11434/v1", "base_url": "http://ollama:11434/v1",
"api": "openai-completions", "api": "openai-completions",
"api_key": "ollama", "api_key": "ollama",
@@ -125,6 +126,7 @@ class TestAgentProviderHostCredentials(unittest.TestCase):
}) })
self.assertEqual( self.assertEqual(
{ {
"provider": "ollama",
"base_url": "http://ollama:11434/v1", "base_url": "http://ollama:11434/v1",
"api": "openai-completions", "api": "openai-completions",
"api_key": "ollama", "api_key": "ollama",
@@ -135,6 +137,29 @@ class TestAgentProviderHostCredentials(unittest.TestCase):
b.agent_provider.settings, b.agent_provider.settings,
) )
def test_settings_allowed_for_pi_api_key_env(self):
b = _provider_config_bottle({
"template": "pi",
"settings": {
"provider": "openrouter",
"base_url": "https://openrouter.ai/api/v1",
"api": "openai-completions",
"api_key_env": "OPENROUTER_API_KEY",
"models": ["google/gemma-4-26b-a4b-it:free"],
},
})
self.assertEqual("OPENROUTER_API_KEY", b.agent_provider.settings["api_key_env"])
def test_settings_rejects_api_key_and_api_key_env_together(self):
with self.assertRaises(ManifestError):
_provider_config_bottle({
"template": "pi",
"settings": {
"api_key": "literal",
"api_key_env": "OPENROUTER_API_KEY",
},
})
def test_settings_rejected_for_claude(self): def test_settings_rejected_for_claude(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
_provider_config_bottle({ _provider_config_bottle({