-
Notifications
You must be signed in to change notification settings - Fork 43
feat: [Option A] configurable runtime-token header (server + SDK) [Need Discussion] #252
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,6 +20,8 @@ | |
| _logger = logging.getLogger(__name__) | ||
|
|
||
| _RUNTIME_AUTH_MODE_ENV_VAR = "AGENT_CONTROL_RUNTIME_AUTH_MODE" | ||
| _RUNTIME_TOKEN_HEADER_ENV_VAR = "AGENT_CONTROL_RUNTIME_TOKEN_HEADER" | ||
| _DEFAULT_RUNTIME_TOKEN_HEADER = "Authorization" | ||
| _DEFAULT_RUNTIME_TOKEN_REFRESH_MARGIN_SECONDS = 30 | ||
| _AUTO_RUNTIME_TOKEN_FALLBACK_STATUSES = {404, 500, 502, 503, 504} | ||
| _GLOBAL_RUNTIME_TOKEN_FALLBACK_STATUSES = {404} | ||
|
|
@@ -35,19 +37,39 @@ def _runtime_cache_identity(api_key: str | None, api_key_header: str) -> str: | |
|
|
||
|
|
||
| class _AgentControlAuth(httpx.Auth): | ||
| """Attach local API-key credentials unless a request already has Bearer auth.""" | ||
| """Attach local API-key credentials unless a request already carries a token. | ||
|
|
||
| def __init__(self, api_key: str | None, header_name: str = "X-API-Key") -> None: | ||
| The API key is suppressed when the request already presents a bearer | ||
| credential on ``Authorization`` or a runtime token on its dedicated | ||
| header, so a runtime-authenticated evaluation carries a single | ||
| credential regardless of which header the runtime token rides. | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| api_key: str | None, | ||
| header_name: str = "X-API-Key", | ||
| runtime_token_header: str | None = None, | ||
| ) -> None: | ||
| self._api_key = api_key | ||
| self._header_name = header_name | ||
| self._runtime_token_header = runtime_token_header | ||
|
|
||
| def auth_flow( | ||
| self, | ||
| request: httpx.Request, | ||
| ) -> Generator[httpx.Request, httpx.Response, None]: | ||
| if self._api_key and "Authorization" not in request.headers: | ||
| if self._header_name not in request.headers: | ||
| request.headers[self._header_name] = self._api_key | ||
| runtime_token_on_dedicated_header = ( | ||
| self._runtime_token_header is not None | ||
| and self._runtime_token_header in request.headers | ||
| ) | ||
| if ( | ||
| self._api_key | ||
| and "Authorization" not in request.headers | ||
| and not runtime_token_on_dedicated_header | ||
| and self._header_name not in request.headers | ||
| ): | ||
| request.headers[self._header_name] = self._api_key | ||
| yield request | ||
|
|
||
|
|
||
|
|
@@ -99,6 +121,7 @@ def __init__( | |
| api_key: str | None = None, | ||
| api_key_header: str | None = None, | ||
| runtime_auth_mode: RuntimeAuthMode | str | None = None, | ||
| runtime_token_header: str | None = None, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we preserve the existing positional constructor order? Inserting runtime_token_header before runtime_token_cache changes how existing positional calls bind and can fail when the cache is treated as a header. Making the new option keyword-only after the existing parameters would avoid the compatibility break. |
||
| runtime_token_cache: RuntimeTokenCache | None = None, | ||
| runtime_token_refresh_margin_seconds: int = (_DEFAULT_RUNTIME_TOKEN_REFRESH_MARGIN_SECONDS), | ||
| transport: httpx.AsyncBaseTransport | None = None, | ||
|
|
@@ -121,6 +144,13 @@ def __init__( | |
| request auth when the exchange endpoint is unavailable. ``jwt`` | ||
| requires a successful exchange. ``api_key`` and ``none`` keep | ||
| evaluation requests on the normal request-auth path. | ||
| runtime_token_header: HTTP header name to send the runtime token | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we expose runtime_token_header through agent_control.init() as well? The high-level flow creates clients internally, so constructor-only configuration otherwise requires setting a process-wide environment variable. |
||
| on. Defaults to ``Authorization``; the | ||
| AGENT_CONTROL_RUNTIME_TOKEN_HEADER environment variable | ||
| overrides the default. Point this at a dedicated header (e.g. | ||
| ``X-Agent-Control-Runtime-Token``) when the server runs behind | ||
| a gateway that reserves ``Authorization`` for its own identity | ||
| JWT. The server must be configured to read the same header. | ||
| runtime_token_cache: Optional cache shared across client instances. | ||
| runtime_token_refresh_margin_seconds: Refresh cached runtime tokens | ||
| before this many seconds of validity remain. | ||
|
|
@@ -140,6 +170,27 @@ def __init__( | |
| self._runtime_cache_identity = _runtime_cache_identity(self._api_key, self._api_key_header) | ||
| configured_runtime_mode = runtime_auth_mode or os.environ.get(_RUNTIME_AUTH_MODE_ENV_VAR) | ||
| self._runtime_auth_mode = normalize_runtime_auth_mode(configured_runtime_mode) | ||
| if runtime_token_header is not None and not runtime_token_header.strip(): | ||
| raise ValueError("runtime_token_header must not be blank.") | ||
| # A whitespace-only env var falls back to the default header, matching | ||
| # the server's _resolve_runtime_token_header(); an explicit blank | ||
| # *param* above is a hard error. | ||
| env_runtime_token_header = os.environ.get(_RUNTIME_TOKEN_HEADER_ENV_VAR) | ||
| if env_runtime_token_header is not None and not env_runtime_token_header.strip(): | ||
| env_runtime_token_header = None | ||
| self._runtime_token_header = ( | ||
| runtime_token_header | ||
| or env_runtime_token_header | ||
| or _DEFAULT_RUNTIME_TOKEN_HEADER | ||
| ).strip() | ||
| # On Authorization the server keeps the ``Bearer`` scheme prefix | ||
| # (existing contract); a dedicated header carries the raw token so the | ||
| # value no longer collides with a gateway's Authorization JWT. | ||
| # Mirror of LocalJwtVerifyProvider._require_bearer on the server — the | ||
| # send format and the verifier's expectation must stay in sync. | ||
| self._runtime_token_use_bearer = ( | ||
| self._runtime_token_header.lower() == _DEFAULT_RUNTIME_TOKEN_HEADER.lower() | ||
| ) | ||
| if runtime_token_refresh_margin_seconds < 0: | ||
| raise ValueError("runtime_token_refresh_margin_seconds must be >= 0.") | ||
| self._runtime_token_refresh_margin_seconds = runtime_token_refresh_margin_seconds | ||
|
|
@@ -198,7 +249,13 @@ async def __aenter__(self) -> "AgentControlClient": | |
| base_url=self.base_url, | ||
| timeout=self.timeout, | ||
| headers=self._get_headers(), | ||
| auth=_AgentControlAuth(self._api_key, self._api_key_header), | ||
| auth=_AgentControlAuth( | ||
| self._api_key, | ||
| self._api_key_header, | ||
| runtime_token_header=( | ||
| None if self._runtime_token_use_bearer else self._runtime_token_header | ||
| ), | ||
| ), | ||
| transport=self._transport, | ||
| event_hooks={"response": [self._check_server_version]}, | ||
| ) | ||
|
|
@@ -295,18 +352,28 @@ async def post_runtime_evaluation( | |
|
|
||
| return response | ||
|
|
||
| def _format_runtime_token(self, token: str) -> str: | ||
| """Format a runtime token for the configured header. | ||
|
|
||
| Prefixes ``Bearer `` only on the ``Authorization`` header (existing | ||
| server contract); a dedicated runtime header carries the raw token. | ||
| """ | ||
| if self._runtime_token_use_bearer: | ||
| return f"Bearer {token}" | ||
| return token | ||
|
|
||
| def _merge_runtime_headers( | ||
| self, | ||
| headers: dict[str, str] | None, | ||
| runtime_authorization: str | None, | ||
| ) -> dict[str, str] | None: | ||
| """Merge caller headers with an optional Bearer token.""" | ||
| """Merge caller headers with an optional runtime token header.""" | ||
| if headers is None and runtime_authorization is None: | ||
| return None | ||
|
|
||
| merged = dict(headers or {}) | ||
| if runtime_authorization is not None: | ||
| merged["Authorization"] = runtime_authorization | ||
| merged[self._runtime_token_header] = runtime_authorization | ||
| return merged | ||
|
|
||
| async def _runtime_authorization( | ||
|
|
@@ -350,7 +417,7 @@ async def _runtime_authorization( | |
| refresh_margin_seconds=self._runtime_token_refresh_margin_seconds, | ||
| ) | ||
| if cached is not None: | ||
| return f"Bearer {cached.token}" | ||
| return self._format_runtime_token(cached.token) | ||
|
|
||
| exchange_lock = self._runtime_token_cache.exchange_lock( | ||
| self.base_url, | ||
|
|
@@ -379,7 +446,7 @@ async def _runtime_authorization( | |
| refresh_margin_seconds=self._runtime_token_refresh_margin_seconds, | ||
| ) | ||
| if cached is not None: | ||
| return f"Bearer {cached.token}" | ||
| return self._format_runtime_token(cached.token) | ||
|
|
||
| token = await self._exchange_runtime_token( | ||
| target_type=target_type, | ||
|
|
@@ -388,7 +455,7 @@ async def _runtime_authorization( | |
| ) | ||
| if token is None: | ||
| return None | ||
| return f"Bearer {token}" | ||
| return self._format_runtime_token(token) | ||
|
|
||
| async def _exchange_runtime_token( | ||
| self, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we keep the configured request credential when the runtime token is sent on a dedicated header? In a two-hop setup, the outer credential authenticates the gateway while the runtime token authenticates Agent Control, so both are required on the evaluation request. Please update the custom-header test to assert that both headers are sent.