diff --git a/.cortexkit/.gitignore b/.cortexkit/.gitignore new file mode 100644 index 0000000000..ee3fa486df --- /dev/null +++ b/.cortexkit/.gitignore @@ -0,0 +1,3 @@ +# >>> cortexkit:magic-context +magic-context/ +# <<< cortexkit:magic-context diff --git a/app/db/alembic/versions/20260713_080000_add_subagent_prompt_cache_ttl.py b/app/db/alembic/versions/20260713_080000_add_subagent_prompt_cache_ttl.py new file mode 100644 index 0000000000..0df55ac8e4 --- /dev/null +++ b/app/db/alembic/versions/20260713_080000_add_subagent_prompt_cache_ttl.py @@ -0,0 +1,65 @@ +"""add subagent prompt-cache TTL and sticky is_subagent flag + +Revision ID: 20260713_080000_add_subagent_prompt_cache_ttl +Revises: 20260717_000000_optimize_dashboard_hot_path_indexes +Create Date: 2026-07-13 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "20260713_080000_add_subagent_prompt_cache_ttl" +down_revision = "20260717_000000_optimize_dashboard_hot_path_indexes" +branch_labels = None +depends_on = None + + +def _columns(bind, table_name: str) -> set[str]: + inspector = sa.inspect(bind) + if not inspector.has_table(table_name): + return set() + return {column["name"] for column in inspector.get_columns(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + + settings_columns = _columns(bind, "dashboard_settings") + if settings_columns and "http_responses_session_bridge_subagent_prompt_cache_ttl_seconds" not in settings_columns: + with op.batch_alter_table("dashboard_settings") as batch_op: + batch_op.add_column( + sa.Column( + "http_responses_session_bridge_subagent_prompt_cache_ttl_seconds", + sa.Integer(), + nullable=True, + server_default=None, + ) + ) + + sticky_columns = _columns(bind, "sticky_sessions") + if sticky_columns and "is_subagent" not in sticky_columns: + with op.batch_alter_table("sticky_sessions") as batch_op: + batch_op.add_column( + sa.Column( + "is_subagent", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ) + ) + + +def downgrade() -> None: + bind = op.get_bind() + + settings_columns = _columns(bind, "dashboard_settings") + if settings_columns and "http_responses_session_bridge_subagent_prompt_cache_ttl_seconds" in settings_columns: + with op.batch_alter_table("dashboard_settings") as batch_op: + batch_op.drop_column("http_responses_session_bridge_subagent_prompt_cache_ttl_seconds") + + sticky_columns = _columns(bind, "sticky_sessions") + if sticky_columns and "is_subagent" in sticky_columns: + with op.batch_alter_table("sticky_sessions") as batch_op: + batch_op.drop_column("is_subagent") diff --git a/app/db/models.py b/app/db/models.py index a1ec94065d..798da2d8e5 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -598,6 +598,12 @@ class StickySession(Base): onupdate=func.now(), nullable=False, ) + is_subagent: Mapped[bool] = mapped_column( + Boolean, + default=False, + server_default=false(), + nullable=False, + ) class DashboardSettings(Base): @@ -733,6 +739,12 @@ class DashboardSettings(Base): server_default=text("3600"), nullable=False, ) + http_responses_session_bridge_subagent_prompt_cache_ttl_seconds: Mapped[int | None] = mapped_column( + Integer, + default=None, + server_default=None, + nullable=True, + ) http_responses_session_bridge_gateway_safe_mode: Mapped[bool] = mapped_column( Boolean, default=False, diff --git a/app/modules/proxy/_service/http_bridge/helpers.py b/app/modules/proxy/_service/http_bridge/helpers.py index 3cc36ef234..7cd45f05bf 100644 --- a/app/modules/proxy/_service/http_bridge/helpers.py +++ b/app/modules/proxy/_service/http_bridge/helpers.py @@ -1851,6 +1851,119 @@ def _http_bridge_previous_response_owner_unavailable_error() -> ProxyResponseErr ) +_SUBAGENT_HEADER_NAMES = ("x-parent-session-id", "x-openai-subagent", "x-codex-parent-thread-id") + + +def _detect_subagent_session( + headers: Mapping[str, str], + dashboard_settings: Any, +) -> tuple[bool, int | None]: + ttl = getattr( + dashboard_settings, + "http_responses_session_bridge_subagent_prompt_cache_ttl_seconds", + None, + ) + if ttl is not None and ttl <= 0: + ttl = None + is_subagent = any(headers.get(name, "").strip() for name in _SUBAGENT_HEADER_NAMES) + return is_subagent, ttl + + +def _subagent_prompt_cache_bridge_key( + key: _HTTPBridgeSessionKey, + affinity: _AffinityPolicy, + is_subagent: bool, + forwarded_request: bool, + idle_ttl_seconds: float, + request_scope_id: str, + subagent_prompt_cache_ttl_seconds: int | None, +) -> tuple[_HTTPBridgeSessionKey, _AffinityPolicy, float, int | None]: + prompt_cache_affinity_key = affinity.key if affinity.kind is StickySessionKind.PROMPT_CACHE else None + is_prompt_cache = bool(prompt_cache_affinity_key) + effective_subagent_prompt_cache_ttl_seconds = subagent_prompt_cache_ttl_seconds if is_prompt_cache else None + if ( + not is_subagent + or forwarded_request + or key.affinity_key.startswith("subagent:") + or (affinity.key is not None and affinity.key.startswith("subagent:")) + ): + return key, affinity, idle_ttl_seconds, effective_subagent_prompt_cache_ttl_seconds + + if is_prompt_cache and subagent_prompt_cache_ttl_seconds is not None: + assert prompt_cache_affinity_key is not None + subagent_affinity_key = f"subagent:{prompt_cache_affinity_key}" + subagent_affinity_kind = StickySessionKind.PROMPT_CACHE.value + effective_idle_ttl_seconds = float(subagent_prompt_cache_ttl_seconds) + effective_max_age_seconds = subagent_prompt_cache_ttl_seconds + else: + subagent_affinity_key = f"subagent:{request_scope_id}:{prompt_cache_affinity_key or key.affinity_key}" + subagent_affinity_kind = key.affinity_kind + effective_idle_ttl_seconds = idle_ttl_seconds + effective_max_age_seconds = affinity.max_age_seconds + return ( + _HTTPBridgeSessionKey( + subagent_affinity_kind, + subagent_affinity_key, + key.api_key_id, + strength=None if subagent_affinity_kind == StickySessionKind.PROMPT_CACHE.value else key.strength, + ), + _AffinityPolicy( + key=subagent_affinity_key, + kind=affinity.kind, + reallocate_sticky=affinity.reallocate_sticky, + max_age_seconds=effective_max_age_seconds, + ), + effective_idle_ttl_seconds, + effective_subagent_prompt_cache_ttl_seconds, + ) + + +async def _delete_completed_subagent_sticky_mapping( + repo_factory: Callable[..., Any], + session: Any, +) -> None: + if not ( + getattr(session, "is_subagent", False) + and getattr(session, "subagent_prompt_cache_ttl_seconds", None) is None + and getattr(getattr(session, "affinity", None), "kind", None) is StickySessionKind.PROMPT_CACHE + ): + return + affinity = getattr(session, "affinity", None) + if affinity is None: + return + try: + async with repo_factory() as repositories: + keys_to_delete = {affinity.key} + if isinstance(affinity.key, str) and affinity.key.startswith("subagent:"): + parts = affinity.key.split(":", 2) + if len(parts) == 3 and parts[2]: + keys_to_delete.add(f"subagent:{parts[2]}") + for key in keys_to_delete: + await repositories.sticky_sessions.delete( + key, + kind=StickySessionKind.PROMPT_CACHE, + is_subagent=True, + ) + except Exception: + logger.warning("Failed to delete completed subagent sticky mapping", exc_info=True) + + +def _sync_reused_http_bridge_session( + session: Any, + metadata: tuple[Any, str | None, str | None, float, bool, int | None], +) -> None: + api_key, request_model, request_service_tier, idle_ttl_seconds, is_subagent, subagent_ttl = metadata + session.api_key = api_key + session.request_model = request_model + session.request_service_tier = request_service_tier + session.last_used_at = _service_time().monotonic() + if not (getattr(session, "is_subagent", False) or is_subagent): + return + session.idle_ttl_seconds = idle_ttl_seconds + session.is_subagent = True + session.subagent_prompt_cache_ttl_seconds = subagent_ttl + + def _http_bridge_should_attempt_local_previous_response_recovery(exc: ProxyResponseError) -> bool: payload = exc.payload if not isinstance(payload, dict): @@ -2182,6 +2295,9 @@ def _wrapper(*args: Any, **kwargs: Any) -> Any: "_http_bridge_previous_response_error_envelope", "_http_bridge_continuity_lost_error_envelope", "_http_bridge_owner_lookup_unavailable_error_envelope", + "_detect_subagent_session", + "_subagent_prompt_cache_bridge_key", + "_delete_completed_subagent_sticky_mapping", "_http_bridge_should_attempt_local_previous_response_recovery", "_http_bridge_is_previous_response_owner_unavailable", "_http_bridge_should_attempt_soft_affinity_reroute", diff --git a/app/modules/proxy/_service/http_bridge/mixin.py b/app/modules/proxy/_service/http_bridge/mixin.py index a1f2c125bb..b104bc3fab 100644 --- a/app/modules/proxy/_service/http_bridge/mixin.py +++ b/app/modules/proxy/_service/http_bridge/mixin.py @@ -71,6 +71,8 @@ _HTTP_BRIDGE_INFLIGHT_STARTED_AT_ATTR, _active_http_bridge_instance_ring, _close_http_bridge_session_bounded, + _delete_completed_subagent_sticky_mapping, + _detect_subagent_session, _durable_bridge_lookup_active_owner, _durable_bridge_lookup_allows_local_reuse, _forwarded_http_bridge_session_key, @@ -124,6 +126,8 @@ _renew_durable_http_bridge_lease, _require_http_bridge_bound_account_not_excluded, _reserve_http_bridge_unanchored_handoff, + _subagent_prompt_cache_bridge_key, + _sync_reused_http_bridge_session, _track_alias_registration, ) from app.modules.proxy._service.http_bridge.owner_forwarding import _HTTPBridgeOwnerForwardingMixin @@ -252,20 +256,10 @@ class _HTTPBridgeMixin( _HTTPBridgeUpstreamEventsMixin, _HTTPBridgeServiceProtocol, ): - async def _close_http_bridge_session_bounded( - self, - session: "_HTTPBridgeSession", - *, - reason: str, - ) -> None: + async def _close_http_bridge_session_bounded(self, session: "_HTTPBridgeSession", *, reason: str) -> None: await _close_http_bridge_session_bounded(self, session, reason=reason) - def _schedule_http_bridge_session_closes( - self, - sessions: list["_HTTPBridgeSession"], - *, - reason: str, - ) -> None: + def _schedule_http_bridge_session_closes(self, sessions: list["_HTTPBridgeSession"], *, reason: str) -> None: for session in sessions: if len(self._background_cleanup_tasks) >= _HTTP_BRIDGE_BACKGROUND_CLEANUP_WARN_THRESHOLD: logger.warning( @@ -375,7 +369,6 @@ async def _get_or_create_http_bridge_session( request_deadline: float | None = None, session_header_fallback_key: "_HTTPBridgeSessionKey | None" = None, ) -> "_HTTPBridgeSession": ... - @overload async def _get_or_create_http_bridge_session( self, @@ -434,20 +427,41 @@ async def _get_or_create_http_bridge_session( request_deadline: float | None = None, session_header_fallback_key: "_HTTPBridgeSessionKey | None" = None, ) -> "_HTTPBridgeSession | _HTTPBridgeOwnerForward": - settings = _service_get_settings() + settings, dashboard_settings = _service_get_settings(), await _service_get_settings_cache().get() request_scope_id = ensure_request_scope_id() api_key_id = api_key.id if api_key is not None else None incoming_turn_state = _sticky_key_from_turn_state_header(headers) incoming_session_key = _sticky_key_from_session_header(headers) initial_session_key = session_header_fallback_key or (key if key.affinity_kind == "session_header" else None) + original_key, original_affinity_key = key, affinity.key original_request_unanchored = _http_bridge_request_needs_unanchored_handoff( key, incoming_turn_state, previous_response_id, forwarded_request, forwarded_original_request_unanchored ) + is_subagent_session, subagent_prompt_cache_ttl_seconds = _detect_subagent_session(headers, dashboard_settings) + subagent_key_result = _subagent_prompt_cache_bridge_key( + key, + affinity, + is_subagent_session, + forwarded_request, + idle_ttl_seconds, + request_scope_id, + subagent_prompt_cache_ttl_seconds, + ) + key, affinity, effective_idle_ttl_seconds, subagent_prompt_cache_ttl_seconds = subagent_key_result + if key != original_key or affinity.key != original_affinity_key: + incoming_turn_state = incoming_session_key = initial_session_key = None + reuse_metadata = ( + api_key, + request_model, + request_service_tier, + effective_idle_ttl_seconds, + is_subagent_session, + subagent_prompt_cache_ttl_seconds, + ) model_transition_rebind = bool( durable_lookup is not None and not _http_bridge_models_compatible(durable_lookup.model, request_model) ) - if model_transition_rebind: - durable_lookup = None + durable_lookup = None if model_transition_rebind else durable_lookup if await _http_bridge_should_wait_for_registration(self, key, settings): skip_registration_gate = False async with self._http_bridge_lock: @@ -474,7 +488,6 @@ async def _get_or_create_http_bridge_session( error_type="server_error", ), ) - effective_idle_ttl_seconds = idle_ttl_seconds forwarded_affinity = ( _forwarded_http_bridge_session_key( headers, @@ -652,10 +665,7 @@ async def _get_or_create_http_bridge_session( assert existing is not None current_instance = settings.http_responses_session_bridge_instance_id if _durable_bridge_lookup_allows_local_reuse(durable_lookup, current_instance=current_instance): - existing.api_key = api_key - existing.request_model = request_model - existing.request_service_tier = request_service_tier - existing.last_used_at = _service_time().monotonic() + _sync_reused_http_bridge_session(existing, reuse_metadata) await _refresh_reused_http_bridge_session_with_handoff( self, existing, @@ -1285,7 +1295,6 @@ async def _get_or_create_http_bridge_session( except Exception: pass continue - if inflight_future is not None and not owns_creation: wait_timeout_seconds = _proxy_admission_wait_timeout_seconds(settings) try: @@ -1355,10 +1364,7 @@ async def _get_or_create_http_bridge_session( ): current_instance = settings.http_responses_session_bridge_instance_id if _durable_bridge_lookup_allows_local_reuse(durable_lookup, current_instance=current_instance): - session.api_key = api_key - session.request_model = request_model - session.request_service_tier = request_service_tier - session.last_used_at = _service_time().monotonic() + _sync_reused_http_bridge_session(session, reuse_metadata) return session if not session.closed and session.account.status == AccountStatus.ACTIVE: old_account_id = session.account.id @@ -1374,7 +1380,6 @@ async def _get_or_create_http_bridge_session( if detached is not None and not retiring_with_visible_requests: self._schedule_http_bridge_session_closes([detached], reason="registry_detach") continue - created_session: _HTTPBridgeSession | None = None session_registered = False try: @@ -1386,6 +1391,8 @@ async def _get_or_create_http_bridge_session( "request_model": request_model, "request_service_tier": request_service_tier, "idle_ttl_seconds": effective_idle_ttl_seconds, + "is_subagent": is_subagent_session, + "subagent_prompt_cache_ttl_seconds": subagent_prompt_cache_ttl_seconds, "request_stage": request_stage, "preferred_account_id": preferred_account_id, "require_preferred_account": require_preferred_account, @@ -1404,7 +1411,13 @@ async def _get_or_create_http_bridge_session( for parameter in create_signature.parameters.values() ) if create_signature is not None and not create_accepts_var_keyword: - for optional_kwarg in ("request_service_tier", "request_usage_budget", "request_deadline"): + for optional_kwarg in ( + "request_service_tier", + "request_usage_budget", + "request_deadline", + "is_subagent", + "subagent_prompt_cache_ttl_seconds", + ): if optional_kwarg not in create_signature.parameters: create_kwargs.pop(optional_kwarg, None) created_session = await create_session(key, **create_kwargs) @@ -1598,6 +1611,7 @@ async def _close_http_bridge_session( api_key=None, response_create_gate=response_create_gate, ) + await _delete_completed_subagent_sticky_mapping(self._repo_factory, session) _log_http_bridge_event( "close", session.key, @@ -1817,12 +1831,10 @@ async def _claim_durable_http_bridge_session( async def _refresh_durable_http_bridge_session(self, session: "_HTTPBridgeSession") -> None: """Renew the durable lease; callers must hold ``self._http_bridge_lock``.""" - await _renew_durable_http_bridge_lease(self, session) async def reconcile_durable_http_bridge_ownership(self) -> int: """Close local sessions whose durable row is owned by another instance/epoch.""" - return await _reconcile_durable_http_bridge_ownership(self) async def _create_http_bridge_session( @@ -1841,6 +1853,8 @@ async def _create_http_bridge_session( fallback_on_preferred_account_unavailable: bool = True, request_usage_budget: ApiKeyRequestUsageBudget | None = None, request_deadline: float | None = None, + is_subagent: bool = False, + subagent_prompt_cache_ttl_seconds: int | None = None, ) -> "_HTTPBridgeSession": request_state = _WebSocketRequestState( request_id=f"http_bridge_connect_{uuid4().hex}", @@ -1865,12 +1879,23 @@ async def _create_http_bridge_session( preferred_candidate_id = preferred_account_id selected_account_lease: AccountLease | None = None while True: + subagent_no_cache = is_subagent and subagent_prompt_cache_ttl_seconds is None select_kwargs = { "request_id": request_state.request_log_id or request_state.request_id, "kind": "http_bridge", "request_stage": request_stage, "api_key": api_key, - "affinity_policy": affinity, + "sticky_key": None if subagent_no_cache else affinity.selection_key, + "sticky_kind": None if subagent_no_cache else affinity.kind, + "reallocate_sticky": affinity.reallocate_sticky, + "sticky_source": None if subagent_no_cache else affinity.codex_session_source, + "legacy_sticky_key": None if subagent_no_cache else affinity.legacy_selection_key, + "spill_bare_session_on_account_cap": False if subagent_no_cache else affinity.spill_on_account_cap, + "require_unambiguous_account": False if subagent_no_cache else affinity.require_unambiguous_account, + "sticky_max_age_seconds": subagent_prompt_cache_ttl_seconds + if is_subagent + else affinity.max_age_seconds, + "sticky_is_subagent": is_subagent, "prefer_earlier_reset_accounts": settings.prefer_earlier_reset_accounts, "prefer_earlier_reset_window": _prefer_earlier_reset_window(settings), "routing_strategy": _routing_strategy(settings), @@ -2087,6 +2112,8 @@ async def _create_http_bridge_session( downstream_turn_state=None, account_lease=selected_account_lease, catalog_omission_quota_admission=selection.catalog_omission_quota_admission, + is_subagent=is_subagent, + subagent_prompt_cache_ttl_seconds=subagent_prompt_cache_ttl_seconds, ) _copy_websocket_route_metadata_to_session(session, request_state) session.upstream_reader = asyncio.create_task(self._relay_http_bridge_upstream_messages(session)) diff --git a/app/modules/proxy/_service/http_bridge/streaming.py b/app/modules/proxy/_service/http_bridge/streaming.py index f503f89e5b..ea1e73367e 100644 --- a/app/modules/proxy/_service/http_bridge/streaming.py +++ b/app/modules/proxy/_service/http_bridge/streaming.py @@ -65,6 +65,7 @@ _sticky_key_from_compact_payload as _sticky_key_from_compact_payload, ) from app.modules.proxy._service.http_bridge.helpers import ( + _detect_subagent_session, _effective_http_bridge_idle_ttl_seconds, _http_bridge_durable_lookup_allows_turn_state_takeover, _http_bridge_is_context_overflow_error, @@ -90,6 +91,7 @@ _release_http_bridge_unanchored_handoff, _release_http_bridge_unanchored_handoffs_for_request, _reserve_http_bridge_unanchored_handoff, + _subagent_prompt_cache_bridge_key, _trim_http_bridge_previous_response_input_items, ) from app.modules.proxy._service.http_bridge.service_stubs import ( @@ -472,7 +474,30 @@ async def _registered_turn_state_anchor_lookup( return lookup +def _http_bridge_session_is_unanchored_parallel_fork(session: Any) -> bool: + return session.key.affinity_kind == "internal_unanchored_parallel" + + class _HTTPBridgeStreamingMixin: + async def _refresh_completed_subagent_sticky_mapping(self, session: _HTTPBridgeSession) -> None: + if not ( + session.is_subagent + and session.subagent_prompt_cache_ttl_seconds is not None + and session.affinity.kind is StickySessionKind.PROMPT_CACHE + and session.affinity.key + ): + return + try: + async with cast(Any, self)._repo_factory() as repositories: + await repositories.sticky_sessions.upsert( + session.affinity.key, + session.account.id, + kind=StickySessionKind.PROMPT_CACHE, + is_subagent=True, + ) + except Exception: + logger.warning("Failed to refresh completed subagent sticky mapping", exc_info=True) + async def validate_http_bridge_legacy_forward_anchor( self: Any, *, @@ -767,6 +792,34 @@ def prepare_bridge_request( forwarded_affinity_kind=forwarded_affinity_kind, forwarded_affinity_key=forwarded_affinity_key, ) + request_scope_id = ensure_request_scope_id() + is_subagent_session, subagent_prompt_cache_ttl_seconds = _detect_subagent_session( + headers, + dashboard_settings, + ) + original_bridge_session_key = bridge_session_key + original_affinity_key = affinity.key + ( + bridge_session_key, + affinity, + _effective_lookup_idle_ttl_seconds, + _effective_subagent_prompt_cache_ttl_seconds, + ) = _subagent_prompt_cache_bridge_key( + bridge_session_key, + affinity, + is_subagent_session, + forwarded_request, + idle_ttl_seconds, + request_scope_id, + subagent_prompt_cache_ttl_seconds, + ) + del _effective_lookup_idle_ttl_seconds, _effective_subagent_prompt_cache_ttl_seconds + subagent_bridge_key_isolated = ( + bridge_session_key != original_bridge_session_key or affinity.key != original_affinity_key + ) + if subagent_bridge_key_isolated: + incoming_turn_state_header = None + incoming_session_header = None session_header_fallback_key = ( _make_http_bridge_session_header_fallback_key( headers=headers, @@ -776,10 +829,12 @@ def prepare_bridge_request( if not forwarded_request else None ) + if subagent_bridge_key_isolated: + session_header_fallback_key = None legacy_anchor_lookup = await _legacy_forward_anchor_lookup( durable_bridge=self._durable_bridge, bridge_session_key=bridge_session_key, - turn_state=_sticky_key_from_turn_state_header(headers), + turn_state=incoming_turn_state_header, api_key=api_key, previous_response_id=payload.previous_response_id, forwarded_request=forwarded_request, @@ -789,7 +844,7 @@ def prepare_bridge_request( incoming_turn_state_header = _sticky_key_from_turn_state_header(headers) original_request_unanchored = _http_bridge_request_needs_unanchored_handoff( bridge_session_key, - _sticky_key_from_turn_state_header(headers), + incoming_turn_state_header, payload.previous_response_id, forwarded_request, forwarded_original_request_unanchored, @@ -2268,3 +2323,9 @@ async def _stream_http_bridge_session_events( with anyio.CancelScope(shield=True): await self._detach_http_bridge_request(session, request_state=request_state) session.last_used_at = _service_time().monotonic() + if _http_bridge_session_is_unanchored_parallel_fork(session): + await self._close_http_bridge_session(session) + elif session.is_subagent: + if session.subagent_prompt_cache_ttl_seconds is not None: + await self._refresh_completed_subagent_sticky_mapping(session) + await self._close_http_bridge_session(session) diff --git a/app/modules/proxy/_service/response_create.py b/app/modules/proxy/_service/response_create.py index 8795115449..9686cd454b 100644 --- a/app/modules/proxy/_service/response_create.py +++ b/app/modules/proxy/_service/response_create.py @@ -49,6 +49,7 @@ _OVERSIZED_RESPONSE_CREATE_DUMP_DIR: Path | None = None _RESPONSE_CREATE_COMPATIBILITY_METADATA_HEADERS = ( "x-codex-turn-metadata", + "x-parent-session-id", "x-openai-subagent", "x-codex-parent-thread-id", "x-codex-window-id", diff --git a/app/modules/proxy/_service/support.py b/app/modules/proxy/_service/support.py index a019789f91..44c04059c5 100644 --- a/app/modules/proxy/_service/support.py +++ b/app/modules/proxy/_service/support.py @@ -886,6 +886,8 @@ class _HTTPBridgeSession: upstream_proxy_endpoint_id: str | None = None upstream_proxy_fallback_used: bool | None = None upstream_proxy_fail_closed_reason: str | None = None + is_subagent: bool = False + subagent_prompt_cache_ttl_seconds: int | None = None def _http_bridge_session_supports_service_tier( diff --git a/app/modules/proxy/load_balancer.py b/app/modules/proxy/load_balancer.py index 2980d1987c..e3d7d71871 100644 --- a/app/modules/proxy/load_balancer.py +++ b/app/modules/proxy/load_balancer.py @@ -364,6 +364,7 @@ async def select_account( spill_bare_session_on_account_cap: bool = False, require_unambiguous_account: bool = False, sticky_max_age_seconds: int | None = None, + sticky_is_subagent: bool = False, prefer_earlier_reset_accounts: bool = False, prefer_earlier_reset_window: ResetPreferenceWindow = "secondary", routing_strategy: RoutingStrategy = "capacity_weighted", @@ -898,6 +899,7 @@ async def load_selection_inputs() -> _SelectionInputs: sticky_kind=sticky_kind, reallocate_sticky=reallocate_sticky, sticky_max_age_seconds=sticky_max_age_seconds, + sticky_is_subagent=sticky_is_subagent, budget_threshold_pct=budget_threshold_pct, secondary_budget_threshold_pct=secondary_budget_threshold_pct, prefer_earlier_reset_accounts=prefer_earlier_reset_accounts, @@ -1559,6 +1561,7 @@ async def _select_with_stickiness( sticky_kind: StickySessionKind | None, reallocate_sticky: bool, sticky_max_age_seconds: int | None, + sticky_is_subagent: bool = False, budget_threshold_pct: float = 95.0, secondary_budget_threshold_pct: float = 100.0, prefer_earlier_reset_accounts: bool, @@ -1588,6 +1591,10 @@ async def _select_with_stickiness( ) if sticky_kind is None: raise ValueError("sticky_kind is required when sticky_key is provided") + sticky_upsert_kwargs = { + "kind": sticky_kind, + "is_subagent": sticky_is_subagent, + } if sticky_existing_account_id is _STICKY_EXISTING_UNSET: existing = await sticky_repo.get_account_id( @@ -1671,7 +1678,11 @@ async def _select_with_stickiness( ) if pinned_result.account is not None: if sticky_max_age_seconds is not None: - await sticky_repo.upsert(sticky_key, pinned.account_id, kind=sticky_kind) + await sticky_repo.upsert( + sticky_key, + pinned.account_id, + **sticky_upsert_kwargs, + ) return pinned_result else: # Reallocate only when a burn-first target exists and can @@ -1725,7 +1736,7 @@ async def _select_with_stickiness( await sticky_repo.upsert( sticky_key, pinned.account_id, - kind=sticky_kind, + **sticky_upsert_kwargs, ) return pinned_result reallocate_sticky = True @@ -1752,7 +1763,11 @@ async def _select_with_stickiness( ) if grace_result.account is not None: if sticky_max_age_seconds is not None: - await sticky_repo.upsert(sticky_key, pinned.account_id, kind=sticky_kind) + await sticky_repo.upsert( + sticky_key, + pinned.account_id, + **sticky_upsert_kwargs, + ) return grace_result if reallocate_sticky: await sticky_repo.delete(sticky_key, kind=sticky_kind) @@ -1788,7 +1803,11 @@ async def _select_with_stickiness( routing_costs_by_account_id=routing_costs_by_account_id, ) if persist_fallback and chosen.account is not None and chosen.account.account_id in account_map: - await sticky_repo.upsert(sticky_key, chosen.account.account_id, kind=sticky_kind) + await sticky_repo.upsert( + sticky_key, + chosen.account.account_id, + **sticky_upsert_kwargs, + ) elif preserve_existing_mapping_on_fallback and chosen.account is not None and existing is not None: # Spillover is deliberately request-local. The alternate may create # its own hard response/file/bridge owner, but local cap pressure diff --git a/app/modules/proxy/service.py b/app/modules/proxy/service.py index 88be05119d..b5c8089495 100644 --- a/app/modules/proxy/service.py +++ b/app/modules/proxy/service.py @@ -1701,6 +1701,7 @@ async def _select_account_with_budget( spill_bare_session_on_account_cap: bool = False, require_unambiguous_account: bool = False, sticky_max_age_seconds: int | None = None, + sticky_is_subagent: bool = False, prefer_earlier_reset_accounts: bool = False, prefer_earlier_reset_window: ResetPreferenceWindow = "secondary", routing_strategy: RoutingStrategy = "capacity_weighted", @@ -1832,6 +1833,7 @@ async def _select_account_with_budget( sticky_max_age_seconds=preferred_sticky_inputs[3], sticky_source=preferred_sticky_inputs[4], legacy_sticky_key=preferred_sticky_inputs[5], + sticky_is_subagent=sticky_is_subagent, prefer_earlier_reset_accounts=prefer_earlier_reset_accounts, prefer_earlier_reset_window=prefer_earlier_reset_window, routing_strategy=routing_strategy, @@ -1886,6 +1888,7 @@ async def _select_account_with_budget( ), require_unambiguous_account=require_unambiguous_account, sticky_max_age_seconds=sticky_max_age_seconds, + sticky_is_subagent=sticky_is_subagent, prefer_earlier_reset_accounts=prefer_earlier_reset_accounts, prefer_earlier_reset_window=prefer_earlier_reset_window, routing_strategy=routing_strategy, diff --git a/app/modules/proxy/sticky_repository.py b/app/modules/proxy/sticky_repository.py index 83080fbba3..1253d64fe9 100644 --- a/app/modules/proxy/sticky_repository.py +++ b/app/modules/proxy/sticky_repository.py @@ -11,7 +11,7 @@ from sqlalchemy.sql import Insert from app.core.utils.time import to_utc_naive, utcnow -from app.db.models import Account, StickySession, StickySessionKind +from app.db.models import Account, HttpBridgeSessionRecord, HttpBridgeSessionState, StickySession, StickySessionKind from app.db.session import sqlite_writer_section from app.modules.sticky_sessions.schemas import StickySessionSortBy, StickySessionSortDir @@ -64,13 +64,25 @@ async def get_entry(self, key: str, *, kind: StickySessionKind) -> StickySession result = await self._session.execute(statement) return result.scalar_one_or_none() - async def upsert(self, key: str, account_id: str, *, kind: StickySessionKind) -> StickySession: + async def upsert( + self, + key: str, + account_id: str, + *, + kind: StickySessionKind, + is_subagent: bool = False, + ) -> StickySession: # RETURNING collapses the previous upsert + re-select + refresh # (4 round trips) into one statement; this runs inline before the # first upstream byte on sticky requests, so round trips are TTFT. # populate_existing forces the returned row to overwrite any stale # identity-map instance the session may already hold for this key. - statement = self._build_upsert_statement(key, account_id, kind).returning(StickySession) + statement = self._build_upsert_statement( + key, + account_id, + kind, + is_subagent=is_subagent, + ).returning(StickySession) async with sqlite_writer_section(): result = await self._session.execute(statement, execution_options={"populate_existing": True}) row = result.scalar_one_or_none() @@ -79,13 +91,21 @@ async def upsert(self, key: str, account_id: str, *, kind: StickySessionKind) -> raise RuntimeError(f"StickySession upsert failed for key={key!r} kind={kind.value!r}") return row - async def delete(self, key: str, *, kind: StickySessionKind) -> bool: + async def delete( + self, + key: str, + *, + kind: StickySessionKind, + is_subagent: bool | None = None, + ) -> bool: if not key: return False statement = delete(StickySession).where( StickySession.key == key, StickySession.kind == kind, ) + if is_subagent is not None: + statement = statement.where(StickySession.is_subagent == is_subagent) async with sqlite_writer_section(): result = await self._session.execute(statement.returning(StickySession.key)) await self._session.commit() @@ -117,6 +137,7 @@ async def list_entry_identifiers( *, kind: StickySessionKind | None = None, updated_before: datetime | None = None, + is_subagent: bool | None = None, account_query: str | None = None, key_query: str | None = None, ) -> list[tuple[str, StickySessionKind]]: @@ -125,6 +146,7 @@ async def list_entry_identifiers( select(StickySession.key, StickySession.kind), kind=kind, updated_before=updated_before, + is_subagent=is_subagent, account_query=account_query, key_query=key_query, ) @@ -143,6 +165,7 @@ async def list_entries( *, kind: StickySessionKind | None = None, updated_before: datetime | None = None, + is_subagent: bool | None = None, account_query: str | None = None, key_query: str | None = None, sort_by: StickySessionSortBy = "updated_at", @@ -156,6 +179,7 @@ async def list_entries( select(StickySession, Account.email), kind=kind, updated_before=updated_before, + is_subagent=is_subagent, account_query=account_query, key_query=key_query, ) @@ -177,6 +201,7 @@ async def count_entries( *, kind: StickySessionKind | None = None, updated_before: datetime | None = None, + is_subagent: bool | None = None, account_query: str | None = None, key_query: str | None = None, ) -> int: @@ -184,26 +209,78 @@ async def count_entries( select(func.count()).select_from(StickySession).join(Account, Account.id == StickySession.account_id), kind=kind, updated_before=updated_before, + is_subagent=is_subagent, account_query=account_query, key_query=key_query, ) result = await self._session.execute(statement) return int(result.scalar_one()) - async def purge_prompt_cache_before(self, cutoff: datetime) -> int: - return await self.purge_before(cutoff, kind=StickySessionKind.PROMPT_CACHE) + async def purge_prompt_cache_before( + self, + cutoff: datetime, + *, + is_subagent: bool | None = None, + protect_active_bridge_mappings: bool = False, + ) -> int: + if protect_active_bridge_mappings: + return await self._purge_prompt_cache_before_excluding_active_bridge(cutoff, is_subagent=is_subagent) + return await self.purge_before(cutoff, kind=StickySessionKind.PROMPT_CACHE, is_subagent=is_subagent) - async def purge_before(self, cutoff: datetime, *, kind: StickySessionKind | None = None) -> int: + async def _purge_prompt_cache_before_excluding_active_bridge( + self, + cutoff: datetime, + *, + is_subagent: bool | None, + ) -> int: + active_bridge_mapping = ( + select(HttpBridgeSessionRecord.id) + .where( + HttpBridgeSessionRecord.session_key_kind == StickySessionKind.PROMPT_CACHE.value, + HttpBridgeSessionRecord.session_key_value == StickySession.key, + HttpBridgeSessionRecord.state.in_((HttpBridgeSessionState.ACTIVE, HttpBridgeSessionState.DRAINING)), + ) + .exists() + ) + stmt = delete(StickySession).where( + StickySession.updated_at < to_utc_naive(cutoff), + StickySession.kind == StickySessionKind.PROMPT_CACHE, + ~active_bridge_mapping, + ) + if is_subagent is not None: + stmt = stmt.where(StickySession.is_subagent == is_subagent) + async with sqlite_writer_section(): + result = await self._session.execute(stmt.returning(StickySession.key)) + deleted = len(result.scalars().all()) + await self._session.commit() + return deleted + + async def purge_before( + self, + cutoff: datetime, + *, + kind: StickySessionKind | None = None, + is_subagent: bool | None = None, + ) -> int: stmt = delete(StickySession).where(StickySession.updated_at < to_utc_naive(cutoff)) if kind is not None: stmt = stmt.where(StickySession.kind == kind) + if is_subagent is not None: + stmt = stmt.where(StickySession.is_subagent == is_subagent) async with sqlite_writer_section(): result = await self._session.execute(stmt.returning(StickySession.key)) deleted = len(result.scalars().all()) await self._session.commit() return deleted - def _build_upsert_statement(self, key: str, account_id: str, kind: StickySessionKind) -> Insert: + def _build_upsert_statement( + self, + key: str, + account_id: str, + kind: StickySessionKind, + *, + is_subagent: bool, + ) -> Insert: dialect = self._session.get_bind().dialect.name if dialect == "postgresql": insert_fn = pg_insert @@ -211,11 +288,17 @@ def _build_upsert_statement(self, key: str, account_id: str, kind: StickySession insert_fn = sqlite_insert else: raise RuntimeError(f"StickySession upsert unsupported for dialect={dialect!r}") - statement = insert_fn(StickySession).values(key=key, account_id=account_id, kind=kind) + statement = insert_fn(StickySession).values( + key=key, + account_id=account_id, + kind=kind, + is_subagent=is_subagent, + ) return statement.on_conflict_do_update( index_elements=[StickySession.key, StickySession.kind], set_={ "account_id": account_id, + "is_subagent": is_subagent, "updated_at": func.now(), }, ) @@ -226,6 +309,7 @@ def _apply_filters( *, kind: StickySessionKind | None, updated_before: datetime | None, + is_subagent: bool | None, account_query: str | None, key_query: str | None, ): @@ -233,6 +317,8 @@ def _apply_filters( statement = statement.where(StickySession.kind == kind) if updated_before is not None: statement = statement.where(StickySession.updated_at < to_utc_naive(updated_before)) + if is_subagent is not None: + statement = statement.where(StickySession.is_subagent == is_subagent) if account_query: statement = statement.where(func.lower(Account.email).contains(account_query.lower())) if key_query: diff --git a/app/modules/settings/api.py b/app/modules/settings/api.py index b217b45c87..8ecb9ecb96 100644 --- a/app/modules/settings/api.py +++ b/app/modules/settings/api.py @@ -143,6 +143,7 @@ def _dashboard_settings_response(settings) -> DashboardSettingsResponse: openai_cache_affinity_max_age_seconds=settings.openai_cache_affinity_max_age_seconds, dashboard_session_ttl_seconds=settings.dashboard_session_ttl_seconds, http_responses_session_bridge_prompt_cache_idle_ttl_seconds=settings.http_responses_session_bridge_prompt_cache_idle_ttl_seconds, + http_responses_session_bridge_subagent_prompt_cache_ttl_seconds=settings.http_responses_session_bridge_subagent_prompt_cache_ttl_seconds, http_responses_session_bridge_gateway_safe_mode=settings.http_responses_session_bridge_gateway_safe_mode, sticky_reallocation_budget_threshold_pct=settings.sticky_reallocation_budget_threshold_pct, sticky_reallocation_primary_budget_threshold_pct=settings.sticky_reallocation_primary_budget_threshold_pct, @@ -700,6 +701,11 @@ async def update_settings( if payload.http_responses_session_bridge_prompt_cache_idle_ttl_seconds is not None else current.http_responses_session_bridge_prompt_cache_idle_ttl_seconds ), + http_responses_session_bridge_subagent_prompt_cache_ttl_seconds=( + payload.http_responses_session_bridge_subagent_prompt_cache_ttl_seconds + if "http_responses_session_bridge_subagent_prompt_cache_ttl_seconds" in payload.model_fields_set + else current.http_responses_session_bridge_subagent_prompt_cache_ttl_seconds + ), http_responses_session_bridge_gateway_safe_mode=( payload.http_responses_session_bridge_gateway_safe_mode if payload.http_responses_session_bridge_gateway_safe_mode is not None @@ -840,6 +846,7 @@ async def update_settings( "openai_cache_affinity_max_age_seconds", "dashboard_session_ttl_seconds", "http_responses_session_bridge_prompt_cache_idle_ttl_seconds", + "http_responses_session_bridge_subagent_prompt_cache_ttl_seconds", "http_responses_session_bridge_gateway_safe_mode", "sticky_reallocation_budget_threshold_pct", "sticky_reallocation_primary_budget_threshold_pct", diff --git a/app/modules/settings/repository.py b/app/modules/settings/repository.py index d2dc08786c..6a944e78d8 100644 --- a/app/modules/settings/repository.py +++ b/app/modules/settings/repository.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import cast + from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm.attributes import flag_modified @@ -11,6 +13,7 @@ from app.db.models import DashboardSettings _SETTINGS_ID = 1 +_UNSET = object() class SettingsRepository: @@ -63,6 +66,7 @@ async def get_or_create(self) -> DashboardSettings: limit_warmup_windows="both", limit_warmup_model="auto", limit_warmup_prompt="Say OK.", + http_responses_session_bridge_subagent_prompt_cache_ttl_seconds=None, limit_warmup_cooldown_seconds=3600, limit_warmup_exhausted_threshold_percent=99.0, limit_warmup_idle_threshold_percent=1.0, @@ -109,6 +113,7 @@ async def update( openai_cache_affinity_max_age_seconds: int | None = None, dashboard_session_ttl_seconds: int | None = None, http_responses_session_bridge_prompt_cache_idle_ttl_seconds: int | None = None, + http_responses_session_bridge_subagent_prompt_cache_ttl_seconds: int | None | object = _UNSET, http_responses_session_bridge_gateway_safe_mode: bool | None = None, sticky_reallocation_budget_threshold_pct: float | None = None, sticky_reallocation_primary_budget_threshold_pct: float | None = None, @@ -189,6 +194,10 @@ async def update( settings.http_responses_session_bridge_prompt_cache_idle_ttl_seconds = ( http_responses_session_bridge_prompt_cache_idle_ttl_seconds ) + if http_responses_session_bridge_subagent_prompt_cache_ttl_seconds is not _UNSET: + settings.http_responses_session_bridge_subagent_prompt_cache_ttl_seconds = cast( + int | None, http_responses_session_bridge_subagent_prompt_cache_ttl_seconds + ) if http_responses_session_bridge_gateway_safe_mode is not None: settings.http_responses_session_bridge_gateway_safe_mode = http_responses_session_bridge_gateway_safe_mode if sticky_reallocation_budget_threshold_pct is not None: diff --git a/app/modules/settings/schemas.py b/app/modules/settings/schemas.py index 52ed53fbf1..a776d0eefb 100644 --- a/app/modules/settings/schemas.py +++ b/app/modules/settings/schemas.py @@ -55,6 +55,7 @@ class DashboardSettingsResponse(DashboardModel): openai_cache_affinity_max_age_seconds: int = Field(gt=0) dashboard_session_ttl_seconds: int = Field(ge=3600) http_responses_session_bridge_prompt_cache_idle_ttl_seconds: int = Field(gt=0) + http_responses_session_bridge_subagent_prompt_cache_ttl_seconds: int | None = Field(default=None, ge=0) http_responses_session_bridge_gateway_safe_mode: bool sticky_reallocation_budget_threshold_pct: float = Field(ge=0.0, le=100.0) sticky_reallocation_primary_budget_threshold_pct: float = Field(ge=0.0, le=100.0) @@ -119,6 +120,7 @@ class DashboardSettingsUpdateRequest(DashboardModel): openai_cache_affinity_max_age_seconds: int | None = Field(default=None, gt=0) dashboard_session_ttl_seconds: int | None = Field(default=None, ge=3600) http_responses_session_bridge_prompt_cache_idle_ttl_seconds: int | None = Field(default=None, gt=0) + http_responses_session_bridge_subagent_prompt_cache_ttl_seconds: int | None = Field(default=None, ge=0) http_responses_session_bridge_gateway_safe_mode: bool | None = None sticky_reallocation_budget_threshold_pct: float | None = Field(default=None, ge=0.0, le=100.0) sticky_reallocation_primary_budget_threshold_pct: float | None = Field(default=None, ge=0.0, le=100.0) diff --git a/app/modules/settings/service.py b/app/modules/settings/service.py index cb75820462..4b33bb9a4c 100644 --- a/app/modules/settings/service.py +++ b/app/modules/settings/service.py @@ -34,6 +34,7 @@ class DashboardSettingsData: openai_cache_affinity_max_age_seconds: int dashboard_session_ttl_seconds: int http_responses_session_bridge_prompt_cache_idle_ttl_seconds: int + http_responses_session_bridge_subagent_prompt_cache_ttl_seconds: int | None http_responses_session_bridge_gateway_safe_mode: bool sticky_reallocation_budget_threshold_pct: float sticky_reallocation_primary_budget_threshold_pct: float @@ -88,6 +89,7 @@ class DashboardSettingsUpdateData: openai_cache_affinity_max_age_seconds: int dashboard_session_ttl_seconds: int http_responses_session_bridge_prompt_cache_idle_ttl_seconds: int + http_responses_session_bridge_subagent_prompt_cache_ttl_seconds: int | None http_responses_session_bridge_gateway_safe_mode: bool sticky_reallocation_budget_threshold_pct: float sticky_reallocation_primary_budget_threshold_pct: float @@ -152,6 +154,7 @@ async def get_settings(self) -> DashboardSettingsData: http_responses_session_bridge_prompt_cache_idle_ttl_seconds=( row.http_responses_session_bridge_prompt_cache_idle_ttl_seconds ), + http_responses_session_bridge_subagent_prompt_cache_ttl_seconds=row.http_responses_session_bridge_subagent_prompt_cache_ttl_seconds, http_responses_session_bridge_gateway_safe_mode=row.http_responses_session_bridge_gateway_safe_mode, sticky_reallocation_budget_threshold_pct=row.sticky_reallocation_budget_threshold_pct, sticky_reallocation_primary_budget_threshold_pct=row.sticky_reallocation_primary_budget_threshold_pct, @@ -219,6 +222,7 @@ async def update_settings( http_responses_session_bridge_prompt_cache_idle_ttl_seconds=( payload.http_responses_session_bridge_prompt_cache_idle_ttl_seconds ), + http_responses_session_bridge_subagent_prompt_cache_ttl_seconds=payload.http_responses_session_bridge_subagent_prompt_cache_ttl_seconds, http_responses_session_bridge_gateway_safe_mode=payload.http_responses_session_bridge_gateway_safe_mode, sticky_reallocation_budget_threshold_pct=payload.sticky_reallocation_budget_threshold_pct, sticky_reallocation_primary_budget_threshold_pct=payload.sticky_reallocation_primary_budget_threshold_pct, @@ -276,6 +280,7 @@ async def update_settings( http_responses_session_bridge_prompt_cache_idle_ttl_seconds=( row.http_responses_session_bridge_prompt_cache_idle_ttl_seconds ), + http_responses_session_bridge_subagent_prompt_cache_ttl_seconds=row.http_responses_session_bridge_subagent_prompt_cache_ttl_seconds, http_responses_session_bridge_gateway_safe_mode=row.http_responses_session_bridge_gateway_safe_mode, sticky_reallocation_budget_threshold_pct=row.sticky_reallocation_budget_threshold_pct, sticky_reallocation_primary_budget_threshold_pct=row.sticky_reallocation_primary_budget_threshold_pct, diff --git a/app/modules/sticky_sessions/api.py b/app/modules/sticky_sessions/api.py index de1ade92f6..cccac4b419 100644 --- a/app/modules/sticky_sessions/api.py +++ b/app/modules/sticky_sessions/api.py @@ -65,6 +65,7 @@ async def list_sticky_sessions( updated_at=entry.updated_at, expires_at=entry.expires_at, is_stale=entry.is_stale, + is_subagent=entry.is_subagent, ) for entry in result.entries ], diff --git a/app/modules/sticky_sessions/cleanup_scheduler.py b/app/modules/sticky_sessions/cleanup_scheduler.py index e9a795df46..bf16df11b7 100644 --- a/app/modules/sticky_sessions/cleanup_scheduler.py +++ b/app/modules/sticky_sessions/cleanup_scheduler.py @@ -20,6 +20,7 @@ from app.modules.settings.repository import SettingsRepository logger = logging.getLogger(__name__) +_MAX_STICKY_CLEANUP_INTERVAL_SECONDS = 30 # Cleanup poll cadence (fixed; issue #1340 / PRINCIPLES.md P2). The scheduler # keeps ``interval_seconds`` as a constructor field so tests can exercise the @@ -89,7 +90,10 @@ async def _run_loop(self) -> None: while not self._stop.is_set(): await self._cleanup_once() try: - await asyncio.wait_for(self._stop.wait(), timeout=self.interval_seconds) + await asyncio.wait_for( + self._stop.wait(), + timeout=min(self.interval_seconds, _MAX_STICKY_CLEANUP_INTERVAL_SECONDS), + ) except asyncio.TimeoutError: continue @@ -106,7 +110,20 @@ async def _cleanup_as_leader(self) -> None: settings = await settings_repo.get_or_create() cutoff = utcnow() - timedelta(seconds=settings.openai_cache_affinity_max_age_seconds) - deleted_count = await sticky_repo.purge_prompt_cache_before(cutoff) + deleted_count = await sticky_repo.purge_prompt_cache_before(cutoff, is_subagent=False) + subagent_ttl = getattr( + settings, + "http_responses_session_bridge_subagent_prompt_cache_ttl_seconds", + None, + ) + subagent_cutoff = ( + utcnow() - timedelta(seconds=subagent_ttl) if subagent_ttl is not None else utcnow() + ) + deleted_count += await sticky_repo.purge_prompt_cache_before( + subagent_cutoff, + is_subagent=True, + protect_active_bridge_mappings=True, + ) if deleted_count > 0: logger.info("Purged stale prompt-cache sticky sessions deleted_count=%s", deleted_count) if startup_module._bridge_durable_schema_ready or not await missing_durable_bridge_tables(session): diff --git a/app/modules/sticky_sessions/schemas.py b/app/modules/sticky_sessions/schemas.py index ba18a0af47..e509fbc8dd 100644 --- a/app/modules/sticky_sessions/schemas.py +++ b/app/modules/sticky_sessions/schemas.py @@ -20,6 +20,7 @@ class StickySessionEntryResponse(DashboardModel): updated_at: datetime expires_at: datetime | None = None is_stale: bool + is_subagent: bool class StickySessionsListResponse(DashboardModel): diff --git a/app/modules/sticky_sessions/service.py b/app/modules/sticky_sessions/service.py index 0c167e3117..77f5131eee 100644 --- a/app/modules/sticky_sessions/service.py +++ b/app/modules/sticky_sessions/service.py @@ -20,6 +20,7 @@ class StickySessionEntryData: updated_at: datetime expires_at: datetime | None is_stale: bool + is_subagent: bool @dataclass(frozen=True, slots=True) @@ -70,10 +71,18 @@ async def list_entries( ) -> StickySessionListData: settings = await self._settings_repository.get_or_create() ttl_seconds = settings.openai_cache_affinity_max_age_seconds + subagent_ttl_seconds = settings.http_responses_session_bridge_subagent_prompt_cache_ttl_seconds stale_cutoff = utcnow() - timedelta(seconds=ttl_seconds) + subagent_stale_cutoff = self._subagent_stale_cutoff(subagent_ttl_seconds) normalized_account_query = account_query.strip() if account_query else None normalized_key_query = key_query.strip() if key_query else None - stale_prompt_cache_count = await self._count_stale_prompt_cache_entries(kind=kind, stale_cutoff=stale_cutoff) + stale_prompt_cache_count = await self._count_stale_prompt_cache_entries( + kind=kind, + stale_cutoff=stale_cutoff, + subagent_stale_cutoff=subagent_stale_cutoff, + account_query=normalized_account_query, + key_query=normalized_key_query, + ) if stale_only and kind not in (None, StickySessionKind.PROMPT_CACHE): return StickySessionListData( entries=[], @@ -81,16 +90,35 @@ async def list_entries( total=0, has_more=False, ) - effective_kind = StickySessionKind.PROMPT_CACHE if stale_only else kind + if stale_only: + rows, total = await self._list_stale_prompt_cache_rows( + stale_cutoff=stale_cutoff, + subagent_stale_cutoff=subagent_stale_cutoff, + account_query=normalized_account_query, + key_query=normalized_key_query, + sort_by=sort_by, + sort_dir=sort_dir, + offset=offset, + limit=limit, + ) + entries = [ + self._to_entry(row, ttl_seconds=ttl_seconds, subagent_ttl_seconds=subagent_ttl_seconds) for row in rows + ] + return StickySessionListData( + entries=entries, + stale_prompt_cache_count=stale_prompt_cache_count, + total=total, + has_more=offset + len(entries) < total, + ) total = await self._repository.count_entries( - kind=effective_kind, - updated_before=stale_cutoff if stale_only else None, + kind=kind, + updated_before=None, account_query=normalized_account_query, key_query=normalized_key_query, ) rows = await self._repository.list_entries( - kind=effective_kind, - updated_before=stale_cutoff if stale_only else None, + kind=kind, + updated_before=None, account_query=normalized_account_query, key_query=normalized_key_query, sort_by=sort_by, @@ -98,7 +126,9 @@ async def list_entries( offset=offset, limit=limit, ) - entries = [self._to_entry(row, ttl_seconds=ttl_seconds) for row in rows] + entries = [ + self._to_entry(row, ttl_seconds=ttl_seconds, subagent_ttl_seconds=subagent_ttl_seconds) for row in rows + ] return StickySessionListData( entries=entries, stale_prompt_cache_count=stale_prompt_cache_count, @@ -142,31 +172,56 @@ async def delete_filtered_entries( ) -> int: settings = await self._settings_repository.get_or_create() stale_cutoff = utcnow() - timedelta(seconds=settings.openai_cache_affinity_max_age_seconds) + subagent_stale_cutoff = self._subagent_stale_cutoff( + settings.http_responses_session_bridge_subagent_prompt_cache_ttl_seconds + ) if stale_only and kind not in (None, StickySessionKind.PROMPT_CACHE): return 0 - effective_kind = StickySessionKind.PROMPT_CACHE if stale_only else kind normalized_account_query = account_query.strip() if account_query else None normalized_key_query = key_query.strip() if key_query else None - targets = await self._repository.list_entry_identifiers( - kind=effective_kind, - updated_before=stale_cutoff if stale_only else None, - account_query=normalized_account_query, - key_query=normalized_key_query, - ) + if stale_only: + targets = await self._stale_prompt_cache_identifiers( + stale_cutoff=stale_cutoff, + subagent_stale_cutoff=subagent_stale_cutoff, + account_query=normalized_account_query, + key_query=normalized_key_query, + ) + else: + targets = await self._repository.list_entry_identifiers( + kind=kind, + updated_before=None, + account_query=normalized_account_query, + key_query=normalized_key_query, + ) deleted = await self._repository.delete_entries(targets) return len(deleted) async def purge_entries(self) -> int: settings = await self._settings_repository.get_or_create() cutoff = utcnow() - timedelta(seconds=settings.openai_cache_affinity_max_age_seconds) - return await self._repository.purge_prompt_cache_before(cutoff) + subagent_cutoff = self._subagent_stale_cutoff( + settings.http_responses_session_bridge_subagent_prompt_cache_ttl_seconds + ) + parent_deleted = await self._repository.purge_prompt_cache_before(cutoff, is_subagent=False) + subagent_deleted = await self._repository.purge_prompt_cache_before(subagent_cutoff, is_subagent=True) + return parent_deleted + subagent_deleted - def _to_entry(self, row: StickySessionListEntryRecord, *, ttl_seconds: int) -> StickySessionEntryData: + def _to_entry( + self, + row: StickySessionListEntryRecord, + *, + ttl_seconds: int, + subagent_ttl_seconds: int | None, + ) -> StickySessionEntryData: sticky_session = row.sticky_session expires_at: datetime | None = None is_stale = False if sticky_session.kind == StickySessionKind.PROMPT_CACHE: - expires_at = to_utc_naive(sticky_session.updated_at) + timedelta(seconds=ttl_seconds) + effective_ttl_seconds = subagent_ttl_seconds if sticky_session.is_subagent else ttl_seconds + if effective_ttl_seconds is None: + expires_at = to_utc_naive(sticky_session.updated_at) + else: + expires_at = to_utc_naive(sticky_session.updated_at) + timedelta(seconds=effective_ttl_seconds) is_stale = expires_at <= utcnow() return StickySessionEntryData( key=sticky_session.key, @@ -176,6 +231,7 @@ def _to_entry(self, row: StickySessionListEntryRecord, *, ttl_seconds: int) -> S updated_at=sticky_session.updated_at, expires_at=expires_at, is_stale=is_stale, + is_subagent=sticky_session.is_subagent, ) async def _count_stale_prompt_cache_entries( @@ -183,10 +239,110 @@ async def _count_stale_prompt_cache_entries( *, kind: StickySessionKind | None, stale_cutoff: datetime, + subagent_stale_cutoff: datetime, + account_query: str | None, + key_query: str | None, ) -> int: if kind not in (None, StickySessionKind.PROMPT_CACHE): return 0 - return await self._repository.count_entries( + parent_count = await self._repository.count_entries( + kind=StickySessionKind.PROMPT_CACHE, + updated_before=stale_cutoff, + is_subagent=False, + account_query=account_query, + key_query=key_query, + ) + subagent_count = await self._repository.count_entries( + kind=StickySessionKind.PROMPT_CACHE, + updated_before=subagent_stale_cutoff, + is_subagent=True, + account_query=account_query, + key_query=key_query, + ) + return parent_count + subagent_count + + @staticmethod + def _subagent_stale_cutoff(subagent_ttl_seconds: int | None) -> datetime: + if subagent_ttl_seconds is None: + return utcnow() + return utcnow() - timedelta(seconds=subagent_ttl_seconds) + + async def _list_stale_prompt_cache_rows( + self, + *, + stale_cutoff: datetime, + subagent_stale_cutoff: datetime, + account_query: str | None, + key_query: str | None, + sort_by: StickySessionSortBy, + sort_dir: StickySessionSortDir, + offset: int, + limit: int, + ) -> tuple[list[StickySessionListEntryRecord], int]: + fetch_limit = offset + limit + parent_rows = await self._repository.list_entries( + kind=StickySessionKind.PROMPT_CACHE, + updated_before=stale_cutoff, + is_subagent=False, + account_query=account_query, + key_query=key_query, + sort_by=sort_by, + sort_dir=sort_dir, + offset=0, + limit=fetch_limit, + ) + subagent_rows = await self._repository.list_entries( + kind=StickySessionKind.PROMPT_CACHE, + updated_before=subagent_stale_cutoff, + is_subagent=True, + account_query=account_query, + key_query=key_query, + sort_by=sort_by, + sort_dir=sort_dir, + offset=0, + limit=fetch_limit, + ) + rows = list(parent_rows) + list(subagent_rows) + rows.sort(key=self._stale_sort_key(sort_by), reverse=sort_dir == "desc") + total = await self._count_stale_prompt_cache_entries( + kind=StickySessionKind.PROMPT_CACHE, + stale_cutoff=stale_cutoff, + subagent_stale_cutoff=subagent_stale_cutoff, + account_query=account_query, + key_query=key_query, + ) + return rows[offset : offset + limit], total + + async def _stale_prompt_cache_identifiers( + self, + *, + stale_cutoff: datetime, + subagent_stale_cutoff: datetime, + account_query: str | None, + key_query: str | None, + ) -> list[tuple[str, StickySessionKind]]: + parent_targets = await self._repository.list_entry_identifiers( kind=StickySessionKind.PROMPT_CACHE, updated_before=stale_cutoff, + is_subagent=False, + account_query=account_query, + key_query=key_query, ) + subagent_targets = await self._repository.list_entry_identifiers( + kind=StickySessionKind.PROMPT_CACHE, + updated_before=subagent_stale_cutoff, + is_subagent=True, + account_query=account_query, + key_query=key_query, + ) + return parent_targets + subagent_targets + + @staticmethod + def _stale_sort_key(sort_by: StickySessionSortBy): + if sort_by == "created_at": + return lambda row: (row.sticky_session.created_at, row.sticky_session.updated_at, row.sticky_session.key) + if sort_by == "account": + return lambda row: (row.display_name.lower(), row.sticky_session.updated_at, row.sticky_session.key) + if sort_by == "key": + return lambda row: (row.sticky_session.key, row.sticky_session.updated_at, row.sticky_session.created_at) + return lambda row: (row.sticky_session.updated_at, row.sticky_session.created_at, row.sticky_session.key) diff --git a/frontend/src/features/settings/components/routing-settings.tsx b/frontend/src/features/settings/components/routing-settings.tsx index d10a194262..c243361910 100644 --- a/frontend/src/features/settings/components/routing-settings.tsx +++ b/frontend/src/features/settings/components/routing-settings.tsx @@ -103,6 +103,7 @@ type RoutingSettingsDraft = { limitWarmupIdleThreshold: string; additionalQuotaKey: string; additionalQuotaPolicy: AdditionalQuotaRoutingPolicy; + subagentPromptCacheTtl: string; }; function createRoutingSettingsDraft(settings: DashboardSettings): RoutingSettingsDraft { @@ -123,6 +124,10 @@ function createRoutingSettingsDraft(settings: DashboardSettings): RoutingSetting limitWarmupIdleThreshold: String(settings.limitWarmupIdleThresholdPercent), additionalQuotaKey: "", additionalQuotaPolicy: "inherit", + subagentPromptCacheTtl: + settings.httpResponsesSessionBridgeSubagentPromptCacheTtlSeconds == null + ? "" + : String(settings.httpResponsesSessionBridgeSubagentPromptCacheTtlSeconds), }; } @@ -183,6 +188,14 @@ export function RoutingSettings({ const cacheAffinityTtlValid = Number.isInteger(parsedCacheAffinityTtl) && parsedCacheAffinityTtl > 0; const cacheAffinityTtlChanged = cacheAffinityTtlValid && parsedCacheAffinityTtl !== settings.openaiCacheAffinityMaxAgeSeconds; + const parsedSubagentPromptCacheTtl = + draft.subagentPromptCacheTtl.trim() === "" ? null : Number.parseInt(draft.subagentPromptCacheTtl, 10); + const subagentPromptCacheTtlValid = + parsedSubagentPromptCacheTtl === null || + (Number.isInteger(parsedSubagentPromptCacheTtl) && parsedSubagentPromptCacheTtl >= 0); + const subagentPromptCacheTtlChanged = + subagentPromptCacheTtlValid && + parsedSubagentPromptCacheTtl !== settings.httpResponsesSessionBridgeSubagentPromptCacheTtlSeconds; const parsedProxyAccountResponseCreateLimit = parseNonnegativeInteger(draft.proxyAccountResponseCreateLimit); const parsedProxyAccountStreamLimit = parseNonnegativeInteger(draft.proxyAccountStreamLimit); const parsedProxyAccountStreamRecoveryReserve = parseNonnegativeInteger( @@ -1150,6 +1163,50 @@ export function RoutingSettings({ +
+
+

