diff --git a/bot_bottle/contrib/pi/agent_provider.py b/bot_bottle/contrib/pi/agent_provider.py index 5c6fdae..c823a78 100644 --- a/bot_bottle/contrib/pi/agent_provider.py +++ b/bot_bottle/contrib/pi/agent_provider.py @@ -32,7 +32,7 @@ if TYPE_CHECKING: _DEFAULT_BASE_URL = "http://ollama:11434/v1" _DEFAULT_MODEL = "qwen2.5-coder:7b" -_PROVIDER_NAME = "ollama" +_DEFAULT_PROVIDER_NAME = "ollama" def _skills_dir(guest_home: str) -> str: @@ -56,10 +56,16 @@ def _settings_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)) 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 = [str(model) for model in models_raw] # type: ignore[union-attr] 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( _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] = { "providers": { - _PROVIDER_NAME: { - "baseUrl": base_url, - "api": api, - "apiKey": api_key, - "compat": { - "supportsDeveloperRole": supports_developer_role, - "supportsReasoningEffort": supports_reasoning_effort, - }, - "models": [{"id": model} for model in models], - } + provider_name: provider, } } - return payload, base_url + return payload, base_url, api_key_env def _route_host(base_url: str) -> str: @@ -133,12 +145,13 @@ class PiAgentProvider(AgentProvider): guest_home = self.guest_home 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.write_text(json.dumps(models_payload, indent=2) + "\n") models_file.chmod(0o600) has_prompt = prompt_file.exists() and bool(prompt_file.read_text()) + auth_scheme = "Bearer" if api_key_env else "" return AgentProvisionPlan( template=_RUNTIME.template, command=_RUNTIME.command, @@ -152,7 +165,11 @@ class PiAgentProvider(AgentProvider): has_prompt=has_prompt, dirs=(AgentProvisionDir(f"{guest_home}/.pi/agent"),), 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: diff --git a/bot_bottle/manifest_agent.py b/bot_bottle/manifest_agent.py index 3f60ccc..739cb56 100644 --- a/bot_bottle/manifest_agent.py +++ b/bot_bottle/manifest_agent.py @@ -206,9 +206,11 @@ def _parse_provider_settings( ) settings = as_json_object(raw, f"bottle '{bottle_name}' agent_provider.settings") allowed = { + "provider", "base_url", "api", "api_key", + "api_key_env", "models", "supports_developer_role", "supports_reasoning_effort", @@ -219,13 +221,18 @@ def _parse_provider_settings( f"bottle '{bottle_name}' agent_provider.settings has unknown " 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) if value is not None and (not isinstance(value, str) or not value): raise ManifestError( f"bottle '{bottle_name}' agent_provider.settings.{key} must " "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") if models is not None: if not isinstance(models, list) or not models: diff --git a/docs/prds/0058-pi-agent-provider.md b/docs/prds/0058-pi-agent-provider.md index 8e04a0a..83a00fb 100644 --- a/docs/prds/0058-pi-agent-provider.md +++ b/docs/prds/0058-pi-agent-provider.md @@ -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 `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 -flags through a new `agent_provider.settings` object. +Users can override the provider id, base URL, model list, API key, API-key env +reference, API type, and compatibility flags through a new +`agent_provider.settings` object. ## Problem @@ -60,8 +61,10 @@ supported for built-in `pi`. Supported keys: - `base_url`: string, defaults to `http://ollama:11434/v1` +- `provider`: string, defaults to `ollama` - `api`: string, defaults to `openai-completions` - `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"]` - `supports_developer_role`: 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 `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 `PiAgentProvider.provision_plan` writes `models.json` into the per-launch state diff --git a/tests/unit/test_agent_provider.py b/tests/unit/test_agent_provider.py index 0fe3659..3a3993e 100644 --- a/tests/unit/test_agent_provider.py +++ b/tests/unit/test_agent_provider.py @@ -322,6 +322,38 @@ class TestAgentProviderRuntime(unittest.TestCase): self.assertTrue(provider["compat"]["supportsDeveloperRole"]) 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): self.assertEqual( ["-p", "Read and follow the instructions in /home/node/.bot-bottle-prompt.txt."], diff --git a/tests/unit/test_manifest_egress.py b/tests/unit/test_manifest_egress.py index fe4973b..01c8bcb 100644 --- a/tests/unit/test_manifest_egress.py +++ b/tests/unit/test_manifest_egress.py @@ -115,6 +115,7 @@ class TestAgentProviderHostCredentials(unittest.TestCase): b = _provider_config_bottle({ "template": "pi", "settings": { + "provider": "ollama", "base_url": "http://ollama:11434/v1", "api": "openai-completions", "api_key": "ollama", @@ -125,6 +126,7 @@ class TestAgentProviderHostCredentials(unittest.TestCase): }) self.assertEqual( { + "provider": "ollama", "base_url": "http://ollama:11434/v1", "api": "openai-completions", "api_key": "ollama", @@ -135,6 +137,29 @@ class TestAgentProviderHostCredentials(unittest.TestCase): 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): with self.assertRaises(ManifestError): _provider_config_bottle({