Skip to content
19 changes: 19 additions & 0 deletions framework/py/flwr/supercore/json_message/model_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from __future__ import annotations

from collections.abc import Sequence
from typing import cast

from flwr.app.constants import DEFAULT_TTL
from flwr.supercore.json_message.base import JSONMessage
Expand Down Expand Up @@ -65,6 +66,24 @@ def __init__( # pylint: disable=too-many-arguments,too-many-positional-argument
ttl=ttl,
)

@classmethod
def from_payload(cls, *, dst_task_id: int, payload: JSONObject) -> ModelRequest:
"""Create a model request from a Responses request payload."""
return cls(
dst_task_id=dst_task_id,
input_=cast(str | Sequence[JSONObject], payload.get("input")),
model=cast(str, payload.get("model")),
stream=cast(bool, payload.get("stream", False)),
tools=cast(Sequence[JSONObject] | None, payload.get("tools")),
tool_choice=payload.get("tool_choice"),
reasoning=cast(JSONObject | None, payload.get("reasoning")),
previous_response_id=cast(str | None, payload.get("previous_response_id")),
instructions=cast(str | None, payload.get("instructions")),
max_output_tokens=cast(int | None, payload.get("max_output_tokens")),
metadata=cast(JSONObject | None, payload.get("metadata")),
text=cast(JSONObject | None, payload.get("text")),
)

@classmethod
def _validate_payload(cls, payload: JSONObject) -> None:
"""Validate the minimal Responses create-request shape."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,9 @@ def test_model_messages_create_payloads() -> None:

def test_model_request_accepts_string_input_and_default_stream() -> None:
"""Model requests should accept simple string prompts."""
request = ModelRequest(
request = ModelRequest.from_payload(
dst_task_id=123,
input_="Hello",
model="gpt-5",
payload={"input": "Hello", "model": "gpt-5"},
)

assert request.payload == {
Expand Down
78 changes: 78 additions & 0 deletions framework/py/flwr/supercore/task_process/agent/run_agentapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@
"""Flower AgentApp process."""


import os
import ssl
from logging import DEBUG, ERROR
from pathlib import Path
from queue import Queue
from tempfile import NamedTemporaryFile

import httpx

Expand Down Expand Up @@ -71,6 +74,10 @@
)

_AGENT_INPUT_KEY = "agent.input"
_RUNTIME_API_KEY_ENV = "FLWR_RUNTIME_API_KEY"
_RUNTIME_BASE_URL_ENV = "FLWR_RUNTIME_BASE_URL"
_SSL_CERT_FILE_ENV = "SSL_CERT_FILE"
_SSL_CERT_DIR_ENV = "SSL_CERT_DIR"


def run_agentapp( # pylint: disable=R0912, R0913, R0914, R0915, R0917, W0212
Expand Down Expand Up @@ -102,6 +109,7 @@ def run_agentapp( # pylint: disable=R0912, R0913, R0914, R0915, R0917, W0212
heartbeat_sender = None
context: Context | None = None
runtime_env_dir: Path | None = None
runtime_root_certificates_path: Path | None = None
exit_code = ExitCode.SUCCESS

def on_exit() -> None:
Expand Down Expand Up @@ -131,6 +139,8 @@ def on_exit() -> None:
grid.close()

cleanup_app_runtime_environment(runtime_env_dir)
if runtime_root_certificates_path is not None:
runtime_root_certificates_path.unlink(missing_ok=True)

register_signal_handlers(
event_type=EventType.FLWR_AGENTAPP_RUN_LEAVE,
Expand Down Expand Up @@ -244,6 +254,10 @@ def on_exit() -> None:
responses=responses, connectors=connectors, events=events
)

runtime_root_certificates_path = _set_runtime_environment(
runtime_api_address, token, insecure, certificates
)

# Load and run the AgentApp
agent_app = load_app(agent_app_attr, LoadAgentAppError, app_path)
if not isinstance(agent_app, AgentApp):
Expand Down Expand Up @@ -278,3 +292,67 @@ def on_exit() -> None:
"success": exit_code == ExitCode.SUCCESS,
},
)


def _set_runtime_environment(
runtime_api_address: str,
token: str,
insecure: bool,
certificates: bytes | None = None,
) -> Path | None:
"""Expose the Open Responses-compatible Runtime endpoint to the AgentApp."""
scheme = "http" if insecure else "https"
address = runtime_api_address.rstrip("/")
os.environ[_RUNTIME_BASE_URL_ENV] = f"{scheme}://{address}/v1/runtime"
os.environ[_RUNTIME_API_KEY_ENV] = token

if certificates is None:
return None

# OpenAI/httpx reads custom CAs from SSL_CERT_FILE, which requires a path.
# Extend the public and inherited roots with the Runtime CA for this process.
public_ca_context = httpx.create_ssl_context(trust_env=False)
trusted_certificates = [
*public_ca_context.get_ca_certs(binary_form=True),
*_load_inherited_ca_certificates(),
]
with NamedTemporaryFile(
mode="wb", prefix="flwr-runtime-ca-", suffix=".pem", delete=False
) as certificate_file:
for trusted_certificate in dict.fromkeys(trusted_certificates):
certificate_file.write(
ssl.DER_cert_to_PEM_cert(trusted_certificate).encode("ascii")
)
certificate_file.write(b"\n")
certificate_file.write(certificates)
certificate_path = Path(certificate_file.name)
os.environ[_SSL_CERT_FILE_ENV] = str(certificate_path)
return certificate_path


def _load_inherited_ca_certificates() -> list[bytes]:
"""Load roots configured through the standard OpenSSL environment."""
ca_file = os.environ.get(_SSL_CERT_FILE_ENV)
ca_dir = os.environ.get(_SSL_CERT_DIR_ENV)
if not ca_file and not ca_dir:
return []

context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
if ca_file:
context.load_verify_locations(cafile=ca_file)
if ca_dir:
for directory in ca_dir.split(os.pathsep):
if not directory:
continue
try:
ca_paths = sorted(Path(directory).iterdir())
except OSError:
continue
for ca_path in ca_paths:
if not ca_path.is_file():
continue
try:
context.load_verify_locations(cafile=ca_path)
except OSError:
continue
return context.get_ca_certs(binary_form=True)
121 changes: 121 additions & 0 deletions framework/py/flwr/supercore/task_process/agent/run_agentapp_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Copyright 2026 Flower Labs GmbH. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""Tests for the AgentApp process environment."""

