feat(pi): support egress injected api keys
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."],
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user