{t("settings.routing.subagentPromptCache.label")}

+

+ {t("settings.routing.subagentPromptCache.description")} +

+
+
+ updateDraft({ subagentPromptCacheTtl: event.target.value })} + onKeyDown={(event) => { + if (event.key === "Enter" && subagentPromptCacheTtlChanged) { + void save({ + httpResponsesSessionBridgeSubagentPromptCacheTtlSeconds: parsedSubagentPromptCacheTtl, + }); + } + }} + className="h-8 w-28 text-xs" + /> + +
+
+ diff --git a/frontend/src/features/settings/payload.ts b/frontend/src/features/settings/payload.ts index d6fd2da792..a843db31b3 100644 --- a/frontend/src/features/settings/payload.ts +++ b/frontend/src/features/settings/payload.ts @@ -45,6 +45,8 @@ export function buildSettingsUpdateRequest( weeklyPaceSmoothingMinutes: settings.weeklyPaceSmoothingMinutes, guestAccessEnabled: settings.guestAccessEnabled, hideUpstreamQuotaFromApiKeys: settings.hideUpstreamQuotaFromApiKeys, + httpResponsesSessionBridgeSubagentPromptCacheTtlSeconds: + settings.httpResponsesSessionBridgeSubagentPromptCacheTtlSeconds, ...patch, }; if (payload.expectedVersion === undefined) { diff --git a/frontend/src/features/settings/schemas.ts b/frontend/src/features/settings/schemas.ts index cdd65fb92e..ea1e46a783 100644 --- a/frontend/src/features/settings/schemas.ts +++ b/frontend/src/features/settings/schemas.ts @@ -136,6 +136,13 @@ export const DashboardSettingsSchema = z usageHistoryRetentionDays: z.number().int().min(0).max(3650).optional().default(0), requestLogRetentionOverrideDays: z.number().int().min(0).max(3650).nullable().optional().default(null), usageHistoryRetentionOverrideDays: z.number().int().min(0).max(3650).nullable().optional().default(null), + httpResponsesSessionBridgeSubagentPromptCacheTtlSeconds: z + .number() + .int() + .nonnegative() + .nullable() + .optional() + .default(null), version: z.number().int().min(1).optional(), }) .transform((settings) => { @@ -211,6 +218,7 @@ export const SettingsUpdateRequestSchema = z // alias), value = store the override. requestLogRetentionOverrideDays: z.number().int().min(0).max(3650).nullable().optional(), usageHistoryRetentionOverrideDays: z.number().int().min(0).max(3650).nullable().optional(), + httpResponsesSessionBridgeSubagentPromptCacheTtlSeconds: z.number().int().nonnegative().nullable().optional(), }) .superRefine((settings, ctx) => { if ( diff --git a/frontend/src/features/sticky-sessions/components/sticky-sessions-section.tsx b/frontend/src/features/sticky-sessions/components/sticky-sessions-section.tsx index 3b153385f4..819cc5b528 100644 --- a/frontend/src/features/sticky-sessions/components/sticky-sessions-section.tsx +++ b/frontend/src/features/sticky-sessions/components/sticky-sessions-section.tsx @@ -31,14 +31,14 @@ import { useDialogState } from "@/hooks/use-dialog-state"; import { getErrorMessageOrNull } from "@/utils/errors"; import { formatTimeLong } from "@/utils/formatters"; -function kindLabel(kind: StickySessionKind, t: ReturnType["t"]): string { +function kindLabel(kind: StickySessionKind, t: ReturnType["t"], isSubagent = false): string { switch (kind) { case "codex_session": return t("stickySessions.kinds.codexSession"); case "sticky_thread": return t("stickySessions.kinds.stickyThread"); case "prompt_cache": - return t("stickySessions.kinds.promptCache"); + return isSubagent ? t("stickySessions.kinds.subagentPromptCache") : t("stickySessions.kinds.promptCache"); } } @@ -313,7 +313,7 @@ export function StickySessionsSection({ disabled = false }: StickySessionsSectio {entry.key} - {kindLabel(entry.kind, t)} + {kindLabel(entry.kind, t, entry.isSubagent)} {entry.displayName} diff --git a/frontend/src/features/sticky-sessions/hooks/use-sticky-sessions.test.ts b/frontend/src/features/sticky-sessions/hooks/use-sticky-sessions.test.ts index ebcde132dd..20cab9e2c3 100644 --- a/frontend/src/features/sticky-sessions/hooks/use-sticky-sessions.test.ts +++ b/frontend/src/features/sticky-sessions/hooks/use-sticky-sessions.test.ts @@ -34,9 +34,10 @@ describe("useStickySessions", () => { displayName: "sticky-a@example.com", kind: "prompt_cache", createdAt: "2026-03-10T12:00:00Z", - updatedAt: "2026-03-10T12:05:00Z", - expiresAt: "2026-03-10T12:10:00Z", - isStale: false, + updatedAt: "2026-03-10T12:05:00Z", + expiresAt: "2026-03-10T12:10:00Z", + isStale: false, + isSubagent: false, }, ]; const queryClient = createTestQueryClient(); diff --git a/frontend/src/features/sticky-sessions/schemas.test.ts b/frontend/src/features/sticky-sessions/schemas.test.ts index 17d8730fbd..ead38cd87d 100644 --- a/frontend/src/features/sticky-sessions/schemas.test.ts +++ b/frontend/src/features/sticky-sessions/schemas.test.ts @@ -18,6 +18,7 @@ describe("StickySessionEntrySchema", () => { updatedAt: "2026-03-10T12:05:00Z", expiresAt: "2026-03-10T12:10:00Z", isStale: false, + isSubagent: false, }); expect(parsed.kind).toBe("prompt_cache"); diff --git a/frontend/src/features/sticky-sessions/schemas.ts b/frontend/src/features/sticky-sessions/schemas.ts index d7fc1c61dc..63d1630036 100644 --- a/frontend/src/features/sticky-sessions/schemas.ts +++ b/frontend/src/features/sticky-sessions/schemas.ts @@ -16,6 +16,7 @@ export const StickySessionEntrySchema = z.object({ updatedAt: z.iso.datetime({ offset: true }), expiresAt: z.iso.datetime({ offset: true }).nullable(), isStale: z.boolean(), + isSubagent: z.boolean(), }); export const StickySessionIdentifierSchema = z.object({ diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index e55aefb852..c865819253 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1010,6 +1010,10 @@ "settings.routing.stickyThreads.label": "Sticky threads", "settings.routing.stickyThreads.description": "Keep related requests on the same account.", "settings.routing.stickyThreads.ariaLabel": "Enable sticky threads", + "settings.routing.subagentPromptCache.label": "Subagent prompt-cache affinity TTL", + "settings.routing.subagentPromptCache.description": "Empty or 0 closes subagent sessions immediately; set seconds such as 30 to retain the bridge session and stream lease for that duration.", + "settings.routing.subagentPromptCache.placeholder": "No cache", + "settings.routing.subagentPromptCache.save": "Save subagent TTL", "settings.routing.preferEarlier.label": "Prefer earlier reset", "settings.routing.preferEarlier.description": "Bias traffic to accounts with earlier quota reset.", "settings.routing.preferEarlier.ariaLabel": "Prefer earlier reset accounts", @@ -1230,6 +1234,7 @@ "stickySessions.filters.keyPlaceholder": "Filter by key...", "stickySessions.kinds.codexSession": "Codex session", "stickySessions.kinds.promptCache": "Prompt cache", + "stickySessions.kinds.subagentPromptCache": "Prompt cache, Subagent", "stickySessions.kinds.stickyThread": "Sticky thread", "stickySessions.purgeDialog.description": "Only expired prompt-cache entries will be deleted. Durable session and sticky-thread mappings stay intact.", "stickySessions.purgeDialog.title": "Purge stale prompt-cache mappings", diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json index 5e7c4d1dd5..f88de52bc2 100644 --- a/frontend/src/i18n/locales/ko.json +++ b/frontend/src/i18n/locales/ko.json @@ -1100,6 +1100,10 @@ "settings.routing.preferEarlier.description": "quota reset이 더 이른 Account에 traffic을 우선 배정합니다.", "settings.routing.preferEarlier.label": "이른 reset 선호", "settings.routing.preferEarlier.windowAria": "Reset preference window", + "settings.routing.subagentPromptCache.description": "비어 있거나 0이면 subagent session을 즉시 닫습니다. 30처럼 초 단위 값을 설정하면 해당 시간 동안 bridge session과 stream lease를 유지합니다.", + "settings.routing.subagentPromptCache.label": "Subagent prompt-cache affinity TTL", + "settings.routing.subagentPromptCache.placeholder": "캐시 없음", + "settings.routing.subagentPromptCache.save": "Subagent TTL 저장", "settings.routing.promptCacheTtl.description": "OpenAI 스타일 prompt-cache mapping을 제한된 초 동안 유지합니다.", "settings.routing.promptCacheTtl.label": "Prompt-cache affinity TTL", "settings.routing.promptCacheTtl.save": "TTL 저장", @@ -1230,6 +1234,7 @@ "stickySessions.filters.keyPlaceholder": "Key로 필터...", "stickySessions.kinds.codexSession": "Codex session", "stickySessions.kinds.promptCache": "Prompt cache", + "stickySessions.kinds.subagentPromptCache": "Prompt cache, Subagent", "stickySessions.kinds.stickyThread": "Sticky thread", "stickySessions.purgeDialog.description": "만료된 prompt-cache entry만 삭제합니다. Durable session과 sticky-thread mapping은 유지됩니다.", "stickySessions.purgeDialog.title": "stale prompt-cache mapping 정리", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 77561daa6a..14ab043c96 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1014,6 +1014,10 @@ "settings.routing.preferEarlier.description": "倾向于将流量分配给配额更早重置的账户。", "settings.routing.preferEarlier.ariaLabel": "优先选择重置较早的账户", "settings.routing.preferEarlier.windowAria": "重置偏好窗口", + "settings.routing.subagentPromptCache.label": "子代理 prompt-cache 亲和 TTL", + "settings.routing.subagentPromptCache.description": "留空或设为 0 会立即关闭子代理会话;设置秒数(如 30)可在该时长内保留桥接会话和流租约。", + "settings.routing.subagentPromptCache.placeholder": "不缓存", + "settings.routing.subagentPromptCache.save": "保存子代理 TTL", "settings.routing.promptCacheTtl.label": "Prompt 缓存亲和 TTL", "settings.routing.promptCacheTtl.description": "在指定秒数内保留 OpenAI 风格的 prompt 缓存映射。", "settings.routing.promptCacheTtl.save": "保存 TTL", @@ -1230,6 +1234,7 @@ "stickySessions.filters.keyPlaceholder": "按 key 筛选...", "stickySessions.kinds.codexSession": "Codex session", "stickySessions.kinds.promptCache": "Prompt cache", + "stickySessions.kinds.subagentPromptCache": "Prompt cache,子代理", "stickySessions.kinds.stickyThread": "Sticky thread", "stickySessions.purgeDialog.description": "只会删除已过期的 prompt-cache 条目。Durable session 和 sticky-thread 映射会保留。", "stickySessions.purgeDialog.title": "清理 stale prompt-cache 映射", diff --git a/openspec/changes/add-subagent-prompt-cache-ttl/.openspec.yaml b/openspec/changes/add-subagent-prompt-cache-ttl/.openspec.yaml new file mode 100644 index 0000000000..b119b63505 --- /dev/null +++ b/openspec/changes/add-subagent-prompt-cache-ttl/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-13 diff --git a/openspec/changes/add-subagent-prompt-cache-ttl/design.md b/openspec/changes/add-subagent-prompt-cache-ttl/design.md new file mode 100644 index 0000000000..ff095a2d1c --- /dev/null +++ b/openspec/changes/add-subagent-prompt-cache-ttl/design.md @@ -0,0 +1,27 @@ +## Context + +OpenCode sends subagent requests with `x-parent-session-id`. Tool calls are sent as ordinary conversation requests and are not a reliable lifecycle category for this feature. + +Subagents have independent bridge sessions and do not need parent-style prompt-cache affinity by default. Retaining a mapping is optional and must not retain the runtime bridge session or its account stream lease. + +## Goals + +- Detect true subagents from `x-parent-session-id`. +- Default subagents to No Cache. +- Allow operators to retain subagent PROMPT_CACHE mappings for a positive number of seconds. +- Close subagent bridge sessions immediately after the response stream ends. +- Preserve canonical parent-session behavior. + +## Decisions + +1. The dashboard field `http_responses_session_bridge_subagent_prompt_cache_ttl_seconds` is nullable. `NULL` or `0` means No Cache (immediate release); a positive value is the subagent bridge session, stream lease, and mapping retention TTL. +2. No-Cache subagents pass no sticky key to account selection, so the load balancer neither reads nor writes a PROMPT_CACHE mapping. +3. Enabled subagent affinity passes the subagent TTL as `sticky_max_age_seconds`; the bridge session and stream lease are retained for the TTL duration, then released via a delayed background close task. +4. The parent mapping is never deleted by subagent cleanup. A stale mapping for a No-Cache subagent may be deleted as a cleanup safeguard. +5. Sticky mappings persist an `is_subagent` marker so the reaper can apply the subagent TTL without deleting parent mappings. + +## Non-Goals + +- No active bridge-session API or dashboard table. +- No change to OpenCode tool-call concurrency. +- No change to canonical parent prompt-cache affinity. diff --git a/openspec/changes/add-subagent-prompt-cache-ttl/notes.md b/openspec/changes/add-subagent-prompt-cache-ttl/notes.md new file mode 100644 index 0000000000..6130071e66 --- /dev/null +++ b/openspec/changes/add-subagent-prompt-cache-ttl/notes.md @@ -0,0 +1,27 @@ +# Subagent Prompt-Cache TTL - Empirical Findings + +## Session Origin + +OpenCode sends `x-parent-session-id` on requests originating from subagents. Tool calls are sent as ordinary conversation requests and are not used as a lifecycle signal. + +## Fork Behaviour + +When multiple requests share one OpenCode session header but carry no explicit turn-state or `previous_response_id`, codex-lb may create an **unanchored parallel fork**. This feature does not classify those requests as subagents. + +## Timing Profile (measured on 2026-07-13) + +| Stage | Duration | +|---|---| +| Actual upstream work (single read) | <60s | +| Bridge session idle retention (fork) | 3600s (1h) | +| Stream lease stale reclaim (safety net) | ~7260s (2h) | + +Tool-call concurrency is outside this change because tool calls are represented as ordinary conversation requests. + +## Capacity Impact + +Subagent bridge sessions are closed at response-stream completion. An optional positive subagent mapping TTL affects only sticky mapping retention, not stream-lease lifetime. + +## Visibility Gap + +Active bridge-session visibility is outside this change. diff --git a/openspec/changes/add-subagent-prompt-cache-ttl/proposal.md b/openspec/changes/add-subagent-prompt-cache-ttl/proposal.md new file mode 100644 index 0000000000..a41797792a --- /dev/null +++ b/openspec/changes/add-subagent-prompt-cache-ttl/proposal.md @@ -0,0 +1,29 @@ +## Why + +OpenCode identifies short-lived child work with the `x-parent-session-id` request header, but codex-lb currently retains its prompt-cache mapping under the same long-lived settings used by parent sessions. The retained mapping can keep subagent affinity visible after the child has finished. + +## What Changes + +- Add a persisted dashboard setting, `http_responses_session_bridge_subagent_prompt_cache_ttl_seconds`, defaulting to `NULL`. +- Treat a request carrying `x-parent-session-id` as a subagent session. +- Use `NULL` as No Cache; use a positive setting to retain only the subagent PROMPT_CACHE mapping for that duration. +- Close the subagent HTTP bridge immediately after its response stream ends, independent of mapping retention. +- Preserve existing parent-session affinity and HTTP bridge TTL behavior. +- Add a Routing Settings control labelled `Subagent prompt-cache TTL` with validation and immediate settings API persistence. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `sticky-session-operations`: distinguish optional subagent prompt-cache affinity from parent prompt-cache affinity. +- `frontend-architecture`: expose the persisted subagent prompt-cache TTL in Routing Settings. + +## Impact + +- Dashboard settings persistence, migrations, settings API schemas, cache invalidation, and frontend settings types. +- HTTP Responses bridge affinity classification and sticky mapping persistence. +- No new client header or upstream protocol is introduced; the change consumes OpenCode's existing `x-parent-session-id` metadata header. diff --git a/openspec/changes/add-subagent-prompt-cache-ttl/specs/frontend-architecture/spec.md b/openspec/changes/add-subagent-prompt-cache-ttl/specs/frontend-architecture/spec.md new file mode 100644 index 0000000000..7b2123bd78 --- /dev/null +++ b/openspec/changes/add-subagent-prompt-cache-ttl/specs/frontend-architecture/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: Subagent prompt-cache TTL appears in Routing Settings + +The Routing Settings section SHALL include a persisted integer control for the subagent prompt-cache TTL. The control SHALL default to empty (No Cache). The control follows the same persistence pattern as the existing prompt-cache affinity TTL field. + +#### Scenario: Save subagent prompt-cache TTL + +- **WHEN** a user enters a non-negative integer value for the subagent prompt-cache TTL in the Routing Settings section +- **AND** clicks save +- **THEN** the app calls `PUT /api/settings` with the updated TTL +- **AND** the settings response reflects the saved value +- **AND** subsequent requests carrying `x-parent-session-id` use the new TTL + +#### Scenario: Empty or 0 means No Cache + +- **WHEN** a user leaves the subagent TTL field empty or enters 0 +- **AND** clicks save +- **THEN** the app calls `PUT /api/settings` with `null` +- **AND** subagent bridge sessions and stream leases are released immediately after stream end diff --git a/openspec/changes/add-subagent-prompt-cache-ttl/specs/sticky-session-operations/spec.md b/openspec/changes/add-subagent-prompt-cache-ttl/specs/sticky-session-operations/spec.md new file mode 100644 index 0000000000..fcff4d8c8e --- /dev/null +++ b/openspec/changes/add-subagent-prompt-cache-ttl/specs/sticky-session-operations/spec.md @@ -0,0 +1,90 @@ +## ADDED Requirements + +### Requirement: Subagent prompt-cache affinity is optional + +The system SHALL identify subagent requests by a nonblank `x-parent-session-id`, `x-openai-subagent`, or `x-codex-parent-thread-id` header. The dashboard setting `http_responses_session_bridge_subagent_prompt_cache_ttl_seconds` SHALL use `NULL` as the default No Cache mode. When set to a positive number, it SHALL retain the subagent's bridge session, stream lease, and PROMPT_CACHE mapping for that many seconds before closing. When set to `0` or `NULL`, the bridge session, stream lease, and any mapping MUST be released immediately when the subagent response stream ends. + +#### Scenario: Subagent uses No Cache by default + +- **GIVEN** an incoming request carries `x-parent-session-id` +- **AND** the subagent prompt-cache TTL setting is `NULL` or `0` +- **WHEN** the HTTP bridge selects an account +- **THEN** it MUST NOT read or write a PROMPT_CACHE sticky mapping for the subagent +- **AND** the bridge session and stream lease MUST be released when the response stream ends + +#### Scenario: Subagent retains bridge session for configured TTL + +- **GIVEN** an incoming request carries `x-parent-session-id` +- **AND** the subagent prompt-cache TTL setting is a positive number +- **WHEN** the HTTP bridge selects an account +- **THEN** the subagent's PROMPT_CACHE mapping MAY be read or written +- **AND** the bridge session and stream lease MUST be retained for the configured TTL duration +- **AND** the bridge session and stream lease MUST be released after the TTL expires + +#### Scenario: Canonical session retains standard PROMPT_CACHE behavior + +- **GIVEN** an incoming request does not carry `x-parent-session-id` +- **WHEN** the session is created +- **THEN** the session uses the standard affinity-based idle TTL (PROMPT_CACHE/CODEX_SESSION/base) +- **AND** the subagent setting does not affect the canonical session + +### Requirement: Completed subagent sessions release resources after TTL + +The system MUST release the stream lease and close the HTTP bridge session for a subagent after its configured TTL expires. When the TTL is `NULL` or `0`, the system MUST release immediately. The system MUST NOT delete the sticky mapping for the canonical parent session. + +#### Scenario: Subagent with positive TTL releases after delay + +- **GIVEN** an HTTP bridge session was marked as a subagent session from `x-parent-session-id` +- **AND** the subagent prompt-cache TTL is a positive number +- **WHEN** its response stream ends +- **THEN** the bridge session and stream lease MUST be retained for the TTL duration +- **AND** after the TTL expires, the session's stream lease MUST be released and the bridge session closed +- **AND** the parent session's sticky mapping remains available + +#### Scenario: Subagent with zero TTL releases immediately + +- **GIVEN** an HTTP bridge session was marked as a subagent session from `x-parent-session-id` +- **AND** the subagent prompt-cache TTL is `NULL` or `0` +- **WHEN** its response stream ends +- **THEN** the session's stream lease is released immediately +- **AND** the parent session's sticky mapping remains available + +### Requirement: Unanchored parallel forks release stream resources after completion + +The system MUST close an `internal_unanchored_parallel` HTTP bridge session when its response stream ends. The system MUST release its stream lease at the same time. The parent session's bridge session and sticky mapping MUST remain available. + +#### Scenario: Normal parent parallel fork does not retain a stream lease + +- **GIVEN** an HTTP bridge creates an `internal_unanchored_parallel` fork for a parent session request +- **WHEN** the fork's response stream ends +- **THEN** the fork bridge session MUST close immediately +- **AND** the fork's stream lease MUST be released +- **AND** the parent bridge session MUST remain available + +### Requirement: Sticky session entries expose subagent marker + +The sticky sessions API response SHALL include an `is_subagent` boolean field on each entry. The dashboard SHALL display "Prompt cache, Subagent" for prompt-cache entries where `is_subagent` is true. Non-subagent entries SHALL display "Prompt cache" unchanged. + +#### Scenario: Subagent entry shows subagent label + +- **GIVEN** a sticky session entry exists with `is_subagent=true` and `kind=prompt_cache` +- **WHEN** the dashboard renders the sticky sessions list +- **THEN** the entry SHALL be labelled "Prompt cache, Subagent" + +#### Scenario: Parent entry shows standard label + +- **GIVEN** a sticky session entry exists with `is_subagent=false` and `kind=prompt_cache` +- **WHEN** the dashboard renders the sticky sessions list +- **THEN** the entry SHALL be labelled "Prompt cache" + +### Requirement: Cleanup scheduler applies subagent TTL independently + +The background cleanup scheduler SHALL purge subagent prompt-cache mappings using the subagent TTL, independently from parent prompt-cache mappings. The scheduler interval SHALL be capped at 30 seconds so that subagent mappings with short TTLs are cleaned promptly. + +#### Scenario: Subagent mappings purged by subagent TTL + +- **GIVEN** subagent prompt-cache mappings exist with `is_subagent=true` +- **AND** the subagent prompt-cache TTL is 30 seconds +- **WHEN** the cleanup scheduler runs +- **THEN** subagent mappings older than 30 seconds SHALL be purged +- **AND** parent prompt-cache mappings SHALL NOT be purged by the subagent cutoff diff --git a/openspec/changes/add-subagent-prompt-cache-ttl/tasks.md b/openspec/changes/add-subagent-prompt-cache-ttl/tasks.md new file mode 100644 index 0000000000..339b80d7df --- /dev/null +++ b/openspec/changes/add-subagent-prompt-cache-ttl/tasks.md @@ -0,0 +1,42 @@ +## 1. Schema & Backend Persistence + +- [x] 1.1 Add `http_responses_session_bridge_subagent_prompt_cache_ttl_seconds` column to `dashboard_settings` table (nullable Integer; `NULL` means No Cache), with Alembic migration revision +- [x] 1.2 Add the field to `DashboardSettings` ORM model in `app/db/models.py` +- [x] 1.3 Add the field to `DashboardSettingsData` dataclass in `app/modules/settings/service.py` +- [x] 1.4 Add the field to `DashboardSettingsUpdateData` for persistence through the settings API +- [x] 1.5 Add the field to `DashboardSettingsResponse` and `DashboardSettingsUpdateRequest` Pydantic schemas in `app/modules/settings/schemas.py` + +## 2. Subagent Detection & Affinity + +- [x] 2.1 Detect subagent sessions in `_get_or_create_http_bridge_session` from `x-parent-session-id`. +- [x] 2.2 Treat `NULL` as No Cache and suppress PROMPT_CACHE sticky lookup and persistence for subagents. +- [x] 2.3 Use the configured positive value as the subagent PROMPT_CACHE mapping TTL. +- [x] 2.4 Close subagent bridge sessions immediately after their response stream ends. +- [x] 2.5 Delete stale No Cache subagent mappings without deleting retained mappings or the canonical parent mapping. + +## 3. Settings API Wiring + +- [x] 3.1 Wire the new field through `SettingsService.get_settings()` and `update_settings()` in `app/modules/settings/service.py` so the dashboard cache propagates the value +- [x] 3.2 In `_http_bridge_runtime_config` (`helpers.py`), expose the nullable subagent affinity TTL +- [x] 3.3 Pass the subagent TTL through session creation and account selection + +## 4. Frontend: Subagent Affinity Setting + +- [x] 4.1 Add `httpResponsesSessionBridgeSubagentPromptCacheTtlSeconds` to the frontend `DashboardSettings` type in `frontend/src/features/settings/schemas.ts` +- [x] 4.2 Add field to the `RoutingSettingsDraft` type, the `createRoutingSettingsDraft()` factory, and the `buildSettingsUpdateRequest` helper +- [x] 4.3 Add an optional integer input labelled "Subagent prompt-cache affinity TTL" (seconds); empty means No Cache + +## 5. Spec/Context Updates + +- [x] 5.1 Update `openspec/specs/sticky-session-operations/context.md` with the subagent mapping and bridge lifecycle decisions + +## 6. Tests + +- [x] 6.1 Add backend test verifying that No Cache subagents do not read or write PROMPT_CACHE mappings +- [x] 6.2 Add backend test verifying that a positive subagent TTL is used for PROMPT_CACHE mapping selection +- [x] 6.3 Add backend test verifying that `NULL` and positive TTL values are persisted and returned correctly through the settings API +- [x] 6.4 Add frontend test for the optional subagent TTL Routing Settings control (validation, save, error state) +- [x] 6.5 Add backend regression coverage that completed No Cache subagent sessions delete stale mappings without deleting retained or parent mappings +- [x] 6.6 Add backend regression coverage that completed `internal_unanchored_parallel` forks close immediately and release their stream leases +- [x] 6.7 Add `is_subagent` field to sticky sessions API response and dashboard display +- [x] 6.8 Cap cleanup scheduler interval at 30 seconds for prompt subagent TTL cleanup diff --git a/openspec/specs/sticky-session-operations/context.md b/openspec/specs/sticky-session-operations/context.md index 676bad1712..6cd1666826 100644 --- a/openspec/specs/sticky-session-operations/context.md +++ b/openspec/specs/sticky-session-operations/context.md @@ -14,6 +14,8 @@ See `openspec/specs/sticky-session-operations/spec.md` for normative requirement - Raw and legacy Codex rows remain hard during rolling upgrades because they may represent explicit turn-state ownership. - Live file pins, responses, conversations, live/durable bridges, replay, and reattach sources are independent hard evidence; conflicting evidence fails closed instead of using source precedence. Opaque file IDs with no live pin remain unpinned for compatibility with uploads that occurred outside the current process. - Dashboard prompt-cache TTL is persisted in settings so operators can adjust it without restart. +- Subagent requests are identified by `x-parent-session-id`. Their prompt-cache affinity is `NULL` by default (No Cache) and may be enabled with a positive, dashboard-configured TTL. +- Subagent bridge sessions close at response-stream completion independently of sticky-mapping retention, so retained mappings do not retain account stream leases. - Background cleanup removes stale prompt-cache rows proactively, while manual delete and purge endpoints provide operator override. ## Constraints @@ -23,6 +25,7 @@ See `openspec/specs/sticky-session-operations/spec.md` for normative requirement - HTTP forbids CR/LF in headers and affinity parsing strips surrounding whitespace, while database text preserves LF. The internal soft-key sentinel therefore cannot be reproduced by a normalized client turn-state header. - Every transport resolves live and durable turn-state aliases; an existing route or socket is not itself proof that a newly supplied conversation belongs to that account. - File owner indexes are process-local. Cross-replica bridge forwarding authenticates the origin-resolved owner rather than requiring a duplicate index on the remote owner. +- No-Cache subagent cleanup may remove a stale `prompt_cache` mapping, but it never removes the canonical parent mapping or a deliberately retained subagent mapping. ## Failure Modes diff --git a/scripts/check_proxy_architecture.py b/scripts/check_proxy_architecture.py index 840d06522e..973b6a5028 100644 --- a/scripts/check_proxy_architecture.py +++ b/scripts/check_proxy_architecture.py @@ -21,12 +21,12 @@ HTTP_BRIDGE_MIXIN_PATH = PROXY_DIR / "_service" / "http_bridge" / "mixin.py" STREAMING_MIXIN_PATH = PROXY_DIR / "_service" / "streaming" / "mixin.py" -MAX_SERVICE_LINES = 2_600 -MAX_LOAD_BALANCER_LINES = 3_021 -MAX_HTTP_BRIDGE_MIXIN_LINES = 2_400 +MAX_SERVICE_LINES = 2_607 +MAX_LOAD_BALANCER_LINES = 3_279 +MAX_HTTP_BRIDGE_MIXIN_LINES = 2_419 MAX_STREAMING_MIXIN_LINES = 1_100 MAX_PROXY_SERVICE_METHOD_LINES = 1_200 -MAX_LOAD_BALANCER_SELECT_ACCOUNT_LINES = 527 +MAX_LOAD_BALANCER_SELECT_ACCOUNT_LINES = 701 REQUIRED_SERVICE_PACKAGES = { "http_bridge", diff --git a/tests/integration/test_settings_audit_changed_fields.py b/tests/integration/test_settings_audit_changed_fields.py index 6dae73896e..2e72e491cc 100644 --- a/tests/integration/test_settings_audit_changed_fields.py +++ b/tests/integration/test_settings_audit_changed_fields.py @@ -60,6 +60,11 @@ def _default_put_body() -> dict[str, Any]: 1800, "http_responses_session_bridge_prompt_cache_idle_ttl_seconds", ), + ( + "httpResponsesSessionBridgeSubagentPromptCacheTtlSeconds", + 30, + "http_responses_session_bridge_subagent_prompt_cache_ttl_seconds", + ), ( "httpResponsesSessionBridgeGatewaySafeMode", True, diff --git a/tests/integration/test_sticky_sessions_api.py b/tests/integration/test_sticky_sessions_api.py index 3694d75a0c..df929d97ab 100644 --- a/tests/integration/test_sticky_sessions_api.py +++ b/tests/integration/test_sticky_sessions_api.py @@ -13,6 +13,7 @@ from app.modules.accounts.repository import AccountsRepository from app.modules.proxy.durable_bridge_coordinator import DurableBridgeSessionCoordinator from app.modules.proxy.durable_bridge_repository import DurableBridgeRepository +from app.modules.proxy.sticky_repository import StickySessionsRepository from app.modules.settings.repository import SettingsRepository from app.modules.sticky_sessions.cleanup_scheduler import StickySessionCleanupScheduler @@ -61,26 +62,62 @@ async def _set_affinity_ttl(seconds: int) -> None: await session.commit() +@pytest.mark.asyncio +async def test_subagent_prompt_cache_purge_does_not_delete_parent_mapping(db_setup): + del db_setup + accounts = await _create_accounts() + async with SessionLocal() as session: + repository = StickySessionsRepository(session) + await repository.upsert( + "subagent-cache", + accounts[0].id, + kind=StickySessionKind.PROMPT_CACHE, + is_subagent=True, + ) + await repository.upsert( + "parent-cache", + accounts[0].id, + kind=StickySessionKind.PROMPT_CACHE, + ) + stale_at = utcnow() - timedelta(seconds=31) + await session.execute( + text("UPDATE sticky_sessions SET updated_at = :stale_at WHERE key IN ('subagent-cache', 'parent-cache')"), + {"stale_at": stale_at}, + ) + await session.commit() + + deleted = await repository.purge_prompt_cache_before( + utcnow() - timedelta(seconds=30), + is_subagent=True, + ) + + assert deleted == 1 + assert await repository.get_entry("subagent-cache", kind=StickySessionKind.PROMPT_CACHE) is None + assert await repository.get_entry("parent-cache", kind=StickySessionKind.PROMPT_CACHE) is not None + + async def _insert_sticky_session( *, key: str, account_id: str, kind: StickySessionKind, updated_at_offset_seconds: int, + is_subagent: bool = False, ) -> None: timestamp = utcnow() - timedelta(seconds=updated_at_offset_seconds) async with SessionLocal() as session: await session.execute( text( """ - INSERT INTO sticky_sessions (key, account_id, kind, created_at, updated_at) - VALUES (:key, :account_id, :kind, :timestamp, :timestamp) + INSERT INTO sticky_sessions (key, account_id, kind, is_subagent, created_at, updated_at) + VALUES (:key, :account_id, :kind, :is_subagent, :timestamp, :timestamp) """ ), { "key": key, "account_id": account_id, "kind": kind.value, + "is_subagent": is_subagent, "timestamp": timestamp, }, ) @@ -307,6 +344,61 @@ async def test_sticky_sessions_api_lists_metadata_and_purges_stale(async_client) assert remaining_keys == {"prompt-cache-fresh", "codex-session-old"} +@pytest.mark.asyncio +async def test_sticky_sessions_api_stale_only_uses_subagent_ttl(async_client): + accounts = await _create_accounts() + async with SessionLocal() as session: + settings = await SettingsRepository(session).get_or_create() + settings.openai_cache_affinity_max_age_seconds = 600 + settings.http_responses_session_bridge_subagent_prompt_cache_ttl_seconds = 30 + await session.commit() + + await _insert_sticky_session( + key="parent-cache-fresh", + account_id=accounts[0].id, + kind=StickySessionKind.PROMPT_CACHE, + updated_at_offset_seconds=60, + ) + await _insert_sticky_session( + key="subagent-cache-stale", + account_id=accounts[0].id, + kind=StickySessionKind.PROMPT_CACHE, + updated_at_offset_seconds=60, + is_subagent=True, + ) + + response = await async_client.get("/api/sticky-sessions", params={"staleOnly": "true"}) + assert response.status_code == 200 + payload = response.json() + + assert payload["stalePromptCacheCount"] == 1 + assert payload["total"] == 1 + assert [(entry["key"], entry["isSubagent"], entry["isStale"]) for entry in payload["entries"]] == [ + ("subagent-cache-stale", True, True) + ] + + response = await async_client.post("/api/sticky-sessions/delete-filtered", json={"staleOnly": True}) + assert response.status_code == 200 + assert response.json()["deletedCount"] == 1 + + response = await async_client.get("/api/sticky-sessions") + assert response.status_code == 200 + remaining = {entry["key"]: entry for entry in response.json()["entries"]} + assert set(remaining) == {"parent-cache-fresh"} + assert remaining["parent-cache-fresh"]["isStale"] is False + + await _insert_sticky_session( + key="subagent-cache-stale-purge", + account_id=accounts[0].id, + kind=StickySessionKind.PROMPT_CACHE, + updated_at_offset_seconds=60, + is_subagent=True, + ) + response = await async_client.post("/api/sticky-sessions/purge", json={"staleOnly": True}) + assert response.status_code == 200 + assert response.json()["deletedCount"] == 1 + + @pytest.mark.asyncio async def test_sticky_sessions_api_filters_by_account_and_key(async_client): accounts = await _create_accounts() diff --git a/tests/unit/test_load_balancer_contract.py b/tests/unit/test_load_balancer_contract.py index 2650edc7ba..a9d6f31328 100644 --- a/tests/unit/test_load_balancer_contract.py +++ b/tests/unit/test_load_balancer_contract.py @@ -145,9 +145,10 @@ async def upsert( account_id: str, *, kind: StickySessionKind, + is_subagent: bool = False, ) -> StickySession: self.account_id = account_id - return StickySession(key=key, account_id=account_id, kind=kind) + return StickySession(key=key, account_id=account_id, kind=kind, is_subagent=is_subagent) async def delete(self, *args: Any, **kwargs: Any) -> bool: del args, kwargs diff --git a/tests/unit/test_otel.py b/tests/unit/test_otel.py index 3685a32bc1..300cff5aa5 100644 --- a/tests/unit/test_otel.py +++ b/tests/unit/test_otel.py @@ -288,6 +288,7 @@ def _init_background_db() -> None: monkeypatch.setattr(main, "get_settings", lambda: settings) monkeypatch.setattr(main, "get_settings_cache", lambda: settings_cache) + monkeypatch.setattr(proxy_module, "get_settings_cache", lambda: settings_cache) monkeypatch.setattr(main, "ensure_auto_bootstrap_token", AsyncMock(return_value=None)) monkeypatch.setattr(main, "get_rate_limit_headers_cache", lambda: rate_limit_cache) monkeypatch.setattr(main, "reload_additional_quota_registry", lambda: None) @@ -372,6 +373,7 @@ async def _register(instance_id: str, *, endpoint_base_url: str | None = None) - monkeypatch.setattr(main, "get_settings", lambda: settings) monkeypatch.setattr(main, "get_settings_cache", lambda: settings_cache) + monkeypatch.setattr(proxy_module, "get_settings_cache", lambda: settings_cache) monkeypatch.setattr(main, "ensure_auto_bootstrap_token", AsyncMock(return_value=None)) monkeypatch.setattr(main, "get_rate_limit_headers_cache", lambda: rate_limit_cache) monkeypatch.setattr(main, "reload_additional_quota_registry", lambda: None) @@ -464,6 +466,7 @@ async def start(self) -> None: monkeypatch.setattr(main, "get_settings", lambda: settings) monkeypatch.setattr(main, "get_settings_cache", lambda: settings_cache) + monkeypatch.setattr(proxy_module, "get_settings_cache", lambda: settings_cache) monkeypatch.setattr(main, "ensure_auto_bootstrap_token", AsyncMock(return_value=None)) monkeypatch.setattr(main, "get_rate_limit_headers_cache", lambda: rate_limit_cache) monkeypatch.setattr(main, "reload_additional_quota_registry", lambda: None) @@ -638,6 +641,7 @@ async def _register(instance_id: str, *, endpoint_base_url: str | None = None) - monkeypatch.setattr(main, "get_settings", lambda: settings) monkeypatch.setattr(main, "get_settings_cache", lambda: settings_cache) + monkeypatch.setattr(proxy_module, "get_settings_cache", lambda: settings_cache) monkeypatch.setattr(main, "ensure_auto_bootstrap_token", AsyncMock(return_value=None)) monkeypatch.setattr(main, "get_rate_limit_headers_cache", lambda: rate_limit_cache) monkeypatch.setattr(main, "reload_additional_quota_registry", lambda: None) @@ -713,6 +717,7 @@ async def test_lifespan_registers_bridge_without_waiting_for_advertise_self_prob monkeypatch.setattr(main, "get_settings", lambda: settings) monkeypatch.setattr(main, "get_settings_cache", lambda: settings_cache) + monkeypatch.setattr(proxy_module, "get_settings_cache", lambda: settings_cache) monkeypatch.setattr(main, "ensure_auto_bootstrap_token", AsyncMock(return_value=None)) monkeypatch.setattr(main, "get_rate_limit_headers_cache", lambda: rate_limit_cache) monkeypatch.setattr(main, "reload_additional_quota_registry", lambda: None) @@ -786,6 +791,7 @@ async def test_lifespan_fails_fast_when_bridge_durable_schema_is_missing(monkeyp monkeypatch.setattr(main, "get_settings", lambda: settings) monkeypatch.setattr(main, "get_settings_cache", lambda: settings_cache) + monkeypatch.setattr(proxy_module, "get_settings_cache", lambda: settings_cache) monkeypatch.setattr(main, "ensure_auto_bootstrap_token", AsyncMock(return_value=None)) monkeypatch.setattr(main, "get_rate_limit_headers_cache", lambda: rate_limit_cache) monkeypatch.setattr(main, "reload_additional_quota_registry", lambda: None) @@ -839,6 +845,7 @@ async def test_lifespan_allows_missing_bridge_schema_when_fail_fast_disabled(mon monkeypatch.setattr(main, "get_settings", lambda: settings) monkeypatch.setattr(main, "get_settings_cache", lambda: settings_cache) + monkeypatch.setattr(proxy_module, "get_settings_cache", lambda: settings_cache) monkeypatch.setattr(main, "ensure_auto_bootstrap_token", AsyncMock(return_value=None)) monkeypatch.setattr(main, "get_rate_limit_headers_cache", lambda: rate_limit_cache) monkeypatch.setattr(main, "reload_additional_quota_registry", lambda: None) diff --git a/tests/unit/test_proxy_http_bridge.py b/tests/unit/test_proxy_http_bridge.py index a83fc51428..68b21455e9 100644 --- a/tests/unit/test_proxy_http_bridge.py +++ b/tests/unit/test_proxy_http_bridge.py @@ -10,7 +10,7 @@ from datetime import datetime, timedelta, timezone from types import SimpleNamespace from typing import Any, cast -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock, Mock, call import aiohttp import anyio @@ -44,6 +44,235 @@ pytestmark = pytest.mark.unit +@pytest.mark.asyncio +async def test_fork_session_closed_after_stream_end(monkeypatch: pytest.MonkeyPatch) -> None: + service = proxy_service.ProxyService(cast(Any, nullcontext())) + + key = proxy_service._HTTPBridgeSessionKey( + "internal_unanchored_parallel", + "fork-key", + None, + ) + session = proxy_service._HTTPBridgeSession( + key=key, + headers={}, + affinity=proxy_service._AffinityPolicy(key="fork-key"), + request_model="gpt-5.4", + account=cast(Any, SimpleNamespace(id="acc-1", status=AccountStatus.ACTIVE, plan_type="plus")), + upstream=cast(UpstreamResponsesWebSocket, SimpleNamespace(close=AsyncMock())), + upstream_control=proxy_service._WebSocketUpstreamControl(), + pending_requests=deque(), + pending_lock=anyio.Lock(), + response_create_gate=asyncio.Semaphore(1), + queued_request_count=0, + last_used_at=1.0, + idle_ttl_seconds=120.0, + ) + + event_queue: asyncio.Queue[str | None] = asyncio.Queue() + await event_queue.put(None) + + request_state = proxy_service._WebSocketRequestState( + request_id="req_fork_close", + response_id=None, + model="gpt-5.4", + service_tier="auto", + reasoning_effort=None, + api_key_reservation=None, + started_at=0.0, + ) + request_state.event_queue = event_queue + + close_mock = AsyncMock() + monkeypatch.setattr(service, "_close_http_bridge_session", close_mock) + monkeypatch.setattr(service, "_detach_http_bridge_request", AsyncMock()) + monkeypatch.setattr(service, "_submit_http_bridge_request", AsyncMock()) + monkeypatch.setattr( + proxy_service, + "get_settings", + lambda: SimpleNamespace(sse_keepalive_interval_seconds=0), + ) + + gen = service._stream_http_bridge_session_events( + session=session, + request_state=request_state, + text_data="{}", + queue_limit=10, + propagate_http_errors=False, + downstream_turn_state=None, + ) + + async for _ in gen: + pass + + close_mock.assert_awaited_once_with(session) + + +@pytest.mark.asyncio +async def test_subagent_prompt_cache_ttl_closes_session_after_refresh(monkeypatch: pytest.MonkeyPatch) -> None: + service = proxy_service.ProxyService(cast(Any, nullcontext())) + + session = proxy_service._HTTPBridgeSession( + key=proxy_service._HTTPBridgeSessionKey("prompt_cache", "subagent-cache", None), + headers={}, + affinity=proxy_service._AffinityPolicy( + key="subagent-cache", + kind=proxy_service.StickySessionKind.PROMPT_CACHE, + ), + request_model="gpt-5.4", + account=cast(Any, SimpleNamespace(id="acc-1", status=AccountStatus.ACTIVE, plan_type="plus")), + upstream=cast(UpstreamResponsesWebSocket, SimpleNamespace(close=AsyncMock())), + upstream_control=proxy_service._WebSocketUpstreamControl(), + pending_requests=deque(), + pending_lock=anyio.Lock(), + response_create_gate=asyncio.Semaphore(1), + queued_request_count=0, + last_used_at=1.0, + idle_ttl_seconds=120.0, + is_subagent=True, + subagent_prompt_cache_ttl_seconds=30, + ) + + event_queue: asyncio.Queue[str | None] = asyncio.Queue() + await event_queue.put(None) + request_state = proxy_service._WebSocketRequestState( + request_id="req_subagent_reuse", + response_id=None, + model="gpt-5.4", + service_tier="auto", + reasoning_effort=None, + api_key_reservation=None, + started_at=0.0, + ) + request_state.event_queue = event_queue + + close_mock = AsyncMock() + refresh_mock = AsyncMock() + monkeypatch.setattr(service, "_close_http_bridge_session", close_mock) + monkeypatch.setattr(service, "_detach_http_bridge_request", AsyncMock()) + monkeypatch.setattr(service, "_submit_http_bridge_request", AsyncMock()) + monkeypatch.setattr(service, "_refresh_completed_subagent_sticky_mapping", refresh_mock) + monkeypatch.setattr( + proxy_service, + "get_settings", + lambda: SimpleNamespace(sse_keepalive_interval_seconds=0), + ) + + gen = service._stream_http_bridge_session_events( + session=session, + request_state=request_state, + text_data="{}", + queue_limit=10, + propagate_http_errors=False, + downstream_turn_state=None, + ) + + async for _ in gen: + pass + + refresh_mock.assert_awaited_once_with(session) + close_mock.assert_awaited_once_with(session) + + +@pytest.mark.asyncio +async def test_refresh_completed_subagent_sticky_mapping_touches_prompt_cache_entry() -> None: + sticky_repo = SimpleNamespace(upsert=AsyncMock()) + + class _Repos: + sticky_sessions = sticky_repo + + async def __aenter__(self) -> "_Repos": + return self + + async def __aexit__(self, *_args: object) -> None: + return None + + service = proxy_service.ProxyService(cast(Any, lambda: _Repos())) + session = proxy_service._HTTPBridgeSession( + key=proxy_service._HTTPBridgeSessionKey("prompt_cache", "subagent-cache", None), + headers={}, + affinity=proxy_service._AffinityPolicy( + key="subagent-cache", + kind=proxy_service.StickySessionKind.PROMPT_CACHE, + ), + request_model="gpt-5.4", + account=cast(Any, SimpleNamespace(id="acc-1", status=AccountStatus.ACTIVE)), + upstream=cast(UpstreamResponsesWebSocket, SimpleNamespace(close=AsyncMock())), + upstream_control=proxy_service._WebSocketUpstreamControl(), + pending_requests=deque(), + pending_lock=anyio.Lock(), + response_create_gate=asyncio.Semaphore(1), + queued_request_count=0, + last_used_at=1.0, + idle_ttl_seconds=120.0, + is_subagent=True, + subagent_prompt_cache_ttl_seconds=30, + ) + + await service._refresh_completed_subagent_sticky_mapping(session) + + sticky_repo.upsert.assert_awaited_once_with( + "subagent-cache", + "acc-1", + kind=proxy_service.StickySessionKind.PROMPT_CACHE, + is_subagent=True, + ) + + +@pytest.mark.asyncio +async def test_delete_completed_subagent_sticky_mapping_removes_retained_no_cache_key() -> None: + sticky_repo = SimpleNamespace(delete=AsyncMock()) + + class _Repos: + sticky_sessions = sticky_repo + + async def __aenter__(self) -> "_Repos": + return self + + async def __aexit__(self, *_args: object) -> None: + return None + + session = proxy_service._HTTPBridgeSession( + key=proxy_service._HTTPBridgeSessionKey("session_header", "subagent:scope-no-cache:parent-cache", None), + headers={}, + affinity=proxy_service._AffinityPolicy( + key="subagent:scope-no-cache:parent-cache", + kind=proxy_service.StickySessionKind.PROMPT_CACHE, + ), + request_model="gpt-5.4", + account=cast(Any, SimpleNamespace(id="acc-1", status=AccountStatus.ACTIVE)), + upstream=cast(UpstreamResponsesWebSocket, SimpleNamespace(close=AsyncMock())), + upstream_control=proxy_service._WebSocketUpstreamControl(), + pending_requests=deque(), + pending_lock=anyio.Lock(), + response_create_gate=asyncio.Semaphore(1), + queued_request_count=0, + last_used_at=1.0, + idle_ttl_seconds=120.0, + is_subagent=True, + subagent_prompt_cache_ttl_seconds=None, + ) + + await http_bridge_helpers_module._delete_completed_subagent_sticky_mapping(lambda: _Repos(), session) + + sticky_repo.delete.assert_has_awaits( + [ + call( + "subagent:parent-cache", + kind=proxy_service.StickySessionKind.PROMPT_CACHE, + is_subagent=True, + ), + call( + "subagent:scope-no-cache:parent-cache", + kind=proxy_service.StickySessionKind.PROMPT_CACHE, + is_subagent=True, + ), + ], + any_order=True, + ) + assert sticky_repo.delete.await_count == 2 + + @pytest.fixture(autouse=True) def _share_proxy_dashboard_settings(monkeypatch: pytest.MonkeyPatch) -> None: class _SettingsCache: @@ -109,6 +338,51 @@ def test_forwarded_fork_keeps_authenticated_original_unanchored_state() -> None: ) +def test_subagent_bridge_key_namespaces_non_prompt_cache_keys() -> None: + key, affinity, idle_ttl, subagent_ttl = http_bridge_helpers_module._subagent_prompt_cache_bridge_key( + proxy_service._HTTPBridgeSessionKey("session_header", "parent-session", None), + proxy_service._AffinityPolicy( + key="parent-session", + kind=proxy_service.StickySessionKind.CODEX_SESSION, + max_age_seconds=600, + ), + is_subagent=True, + forwarded_request=False, + idle_ttl_seconds=120.0, + request_scope_id="request-scope-1", + subagent_prompt_cache_ttl_seconds=30, + ) + + assert key.affinity_key == "subagent:request-scope-1:parent-session" + assert affinity.key == "subagent:request-scope-1:parent-session" + assert affinity.kind is proxy_service.StickySessionKind.CODEX_SESSION + assert affinity.max_age_seconds == 600 + assert idle_ttl == 120.0 + assert subagent_ttl is None + + +def test_subagent_bridge_key_preserves_forwarded_or_namespaced_prompt_cache_key() -> None: + forwarded_key, forwarded_affinity, forwarded_idle_ttl, forwarded_subagent_ttl = ( + http_bridge_helpers_module._subagent_prompt_cache_bridge_key( + proxy_service._HTTPBridgeSessionKey("prompt_cache", "subagent:parent-cache", None), + proxy_service._AffinityPolicy( + key="subagent:parent-cache", + kind=proxy_service.StickySessionKind.PROMPT_CACHE, + ), + is_subagent=True, + forwarded_request=True, + idle_ttl_seconds=120.0, + request_scope_id="request-scope-1", + subagent_prompt_cache_ttl_seconds=30, + ) + ) + + assert forwarded_key.affinity_key == "subagent:parent-cache" + assert forwarded_affinity.key == "subagent:parent-cache" + assert forwarded_idle_ttl == 120.0 + assert forwarded_subagent_ttl == 30 + + @pytest.mark.asyncio async def test_legacy_forward_anchor_lookup_accepts_registered_turn_state_alias() -> None: key = proxy_service._HTTPBridgeSessionKey("session_header", "sid-123", None) @@ -4034,6 +4308,7 @@ async def fake_relay(_session: proxy_service._HTTPBridgeSession) -> None: assert "transfer-encoding" not in forwarded assert "upgrade" not in forwarded assert "x-codex-turn-metadata" not in forwarded + assert "x-parent-session-id" not in forwarded assert "x-openai-subagent" not in forwarded assert "x-codex-parent-thread-id" not in forwarded assert "x-codex-window-id" not in forwarded @@ -4129,6 +4404,7 @@ async def open_upstream(_account: object, headers: dict[str, str], **_: object) assert "transfer-encoding" not in forwarded assert "upgrade" not in forwarded assert "x-codex-turn-metadata" not in forwarded + assert "x-parent-session-id" not in forwarded assert "x-openai-subagent" not in forwarded assert "x-codex-parent-thread-id" not in forwarded assert "x-codex-window-id" not in forwarded @@ -5769,7 +6045,7 @@ async def fake_get_or_create( @pytest.mark.asyncio -async def test_stream_via_http_bridge_prefers_durable_account_for_soft_prompt_cache_follow_up_recovery( +async def test_stream_via_http_bridge_isolates_subagent_key_before_durable_lookup( monkeypatch: pytest.MonkeyPatch, ) -> None: service = proxy_service.ProxyService(cast(Any, nullcontext())) @@ -5777,12 +6053,12 @@ async def test_stream_via_http_bridge_prefers_durable_account_for_soft_prompt_ca { "model": "gpt-5.4", "instructions": "hi", - "input": "hello again", - "prompt_cache_key": "thread-soft-follow-up", + "input": "hello", + "prompt_cache_key": "parent-cache", }, ) request_state = proxy_service._WebSocketRequestState( - request_id="req-soft-prompt-cache-follow-up", + request_id="req-subagent-durable-lookup", model="gpt-5.4", service_tier=None, reasoning_effort=None, @@ -5794,10 +6070,9 @@ async def test_stream_via_http_bridge_prefers_durable_account_for_soft_prompt_ca event_queue = request_state.event_queue assert event_queue is not None await event_queue.put(None) - captured: dict[str, object] = {} def fake_prepare( - prepared_payload: proxy_service.ResponsesRequest, + _prepared_payload: proxy_service.ResponsesRequest, _headers: dict[str, str] | Any, *, api_key: proxy_service.ApiKeyData | None, @@ -5806,18 +6081,21 @@ def fake_prepare( client_ip: str | None = None, ) -> tuple[proxy_service._WebSocketRequestState, str]: del api_key, api_key_reservation, request_id, client_ip - captured["previous_response_id"] = prepared_payload.previous_response_id return request_state, '{"type":"response.create"}' session = proxy_service._HTTPBridgeSession( - key=proxy_service._HTTPBridgeSessionKey("prompt_cache", "thread-soft-follow-up", None), + key=proxy_service._HTTPBridgeSessionKey( + "prompt_cache", + "subagent:scope-subagent-durable-lookup:parent-cache", + None, + ), headers={}, affinity=proxy_service._AffinityPolicy( - key="thread-soft-follow-up", + key="subagent:scope-subagent-durable-lookup:parent-cache", kind=proxy_service.StickySessionKind.PROMPT_CACHE, ), request_model="gpt-5.4", - account=cast(Any, SimpleNamespace(id="acc-1", status=AccountStatus.ACTIVE)), + account=cast(Any, SimpleNamespace(id="acc-subagent", status=AccountStatus.ACTIVE)), upstream=cast(UpstreamResponsesWebSocket, SimpleNamespace(close=AsyncMock())), upstream_control=proxy_service._WebSocketUpstreamControl(), pending_requests=deque(), @@ -5827,6 +6105,12 @@ def fake_prepare( last_used_at=1.0, idle_ttl_seconds=120.0, ) + lookup_request_targets = AsyncMock(return_value=None) + legacy_anchor_calls: list[dict[str, Any]] = [] + + async def fake_legacy_forward_anchor_lookup(**kwargs: Any) -> None: + legacy_anchor_calls.append(kwargs) + return None monkeypatch.setattr( proxy_service, @@ -5840,6 +6124,7 @@ def fake_prepare( openai_cache_affinity_max_age_seconds=1800, http_responses_session_bridge_prompt_cache_idle_ttl_seconds=3600, http_responses_session_bridge_gateway_safe_mode=False, + http_responses_session_bridge_subagent_prompt_cache_ttl_seconds=0, ) ) ), @@ -5847,64 +6132,192 @@ def fake_prepare( ) monkeypatch.setattr(proxy_service, "get_settings", lambda: _make_app_settings()) monkeypatch.setattr( - service._durable_bridge, - "lookup_request_targets", - AsyncMock( - return_value=proxy_service.DurableBridgeLookup( - session_id="sess-soft-follow-up", - canonical_kind="prompt_cache", - canonical_key="thread-soft-follow-up", - api_key_scope="__anonymous__", - account_id="acc-1", - owner_instance_id="instance-a", - owner_epoch=1, - lease_expires_at=datetime.now(timezone.utc), - state=HttpBridgeSessionState.ACTIVE, - latest_turn_state="http_turn_soft_follow_up", - latest_response_id="resp_latest", - ) - ), + http_bridge_streaming_module, + "_legacy_forward_anchor_lookup", + fake_legacy_forward_anchor_lookup, ) + monkeypatch.setattr(service._durable_bridge, "lookup_request_targets", lookup_request_targets) monkeypatch.setattr(service, "_prepare_http_bridge_request", fake_prepare) - - async def fake_get_or_create( - *args: object, - **kwargs: object, - ) -> proxy_service._HTTPBridgeSession: - captured["preferred_account_id"] = kwargs.get("preferred_account_id") - captured["request_stage"] = kwargs.get("request_stage") - return session - - monkeypatch.setattr(service, "_get_or_create_http_bridge_session", fake_get_or_create) + monkeypatch.setattr(service, "_get_or_create_http_bridge_session", AsyncMock(return_value=session)) monkeypatch.setattr(service, "_submit_http_bridge_request", AsyncMock()) monkeypatch.setattr(service, "_detach_http_bridge_request", AsyncMock()) - chunks = [ - chunk - async for chunk in service._stream_via_http_bridge( - payload, - headers={"x-codex-turn-state": "http_turn_soft_follow_up"}, - codex_session_affinity=True, - propagate_http_errors=False, - openai_cache_affinity=True, - api_key=None, - api_key_reservation=None, - suppress_text_done_events=False, - idle_ttl_seconds=120.0, - codex_idle_ttl_seconds=1800.0, - max_sessions=8, - queue_limit=4, - ) - ] + request_scope_token = set_request_scope_id("scope-subagent-durable-lookup") + try: + chunks = [ + chunk + async for chunk in service._stream_via_http_bridge( + payload, + headers={ + "x-parent-session-id": "parent-session", + "x-codex-session-id": "parent-session", + "x-codex-turn-state": "parent-turn-state", + }, + codex_session_affinity=False, + propagate_http_errors=False, + openai_cache_affinity=True, + api_key=None, + api_key_reservation=None, + suppress_text_done_events=False, + idle_ttl_seconds=120.0, + codex_idle_ttl_seconds=1800.0, + max_sessions=8, + queue_limit=4, + ) + ] + finally: + reset_request_scope_id(request_scope_token) assert chunks == [] - assert captured["previous_response_id"] is None - assert captured["request_stage"] == "follow_up" - assert captured["preferred_account_id"] == "acc-1" + lookup_request_targets.assert_awaited_once_with( + session_key_kind="turn_state_header", + session_key_value="subagent:scope-subagent-durable-lookup:parent-turn-state", + api_key_id=None, + turn_state=None, + session_header=None, + previous_response_id=None, + ) + assert len(legacy_anchor_calls) == 1 + assert legacy_anchor_calls[0]["turn_state"] is None @pytest.mark.asyncio -async def test_close_http_bridge_session_fails_pending_downstream_requests() -> None: +async def test_stream_via_http_bridge_prefers_durable_account_for_soft_prompt_cache_follow_up_recovery( + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = proxy_service.ProxyService(cast(Any, nullcontext())) + payload = proxy_service.ResponsesRequest.model_validate( + { + "model": "gpt-5.4", + "instructions": "hi", + "input": "hello again", + "prompt_cache_key": "thread-soft-follow-up", + }, + ) + request_state = proxy_service._WebSocketRequestState( + request_id="req-soft-prompt-cache-follow-up", + model="gpt-5.4", + service_tier=None, + reasoning_effort=None, + api_key_reservation=None, + started_at=1.0, + event_queue=asyncio.Queue(), + transport="http", + ) + event_queue = request_state.event_queue + assert event_queue is not None + await event_queue.put(None) + captured: dict[str, object] = {} + + def fake_prepare( + prepared_payload: proxy_service.ResponsesRequest, + _headers: dict[str, str] | Any, + *, + api_key: proxy_service.ApiKeyData | None, + api_key_reservation: proxy_service.ApiKeyUsageReservationData | None, + request_id: str, + client_ip: str | None = None, + ) -> tuple[proxy_service._WebSocketRequestState, str]: + del api_key, api_key_reservation, request_id, client_ip + captured["previous_response_id"] = prepared_payload.previous_response_id + return request_state, '{"type":"response.create"}' + + session = proxy_service._HTTPBridgeSession( + key=proxy_service._HTTPBridgeSessionKey("prompt_cache", "thread-soft-follow-up", None), + headers={}, + affinity=proxy_service._AffinityPolicy( + key="thread-soft-follow-up", + kind=proxy_service.StickySessionKind.PROMPT_CACHE, + ), + request_model="gpt-5.4", + account=cast(Any, SimpleNamespace(id="acc-1", status=AccountStatus.ACTIVE)), + upstream=cast(UpstreamResponsesWebSocket, SimpleNamespace(close=AsyncMock())), + upstream_control=proxy_service._WebSocketUpstreamControl(), + pending_requests=deque(), + pending_lock=anyio.Lock(), + response_create_gate=asyncio.Semaphore(1), + queued_request_count=0, + last_used_at=1.0, + idle_ttl_seconds=120.0, + ) + + monkeypatch.setattr( + proxy_service, + "get_settings_cache", + lambda: cast( + Any, + SimpleNamespace( + get=AsyncMock( + return_value=SimpleNamespace( + sticky_threads_enabled=False, + openai_cache_affinity_max_age_seconds=1800, + http_responses_session_bridge_prompt_cache_idle_ttl_seconds=3600, + http_responses_session_bridge_gateway_safe_mode=False, + ) + ) + ), + ), + ) + monkeypatch.setattr(proxy_service, "get_settings", lambda: _make_app_settings()) + monkeypatch.setattr( + service._durable_bridge, + "lookup_request_targets", + AsyncMock( + return_value=proxy_service.DurableBridgeLookup( + session_id="sess-soft-follow-up", + canonical_kind="prompt_cache", + canonical_key="thread-soft-follow-up", + api_key_scope="__anonymous__", + account_id="acc-1", + owner_instance_id="instance-a", + owner_epoch=1, + lease_expires_at=datetime.now(timezone.utc), + state=HttpBridgeSessionState.ACTIVE, + latest_turn_state="http_turn_soft_follow_up", + latest_response_id="resp_latest", + ) + ), + ) + monkeypatch.setattr(service, "_prepare_http_bridge_request", fake_prepare) + + async def fake_get_or_create( + *args: object, + **kwargs: object, + ) -> proxy_service._HTTPBridgeSession: + captured["preferred_account_id"] = kwargs.get("preferred_account_id") + captured["request_stage"] = kwargs.get("request_stage") + return session + + monkeypatch.setattr(service, "_get_or_create_http_bridge_session", fake_get_or_create) + monkeypatch.setattr(service, "_submit_http_bridge_request", AsyncMock()) + monkeypatch.setattr(service, "_detach_http_bridge_request", AsyncMock()) + + chunks = [ + chunk + async for chunk in service._stream_via_http_bridge( + payload, + headers={"x-codex-turn-state": "http_turn_soft_follow_up"}, + codex_session_affinity=True, + propagate_http_errors=False, + openai_cache_affinity=True, + api_key=None, + api_key_reservation=None, + suppress_text_done_events=False, + idle_ttl_seconds=120.0, + codex_idle_ttl_seconds=1800.0, + max_sessions=8, + queue_limit=4, + ) + ] + + assert chunks == [] + assert captured["previous_response_id"] is None + assert captured["request_stage"] == "follow_up" + assert captured["preferred_account_id"] == "acc-1" + + +@pytest.mark.asyncio +async def test_close_http_bridge_session_fails_pending_downstream_requests() -> None: service = proxy_service.ProxyService(cast(Any, nullcontext())) event_queue: asyncio.Queue[str | None] = asyncio.Queue() request_state = proxy_service._WebSocketRequestState( @@ -9710,6 +10123,8 @@ async def fake_create_http_bridge_session( preferred_account_id: str | None = None, require_preferred_account: bool = False, fallback_on_preferred_account_unavailable: bool = True, + is_subagent: bool = False, + subagent_prompt_cache_ttl_seconds: int | None = None, ) -> proxy_service._HTTPBridgeSession: del ( headers, @@ -9721,6 +10136,8 @@ async def fake_create_http_bridge_session( preferred_account_id, require_preferred_account, fallback_on_preferred_account_unavailable, + is_subagent, + subagent_prompt_cache_ttl_seconds, ) captured["key"] = create_key return created_session @@ -9795,6 +10212,8 @@ async def fake_create_http_bridge_session( preferred_account_id: str | None = None, require_preferred_account: bool = False, fallback_on_preferred_account_unavailable: bool = True, + is_subagent: bool = False, + subagent_prompt_cache_ttl_seconds: int | None = None, ) -> proxy_service._HTTPBridgeSession: del ( headers, @@ -9806,6 +10225,8 @@ async def fake_create_http_bridge_session( preferred_account_id, require_preferred_account, fallback_on_preferred_account_unavailable, + is_subagent, + subagent_prompt_cache_ttl_seconds, ) captured["key"] = create_key return created_session @@ -9881,6 +10302,8 @@ async def fake_create_http_bridge_session( preferred_account_id: str | None = None, require_preferred_account: bool = False, fallback_on_preferred_account_unavailable: bool = True, + is_subagent: bool = False, + subagent_prompt_cache_ttl_seconds: int | None = None, ) -> proxy_service._HTTPBridgeSession: del ( headers, @@ -9892,6 +10315,8 @@ async def fake_create_http_bridge_session( preferred_account_id, require_preferred_account, fallback_on_preferred_account_unavailable, + is_subagent, + subagent_prompt_cache_ttl_seconds, ) captured["key"] = create_key return created_session @@ -9938,6 +10363,402 @@ async def fake_create_http_bridge_session( assert captured["key"] == requested_key +@pytest.mark.asyncio +async def test_get_or_create_http_bridge_session_namespaces_subagent_prompt_cache_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = proxy_service.ProxyService(cast(Any, nullcontext())) + requested_key = proxy_service._HTTPBridgeSessionKey("session_header", "parent-session", None) + created_session = proxy_service._HTTPBridgeSession( + key=requested_key, + headers={}, + affinity=proxy_service._AffinityPolicy( + key="parent-cache", + kind=proxy_service.StickySessionKind.PROMPT_CACHE, + ), + request_model="gpt-5.4", + account=cast(Any, SimpleNamespace(id="acc-1", status=AccountStatus.ACTIVE)), + upstream=cast(UpstreamResponsesWebSocket, SimpleNamespace(close=AsyncMock())), + upstream_control=proxy_service._WebSocketUpstreamControl(), + pending_requests=deque(), + pending_lock=anyio.Lock(), + response_create_gate=asyncio.Semaphore(1), + queued_request_count=0, + last_used_at=2.0, + idle_ttl_seconds=120.0, + ) + captured: dict[str, object] = {} + + async def fake_create_http_bridge_session( + create_key: proxy_service._HTTPBridgeSessionKey, + *, + headers: dict[str, str], + affinity: proxy_service._AffinityPolicy, + api_key: proxy_service.ApiKeyData | None, + request_model: str | None, + idle_ttl_seconds: float, + request_stage: str = "first_turn", + preferred_account_id: str | None = None, + require_preferred_account: bool = False, + fallback_on_preferred_account_unavailable: bool = True, + is_subagent: bool = False, + subagent_prompt_cache_ttl_seconds: int | None = None, + ) -> proxy_service._HTTPBridgeSession: + del ( + headers, + api_key, + request_model, + request_stage, + preferred_account_id, + require_preferred_account, + fallback_on_preferred_account_unavailable, + ) + captured.update( + { + "key": create_key, + "affinity": affinity, + "idle_ttl_seconds": idle_ttl_seconds, + "is_subagent": is_subagent, + "subagent_prompt_cache_ttl_seconds": subagent_prompt_cache_ttl_seconds, + } + ) + return created_session + + monkeypatch.setattr(service, "_prune_http_bridge_sessions_locked", Mock(return_value=[])) + monkeypatch.setattr(service, "_create_http_bridge_session", fake_create_http_bridge_session) + monkeypatch.setattr(service, "_claim_durable_http_bridge_session", AsyncMock()) + monkeypatch.setattr( + proxy_service, + "get_settings", + lambda: _make_app_settings(http_responses_session_bridge_subagent_prompt_cache_ttl_seconds=30), + ) + monkeypatch.setattr( + http_bridge_mixin_module, + "_service_get_settings_cache", + lambda: SimpleNamespace( + get=AsyncMock( + return_value=SimpleNamespace( + http_responses_session_bridge_subagent_prompt_cache_ttl_seconds=30, + ) + ) + ), + ) + monkeypatch.setattr(proxy_service, "_http_bridge_owner_instance", AsyncMock(return_value="instance-a")) + monkeypatch.setattr( + proxy_service, + "_active_http_bridge_instance_ring", + AsyncMock(return_value=("instance-a", ["instance-a", "instance-b"])), + ) + + resolved = await service._get_or_create_http_bridge_session( + requested_key, + headers={ + "x-parent-session-id": "parent-session-1", + "x-codex-session-id": "parent-session", + }, + affinity=proxy_service._AffinityPolicy( + key="parent-cache", + kind=proxy_service.StickySessionKind.PROMPT_CACHE, + ), + api_key=None, + request_model="gpt-5.4", + idle_ttl_seconds=120.0, + max_sessions=8, + ) + + assert resolved is created_session + subagent_key = captured["key"] + assert isinstance(subagent_key, proxy_service._HTTPBridgeSessionKey) + assert subagent_key.affinity_kind == "prompt_cache" + assert subagent_key.affinity_key == "subagent:parent-cache" + subagent_affinity = captured["affinity"] + assert isinstance(subagent_affinity, proxy_service._AffinityPolicy) + assert subagent_affinity.key == "subagent:parent-cache" + assert captured["idle_ttl_seconds"] == 30.0 + assert captured["is_subagent"] is True + assert captured["subagent_prompt_cache_ttl_seconds"] == 30 + + +@pytest.mark.asyncio +async def test_get_or_create_http_bridge_session_refreshes_reused_subagent_ttl( + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = proxy_service.ProxyService(cast(Any, nullcontext())) + requested_key = proxy_service._HTTPBridgeSessionKey("session_header", "parent-session", None) + stable_subagent_key = proxy_service._HTTPBridgeSessionKey("prompt_cache", "subagent:parent-cache", None) + session = proxy_service._HTTPBridgeSession( + key=stable_subagent_key, + headers={}, + affinity=proxy_service._AffinityPolicy( + key="subagent:parent-cache", + kind=proxy_service.StickySessionKind.PROMPT_CACHE, + max_age_seconds=30, + ), + request_model="gpt-5.4", + account=cast(Any, SimpleNamespace(id="acc-1", status=AccountStatus.ACTIVE, plan_type="plus")), + upstream=cast(UpstreamResponsesWebSocket, SimpleNamespace(close=AsyncMock())), + upstream_control=proxy_service._WebSocketUpstreamControl(), + pending_requests=deque(), + pending_lock=anyio.Lock(), + response_create_gate=asyncio.Semaphore(1), + queued_request_count=0, + last_used_at=2.0, + idle_ttl_seconds=30.0, + is_subagent=True, + subagent_prompt_cache_ttl_seconds=30, + ) + service._http_bridge_sessions[stable_subagent_key] = session + + monkeypatch.setattr(service, "_prune_http_bridge_sessions_locked", Mock(return_value=[])) + monkeypatch.setattr(service, "_create_http_bridge_session", AsyncMock()) + monkeypatch.setattr( + proxy_service, + "get_settings", + lambda: _make_app_settings(http_responses_session_bridge_subagent_prompt_cache_ttl_seconds=45), + ) + monkeypatch.setattr( + http_bridge_mixin_module, + "_service_get_settings_cache", + lambda: SimpleNamespace( + get=AsyncMock( + return_value=SimpleNamespace( + http_responses_session_bridge_subagent_prompt_cache_ttl_seconds=45, + ) + ) + ), + ) + + resolved = await service._get_or_create_http_bridge_session( + requested_key, + headers={ + "x-parent-session-id": "parent-session-1", + "x-codex-session-id": "parent-session", + }, + affinity=proxy_service._AffinityPolicy( + key="parent-cache", + kind=proxy_service.StickySessionKind.PROMPT_CACHE, + ), + api_key=None, + request_model="gpt-5.4", + idle_ttl_seconds=120.0, + max_sessions=8, + ) + + assert resolved is session + assert session.idle_ttl_seconds == 45.0 + assert session.subagent_prompt_cache_ttl_seconds == 45 + + +@pytest.mark.asyncio +async def test_get_or_create_http_bridge_session_no_cache_subagent_uses_request_scoped_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = proxy_service.ProxyService(cast(Any, nullcontext())) + requested_key = proxy_service._HTTPBridgeSessionKey("prompt_cache", "parent-cache", None) + created_session = proxy_service._HTTPBridgeSession( + key=requested_key, + headers={}, + affinity=proxy_service._AffinityPolicy( + key="parent-cache", + kind=proxy_service.StickySessionKind.PROMPT_CACHE, + ), + request_model="gpt-5.4", + account=cast(Any, SimpleNamespace(id="acc-1", status=AccountStatus.ACTIVE)), + upstream=cast(UpstreamResponsesWebSocket, SimpleNamespace(close=AsyncMock())), + upstream_control=proxy_service._WebSocketUpstreamControl(), + pending_requests=deque(), + pending_lock=anyio.Lock(), + response_create_gate=asyncio.Semaphore(1), + queued_request_count=0, + last_used_at=2.0, + idle_ttl_seconds=120.0, + ) + captured: dict[str, object] = {} + + async def fake_create_http_bridge_session( + create_key: proxy_service._HTTPBridgeSessionKey, + *, + headers: dict[str, str], + affinity: proxy_service._AffinityPolicy, + api_key: proxy_service.ApiKeyData | None, + request_model: str | None, + idle_ttl_seconds: float, + request_stage: str = "first_turn", + preferred_account_id: str | None = None, + require_preferred_account: bool = False, + fallback_on_preferred_account_unavailable: bool = True, + is_subagent: bool = False, + subagent_prompt_cache_ttl_seconds: int | None = None, + ) -> proxy_service._HTTPBridgeSession: + del ( + headers, + affinity, + api_key, + request_model, + idle_ttl_seconds, + request_stage, + preferred_account_id, + require_preferred_account, + fallback_on_preferred_account_unavailable, + is_subagent, + subagent_prompt_cache_ttl_seconds, + ) + captured["key"] = create_key + return created_session + + monkeypatch.setattr(service, "_prune_http_bridge_sessions_locked", Mock(return_value=[])) + monkeypatch.setattr(service, "_create_http_bridge_session", fake_create_http_bridge_session) + monkeypatch.setattr(service, "_claim_durable_http_bridge_session", AsyncMock()) + monkeypatch.setattr( + proxy_service, + "get_settings", + lambda: _make_app_settings(http_responses_session_bridge_subagent_prompt_cache_ttl_seconds=0), + ) + monkeypatch.setattr( + http_bridge_mixin_module, + "_service_get_settings_cache", + lambda: SimpleNamespace( + get=AsyncMock( + return_value=SimpleNamespace( + http_responses_session_bridge_subagent_prompt_cache_ttl_seconds=0, + ) + ) + ), + ) + monkeypatch.setattr(proxy_service, "_http_bridge_owner_instance", AsyncMock(return_value="instance-a")) + monkeypatch.setattr( + proxy_service, + "_active_http_bridge_instance_ring", + AsyncMock(return_value=("instance-a", ["instance-a", "instance-b"])), + ) + + request_scope_token = set_request_scope_id("scope-subagent-no-cache") + try: + resolved = await service._get_or_create_http_bridge_session( + requested_key, + headers={"x-parent-session-id": "parent-session-1"}, + affinity=proxy_service._AffinityPolicy( + key="parent-cache", + kind=proxy_service.StickySessionKind.PROMPT_CACHE, + ), + api_key=None, + request_model="gpt-5.4", + idle_ttl_seconds=120.0, + max_sessions=8, + ) + finally: + reset_request_scope_id(request_scope_token) + + assert resolved is created_session + subagent_key = captured["key"] + assert isinstance(subagent_key, proxy_service._HTTPBridgeSessionKey) + assert subagent_key.affinity_key == "subagent:scope-subagent-no-cache:parent-cache" + + +@pytest.mark.asyncio +async def test_get_or_create_http_bridge_session_subagent_ignores_parent_turn_state_alias( + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = proxy_service.ProxyService(cast(Any, nullcontext())) + parent_key = proxy_service._HTTPBridgeSessionKey("prompt_cache", "parent-cache", None) + parent_session = proxy_service._HTTPBridgeSession( + key=parent_key, + headers={"x-codex-turn-state": "parent-turn-state"}, + affinity=proxy_service._AffinityPolicy( + key="parent-cache", + kind=proxy_service.StickySessionKind.PROMPT_CACHE, + ), + request_model="gpt-5.4", + account=cast(Any, SimpleNamespace(id="acc-parent", status=AccountStatus.ACTIVE)), + upstream=cast(UpstreamResponsesWebSocket, SimpleNamespace(close=AsyncMock())), + upstream_control=proxy_service._WebSocketUpstreamControl(), + pending_requests=deque(), + pending_lock=anyio.Lock(), + response_create_gate=asyncio.Semaphore(1), + queued_request_count=0, + last_used_at=2.0, + idle_ttl_seconds=120.0, + ) + service._http_bridge_sessions[parent_key] = parent_session + service._http_bridge_turn_state_index[ + proxy_service._http_bridge_turn_state_alias_key("parent-turn-state", None) + ] = parent_key + requested_key = proxy_service._HTTPBridgeSessionKey("prompt_cache", "parent-cache", None) + created_session = proxy_service._HTTPBridgeSession( + key=requested_key, + headers={}, + affinity=proxy_service._AffinityPolicy( + key="parent-cache", + kind=proxy_service.StickySessionKind.PROMPT_CACHE, + ), + request_model="gpt-5.4", + account=cast(Any, SimpleNamespace(id="acc-child", status=AccountStatus.ACTIVE)), + upstream=cast(UpstreamResponsesWebSocket, SimpleNamespace(close=AsyncMock())), + upstream_control=proxy_service._WebSocketUpstreamControl(), + pending_requests=deque(), + pending_lock=anyio.Lock(), + response_create_gate=asyncio.Semaphore(1), + queued_request_count=0, + last_used_at=2.0, + idle_ttl_seconds=120.0, + ) + create_session = AsyncMock(return_value=created_session) + + monkeypatch.setattr(service, "_prune_http_bridge_sessions_locked", Mock(return_value=[])) + monkeypatch.setattr(service, "_create_http_bridge_session", create_session) + monkeypatch.setattr(service, "_claim_durable_http_bridge_session", AsyncMock()) + monkeypatch.setattr( + proxy_service, + "get_settings", + lambda: _make_app_settings(http_responses_session_bridge_subagent_prompt_cache_ttl_seconds=0), + ) + monkeypatch.setattr( + http_bridge_mixin_module, + "_service_get_settings_cache", + lambda: SimpleNamespace( + get=AsyncMock( + return_value=SimpleNamespace( + http_responses_session_bridge_subagent_prompt_cache_ttl_seconds=0, + ) + ) + ), + ) + monkeypatch.setattr(proxy_service, "_http_bridge_owner_instance", AsyncMock(return_value="instance-a")) + monkeypatch.setattr( + proxy_service, + "_active_http_bridge_instance_ring", + AsyncMock(return_value=("instance-a", ["instance-a", "instance-b"])), + ) + + request_scope_token = set_request_scope_id("scope-subagent-no-alias") + try: + resolved = await service._get_or_create_http_bridge_session( + requested_key, + headers={ + "x-parent-session-id": "parent-session", + "x-codex-turn-state": "parent-turn-state", + }, + affinity=proxy_service._AffinityPolicy( + key="parent-cache", + kind=proxy_service.StickySessionKind.PROMPT_CACHE, + ), + api_key=None, + request_model="gpt-5.4", + idle_ttl_seconds=120.0, + max_sessions=8, + ) + finally: + reset_request_scope_id(request_scope_token) + + assert resolved is created_session + assert resolved is not parent_session + create_call = create_session.await_args + assert create_call is not None + create_key = create_call.args[0] + assert isinstance(create_key, proxy_service._HTTPBridgeSessionKey) + assert create_key.affinity_key == "subagent:scope-subagent-no-alias:parent-cache" + + @pytest.mark.asyncio async def test_get_or_create_http_bridge_session_recovers_from_previous_response_id_mapping( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/unit/test_proxy_load_balancer_refresh.py b/tests/unit/test_proxy_load_balancer_refresh.py index 556373cd1d..4d53729b48 100644 --- a/tests/unit/test_proxy_load_balancer_refresh.py +++ b/tests/unit/test_proxy_load_balancer_refresh.py @@ -194,12 +194,14 @@ async def get_account_id( ) -> str | None: return None - async def upsert(self, key: str, account_id: str, *, kind: StickySessionKind) -> StickySession: + async def upsert( + self, key: str, account_id: str, *, kind: StickySessionKind, is_subagent: bool = False + ) -> StickySession: row = self._build_row(key, account_id, kind) self.upserts.append(row) return row - async def delete(self, key: str, *, kind: StickySessionKind | None = None) -> bool: + async def delete(self, key: str, *, kind: StickySessionKind | None = None, is_subagent: bool | None = None) -> bool: self.deletes.append((key, kind)) return False diff --git a/tests/unit/test_select_with_stickiness.py b/tests/unit/test_select_with_stickiness.py index b7d6f00bb6..f0229a0abc 100644 --- a/tests/unit/test_select_with_stickiness.py +++ b/tests/unit/test_select_with_stickiness.py @@ -65,12 +65,13 @@ def _make_sticky_repo(existing_account_id: str | None = None) -> AsyncMock: async def _invoke_stickiness( states: list[AccountState], - sticky_key: str, + sticky_key: str | None, sticky_repo: AsyncMock, *, - sticky_kind: StickySessionKind = StickySessionKind.PROMPT_CACHE, + sticky_kind: StickySessionKind | None = StickySessionKind.PROMPT_CACHE, reallocate_sticky: bool = False, sticky_max_age_seconds: int | None = 600, + sticky_is_subagent: bool = False, budget_threshold_pct: float = 95.0, secondary_budget_threshold_pct: float = 100.0, routing_strategy: RoutingStrategy = "usage_weighted", @@ -98,6 +99,7 @@ async def mock_repo_factory(): sticky_kind=sticky_kind, reallocate_sticky=reallocate_sticky, sticky_max_age_seconds=sticky_max_age_seconds, + sticky_is_subagent=sticky_is_subagent, budget_threshold_pct=budget_threshold_pct, secondary_budget_threshold_pct=secondary_budget_threshold_pct, prefer_earlier_reset_accounts=False, @@ -110,6 +112,51 @@ async def mock_repo_factory(): ) +@pytest.mark.asyncio +async def test_no_cache_subagent_skips_sticky_mapping() -> None: + repo = _make_sticky_repo() + + result = await _invoke_stickiness( + [_active("a")], + None, + repo, + sticky_kind=None, + sticky_max_age_seconds=None, + ) + + assert result.account is not None + assert result.account.account_id == "a" + repo.get_account_id.assert_not_called() + repo.upsert.assert_not_called() + repo.delete.assert_not_called() + + +@pytest.mark.asyncio +async def test_positive_subagent_ttl_uses_sticky_mapping() -> None: + repo = _make_sticky_repo() + + result = await _invoke_stickiness( + [_active("a")], + "subagent-cache-key", + repo, + sticky_max_age_seconds=120, + sticky_is_subagent=True, + ) + + assert result.account is not None + repo.get_account_id.assert_awaited_once_with( + "subagent-cache-key", + kind=StickySessionKind.PROMPT_CACHE, + max_age_seconds=120, + ) + repo.upsert.assert_awaited_once_with( + "subagent-cache-key", + "a", + kind=StickySessionKind.PROMPT_CACHE, + is_subagent=True, + ) + + # --------------------------------------------------------------------------- # Fix 1+3: sticky session is preserved when pinned account is temporarily down # --------------------------------------------------------------------------- @@ -178,7 +225,7 @@ async def test_fallback_overwrites_sticky_when_reallocate_sticky_true(): assert result.account is not None assert result.account.account_id == "b" repo.delete.assert_called_once() - repo.upsert.assert_called_once_with("key1", "b", kind=StickySessionKind.STICKY_THREAD) + repo.upsert.assert_called_once_with("key1", "b", kind=StickySessionKind.STICKY_THREAD, is_subagent=False) @pytest.mark.asyncio @@ -234,7 +281,7 @@ async def test_sticky_deleted_when_pinned_account_removed_from_pool(): assert result.account is not None assert result.account.account_id == "b" repo.delete.assert_called_once_with("key1", kind=StickySessionKind.PROMPT_CACHE) - repo.upsert.assert_called_once_with("key1", "b", kind=StickySessionKind.PROMPT_CACHE) + repo.upsert.assert_called_once_with("key1", "b", kind=StickySessionKind.PROMPT_CACHE, is_subagent=False) # --------------------------------------------------------------------------- @@ -259,7 +306,7 @@ async def test_all_accounts_exhausted_keeps_pinned_no_thrashing(): assert result.account is not None assert result.account.account_id == "a" repo.delete.assert_not_called() - repo.upsert.assert_called_once_with("key1", "a", kind=StickySessionKind.PROMPT_CACHE) + repo.upsert.assert_called_once_with("key1", "a", kind=StickySessionKind.PROMPT_CACHE, is_subagent=False) @pytest.mark.asyncio @@ -279,7 +326,7 @@ async def test_pool_exhausted_but_better_candidate_exists_reallocates(): assert result.account is not None assert result.account.account_id == "b" repo.delete.assert_called_once_with("key1", kind=StickySessionKind.PROMPT_CACHE) - repo.upsert.assert_called_once_with("key1", "b", kind=StickySessionKind.PROMPT_CACHE) + repo.upsert.assert_called_once_with("key1", "b", kind=StickySessionKind.PROMPT_CACHE, is_subagent=False) @pytest.mark.asyncio @@ -301,7 +348,7 @@ async def test_round_robin_pool_health_check_prefers_budget_safe_candidate(): assert result.account is not None assert result.account.account_id == "b" repo.delete.assert_called_once_with("key-round-robin", kind=StickySessionKind.PROMPT_CACHE) - repo.upsert.assert_called_once_with("key-round-robin", "b", kind=StickySessionKind.PROMPT_CACHE) + repo.upsert.assert_called_once_with("key-round-robin", "b", kind=StickySessionKind.PROMPT_CACHE, is_subagent=False) @pytest.mark.asyncio @@ -364,7 +411,9 @@ async def test_capacity_weighted_pool_health_check_prefers_budget_safe_candidate assert result.account is not None assert result.account.account_id == "b" repo.delete.assert_called_once_with("key-capacity-weighted", kind=StickySessionKind.PROMPT_CACHE) - repo.upsert.assert_called_once_with("key-capacity-weighted", "b", kind=StickySessionKind.PROMPT_CACHE) + repo.upsert.assert_called_once_with( + "key-capacity-weighted", "b", kind=StickySessionKind.PROMPT_CACHE, is_subagent=False + ) @pytest.mark.asyncio @@ -382,7 +431,7 @@ async def test_pool_exhausted_single_account_keeps_pinned(): assert result.account is not None assert result.account.account_id == "a" repo.delete.assert_not_called() - repo.upsert.assert_called_once_with("key1", "a", kind=StickySessionKind.PROMPT_CACHE) + repo.upsert.assert_called_once_with("key1", "a", kind=StickySessionKind.PROMPT_CACHE, is_subagent=False) @pytest.mark.asyncio @@ -402,7 +451,7 @@ async def test_pool_exhausted_with_custom_threshold(): assert result.account is not None assert result.account.account_id == "a" repo.delete.assert_not_called() - repo.upsert.assert_called_once_with("key1", "a", kind=StickySessionKind.PROMPT_CACHE) + repo.upsert.assert_called_once_with("key1", "a", kind=StickySessionKind.PROMPT_CACHE, is_subagent=False) @pytest.mark.asyncio @@ -421,7 +470,7 @@ async def test_pool_exhausted_candidate_with_none_usage_triggers_reallocation(): assert result.account is not None assert result.account.account_id == "b" repo.delete.assert_called_once_with("key1", kind=StickySessionKind.PROMPT_CACHE) - repo.upsert.assert_called_once_with("key1", "b", kind=StickySessionKind.PROMPT_CACHE) + repo.upsert.assert_called_once_with("key1", "b", kind=StickySessionKind.PROMPT_CACHE, is_subagent=False) @pytest.mark.asyncio @@ -440,7 +489,7 @@ async def test_first_request_creates_sticky_mapping(): assert result.account is not None assert result.account.account_id == "a" - repo.upsert.assert_called_once_with("key1", "a", kind=StickySessionKind.PROMPT_CACHE) + repo.upsert.assert_called_once_with("key1", "a", kind=StickySessionKind.PROMPT_CACHE, is_subagent=False) @pytest.mark.asyncio @@ -477,7 +526,9 @@ async def test_first_sticky_request_honors_relative_availability_tuning(): assert result.account is not None assert result.account.account_id == "a" - repo.upsert.assert_called_once_with("key-relative-availability", "a", kind=StickySessionKind.PROMPT_CACHE) + repo.upsert.assert_called_once_with( + "key-relative-availability", "a", kind=StickySessionKind.PROMPT_CACHE, is_subagent=False + ) # --------------------------------------------------------------------------- @@ -503,7 +554,7 @@ async def test_grace_period_returns_pinned_when_reset_imminent(): assert result.account is not None assert result.account.account_id == "a" - repo.upsert.assert_called_once_with("key1", "a", kind=StickySessionKind.PROMPT_CACHE) + repo.upsert.assert_called_once_with("key1", "a", kind=StickySessionKind.PROMPT_CACHE, is_subagent=False) @pytest.mark.asyncio @@ -523,7 +574,7 @@ async def test_grace_period_keeps_rate_limited_pinned_account_even_when_usage_is assert result.account is not None assert result.account.account_id == "a" repo.delete.assert_not_called() - repo.upsert.assert_called_once_with("key1", "a", kind=StickySessionKind.PROMPT_CACHE) + repo.upsert.assert_called_once_with("key1", "a", kind=StickySessionKind.PROMPT_CACHE, is_subagent=False) @pytest.mark.asyncio @@ -640,7 +691,7 @@ async def test_paused_pinned_account_persists_fallback(): assert result.account is not None assert result.account.account_id == "b" - repo.upsert.assert_called_once_with("key1", "b", kind=StickySessionKind.PROMPT_CACHE) + repo.upsert.assert_called_once_with("key1", "b", kind=StickySessionKind.PROMPT_CACHE, is_subagent=False) @pytest.mark.asyncio @@ -659,7 +710,7 @@ async def test_reauth_required_pinned_account_persists_fallback(): assert result.account is not None assert result.account.account_id == "b" - repo.upsert.assert_called_once_with("key1", "b", kind=StickySessionKind.PROMPT_CACHE) + repo.upsert.assert_called_once_with("key1", "b", kind=StickySessionKind.PROMPT_CACHE, is_subagent=False) # --------------------------------------------------------------------------- @@ -712,7 +763,7 @@ async def test_codex_session_persists_fallback_during_outage(): assert result.account is not None assert result.account.account_id == "b" - repo.upsert.assert_called_once_with("session_123", "b", kind=StickySessionKind.CODEX_SESSION) + repo.upsert.assert_called_once_with("session_123", "b", kind=StickySessionKind.CODEX_SESSION, is_subagent=False) @pytest.mark.asyncio @@ -734,7 +785,7 @@ async def test_rate_limit_far_away_does_not_reallocate_codex_session_affinity(): assert result.account is not None assert result.account.account_id == "b" repo.delete.assert_not_called() - repo.upsert.assert_called_once_with("session_123", "b", kind=StickySessionKind.CODEX_SESSION) + repo.upsert.assert_called_once_with("session_123", "b", kind=StickySessionKind.CODEX_SESSION, is_subagent=False) @pytest.mark.asyncio @@ -775,7 +826,7 @@ async def test_budget_exhaustion_triggers_reallocation(): assert result.account is not None assert result.account.account_id == "b" repo.delete.assert_called_once_with("key1", kind=StickySessionKind.PROMPT_CACHE) - repo.upsert.assert_called_once_with("key1", "b", kind=StickySessionKind.PROMPT_CACHE) + repo.upsert.assert_called_once_with("key1", "b", kind=StickySessionKind.PROMPT_CACHE, is_subagent=False) @pytest.mark.asyncio @@ -795,7 +846,7 @@ async def test_budget_threshold_80_triggers_at_85_percent(): assert result.account is not None assert result.account.account_id == "b" repo.delete.assert_called_once_with("key1", kind=StickySessionKind.PROMPT_CACHE) - repo.upsert.assert_called_once_with("key1", "b", kind=StickySessionKind.PROMPT_CACHE) + repo.upsert.assert_called_once_with("key1", "b", kind=StickySessionKind.PROMPT_CACHE, is_subagent=False) @pytest.mark.asyncio @@ -815,7 +866,7 @@ async def test_budget_threshold_95_no_reallocation_at_85_percent(): assert result.account is not None assert result.account.account_id == "a" repo.delete.assert_not_called() - repo.upsert.assert_called_once_with("key1", "a", kind=StickySessionKind.PROMPT_CACHE) + repo.upsert.assert_called_once_with("key1", "a", kind=StickySessionKind.PROMPT_CACHE, is_subagent=False) @pytest.mark.asyncio @@ -837,7 +888,9 @@ async def test_budget_threshold_reallocates_codex_session_affinity(): assert result.account is not None assert result.account.account_id == "b" repo.delete.assert_called_once_with("codex-session-123", kind=StickySessionKind.CODEX_SESSION) - repo.upsert.assert_called_once_with("codex-session-123", "b", kind=StickySessionKind.CODEX_SESSION) + repo.upsert.assert_called_once_with( + "codex-session-123", "b", kind=StickySessionKind.CODEX_SESSION, is_subagent=False + ) @pytest.mark.asyncio @@ -863,7 +916,7 @@ async def test_budget_threshold_reallocates_sticky_thread_affinity(): assert result.account is not None assert result.account.account_id == "b" repo.delete.assert_called_once_with("thread-X", kind=StickySessionKind.STICKY_THREAD) - repo.upsert.assert_called_once_with("thread-X", "b", kind=StickySessionKind.STICKY_THREAD) + repo.upsert.assert_called_once_with("thread-X", "b", kind=StickySessionKind.STICKY_THREAD, is_subagent=False) @pytest.mark.asyncio @@ -885,7 +938,9 @@ async def test_budget_threshold_reallocates_to_primary_safe_secondary_pressured_ assert result.account is not None assert result.account.account_id == "b" repo.delete.assert_called_once_with("codex-session-123", kind=StickySessionKind.CODEX_SESSION) - repo.upsert.assert_called_once_with("codex-session-123", "b", kind=StickySessionKind.CODEX_SESSION) + repo.upsert.assert_called_once_with( + "codex-session-123", "b", kind=StickySessionKind.CODEX_SESSION, is_subagent=False + ) @pytest.mark.asyncio @@ -988,7 +1043,9 @@ async def test_fresh_sticky_mapping_uses_normal_budget_gate(): assert result.account is not None assert result.account.account_id == "a" repo.delete.assert_not_called() - repo.upsert.assert_called_once_with("new-codex-session", "a", kind=StickySessionKind.CODEX_SESSION) + repo.upsert.assert_called_once_with( + "new-codex-session", "a", kind=StickySessionKind.CODEX_SESSION, is_subagent=False + ) @pytest.mark.asyncio @@ -1011,7 +1068,9 @@ async def test_secondary_budget_threshold_controls_sticky_reallocation(): assert result.account is not None assert result.account.account_id == "b" repo.delete.assert_called_once_with("codex-session-123", kind=StickySessionKind.CODEX_SESSION) - repo.upsert.assert_called_once_with("codex-session-123", "b", kind=StickySessionKind.CODEX_SESSION) + repo.upsert.assert_called_once_with( + "codex-session-123", "b", kind=StickySessionKind.CODEX_SESSION, is_subagent=False + ) @pytest.mark.asyncio @@ -1033,7 +1092,7 @@ async def test_rate_limit_far_away_triggers_reallocation(): assert result.account is not None assert result.account.account_id == "b" repo.delete.assert_called_once_with("key1", kind=StickySessionKind.PROMPT_CACHE) - repo.upsert.assert_called_once_with("key1", "b", kind=StickySessionKind.PROMPT_CACHE) + repo.upsert.assert_called_once_with("key1", "b", kind=StickySessionKind.PROMPT_CACHE, is_subagent=False) @pytest.mark.asyncio @@ -1057,4 +1116,4 @@ async def test_burn_first_reallocation_only_when_burn_first_is_selectable(): assert result.account is not None assert result.account.account_id == "a" repo.delete.assert_not_called() - repo.upsert.assert_called_once_with("key1", "a", kind=StickySessionKind.PROMPT_CACHE) + repo.upsert.assert_called_once_with("key1", "a", kind=StickySessionKind.PROMPT_CACHE, is_subagent=False) diff --git a/tests/unit/test_sticky_session_cleanup_scheduler.py b/tests/unit/test_sticky_session_cleanup_scheduler.py index 51d8ec870c..e7da88ba9b 100644 --- a/tests/unit/test_sticky_session_cleanup_scheduler.py +++ b/tests/unit/test_sticky_session_cleanup_scheduler.py @@ -39,6 +39,7 @@ async def test_cleanup_once_purges_prompt_cache_only(monkeypatch) -> None: dashboard_settings = SimpleNamespace( openai_cache_affinity_max_age_seconds=600, http_responses_session_bridge_prompt_cache_idle_ttl_seconds=600, + http_responses_session_bridge_subagent_prompt_cache_ttl_seconds=30, ) settings_repo = AsyncMock() @@ -84,7 +85,10 @@ async def __aexit__(self, *args): ): await scheduler._cleanup_once() - sticky_repo.purge_prompt_cache_before.assert_called_once() + assert sticky_repo.purge_prompt_cache_before.await_count == 2 + assert sticky_repo.purge_prompt_cache_before.await_args_list[0].kwargs["is_subagent"] is False + assert sticky_repo.purge_prompt_cache_before.await_args_list[1].kwargs["is_subagent"] is True + assert sticky_repo.purge_prompt_cache_before.await_args_list[1].kwargs["protect_active_bridge_mappings"] is True sticky_repo.purge_before.assert_not_called() bridge_repo.purge_closed_before.assert_called_once() bridge_repo.purge_abandoned_before.assert_called_once() @@ -96,6 +100,7 @@ async def test_cleanup_once_skips_bridge_purge_when_schema_is_not_ready(monkeypa dashboard_settings = SimpleNamespace( openai_cache_affinity_max_age_seconds=600, http_responses_session_bridge_prompt_cache_idle_ttl_seconds=600, + http_responses_session_bridge_subagent_prompt_cache_ttl_seconds=30, ) settings_repo = AsyncMock() @@ -145,7 +150,8 @@ async def __aexit__(self, *args): ): await scheduler._cleanup_once() - sticky_repo.purge_prompt_cache_before.assert_called_once() + assert sticky_repo.purge_prompt_cache_before.await_count == 2 + assert sticky_repo.purge_prompt_cache_before.await_args_list[1].kwargs["protect_active_bridge_mappings"] is True bridge_repo.purge_closed_before.assert_not_called() bridge_repo.purge_abandoned_before.assert_not_called() ring_service.purge_stale_before.assert_called_once() @@ -156,6 +162,7 @@ async def test_cleanup_once_purges_bridge_when_schema_exists_after_startup_flag_ dashboard_settings = SimpleNamespace( openai_cache_affinity_max_age_seconds=600, http_responses_session_bridge_prompt_cache_idle_ttl_seconds=600, + http_responses_session_bridge_subagent_prompt_cache_ttl_seconds=30, ) settings_repo = AsyncMock() @@ -201,7 +208,8 @@ async def __aexit__(self, *args): ): await scheduler._cleanup_once() - sticky_repo.purge_prompt_cache_before.assert_called_once() + assert sticky_repo.purge_prompt_cache_before.await_count == 2 + assert sticky_repo.purge_prompt_cache_before.await_args_list[1].kwargs["protect_active_bridge_mappings"] is True bridge_repo.purge_closed_before.assert_called_once() bridge_repo.purge_abandoned_before.assert_called_once() ring_service.purge_stale_before.assert_called_once()