import os
import ssl
from pathlib import Path
from unittest.mock import Mock

import httpx
import pytest

from .run_agentapp import _set_runtime_environment


@pytest.mark.parametrize(("insecure", "scheme"), [(True, "http"), (False, "https")])
def test_set_runtime_environment(
monkeypatch: pytest.MonkeyPatch, insecure: bool, scheme: str
) -> None:
"""Expose the Runtime Responses base URL and AgentApp task token."""
monkeypatch.delenv("FLWR_RUNTIME_BASE_URL", raising=False)
monkeypatch.delenv("FLWR_RUNTIME_API_KEY", raising=False)
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
certificate_path = _set_runtime_environment(
"runtime.example:9092", "task-token", insecure=insecure
)

assert os.environ["FLWR_RUNTIME_BASE_URL"] == (
f"{scheme}://runtime.example:9092/v1/runtime"
)
assert os.environ["FLWR_RUNTIME_API_KEY"] == "task-token"
assert certificate_path is None
assert "SSL_CERT_FILE" not in os.environ


def test_set_runtime_environment_exposes_root_certificates(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Add custom Runtime root certificates to the public trust bundle."""
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
monkeypatch.delenv("SSL_CERT_DIR", raising=False)

certificate_path = _set_runtime_environment(
"runtime.example:9092",
"task-token",
insecure=False,
certificates=b"root-certificates",
)

assert certificate_path is not None
try:
assert os.environ["SSL_CERT_FILE"] == str(certificate_path)
certificate_bundle = certificate_path.read_bytes()
assert certificate_bundle.startswith(b"-----BEGIN CERTIFICATE-----")
assert certificate_bundle.endswith(b"\nroot-certificates")
finally:
certificate_path.unlink(missing_ok=True)


@pytest.mark.parametrize("ca_env", ["SSL_CERT_FILE", "SSL_CERT_DIR"])
def test_set_runtime_environment_preserves_inherited_root_certificates(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ca_env: str
) -> None:
"""Keep inherited custom roots when adding the Runtime root certificate."""
inherited_certificates = httpx.create_ssl_context(trust_env=False).get_ca_certs(
binary_form=True
)[:2]
inherited_certificates_pem = [
ssl.DER_cert_to_PEM_cert(certificate).encode("ascii")
for certificate in inherited_certificates
]
if ca_env == "SSL_CERT_FILE":
inherited_ca_path = tmp_path / "inherited-ca.pem"
inherited_ca_path.write_bytes(inherited_certificates_pem[0])
ca_env_value = str(inherited_ca_path)
expected_certificates = inherited_certificates_pem[:1]
else:
inherited_ca_directories = []
for index, certificate in enumerate(inherited_certificates_pem):
inherited_ca_directory = tmp_path / f"inherited-cas-{index}"
inherited_ca_directory.mkdir()
(inherited_ca_directory / "inherited-ca.pem").write_bytes(certificate)
inherited_ca_directories.append(str(inherited_ca_directory))
ca_env_value = os.pathsep.join(inherited_ca_directories)
expected_certificates = inherited_certificates_pem
monkeypatch.setenv(ca_env, ca_env_value)
monkeypatch.delenv(
"SSL_CERT_DIR" if ca_env == "SSL_CERT_FILE" else "SSL_CERT_FILE",
raising=False,
)
public_ca_context = Mock()
public_ca_context.get_ca_certs.return_value = []
monkeypatch.setattr(httpx, "create_ssl_context", lambda **_: public_ca_context)

certificate_path = _set_runtime_environment(
"runtime.example:9092",
"task-token",
insecure=False,
certificates=b"runtime-root-certificate",
)

assert certificate_path is not None
try:
certificate_bundle = certificate_path.read_bytes()
for certificate in expected_certificates:
assert certificate in certificate_bundle
assert certificate_bundle.endswith(b"\nruntime-root-certificate")
finally:
certificate_path.unlink(missing_ok=True)
2 changes: 2 additions & 0 deletions framework/py/flwr/superlink/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
ControlEventLogMiddleware,
ControlLicenseMiddleware,
)
from flwr.superlink.routers.runtime import responses_router
from flwr.superlink.routers.runtime import router as runtime_router

try:
Expand Down Expand Up @@ -184,6 +185,7 @@ async def lifespan(fastapi_app: FastAPI) -> AsyncIterator[dict[str, object]]:
# SuperLink APIs
fastapi_app.include_router(control_router)
fastapi_app.include_router(runtime_router)
fastapi_app.include_router(responses_router)

# Extension hooks
extensions.configure_app(fastapi_app)
Expand Down
3 changes: 2 additions & 1 deletion framework/py/flwr/superlink/routers/runtime/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"""Runtime API router."""


from .responses import router as responses_router
from .router import router

__all__ = ["router"]
__all__ = ["responses_router", "router"]
Loading
Loading