-
Notifications
You must be signed in to change notification settings - Fork 52.4k
feat(gateway): session model pool — concurrency-aware auto-assignment with auxiliary slot tracking #37519
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat(gateway): session model pool — concurrency-aware auto-assignment with auxiliary slot tracking #37519
Changes from all commits
136c8e6
d85358b
e2fd76a
18e65eb
063c677
ca4d81a
dba53db
85c8e79
da16faa
11b9ade
51827e3
847e8ca
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,6 +24,7 @@ | |
| # means UTF-8 stdio setup is skipped on Windows; POSIX is unaffected. | ||
| pass | ||
|
|
||
|
|
||
| import asyncio | ||
| import dataclasses | ||
| import inspect | ||
|
|
@@ -1876,6 +1877,12 @@ def __init__(self, config: Optional[GatewayConfig] = None): | |
| # Per-session reasoning effort overrides from /reasoning. | ||
| # Key: session_key, Value: parsed reasoning config dict. | ||
| self._session_reasoning_overrides: Dict[str, Dict[str, Any]] = {} | ||
| # Pool-assigned models (populated by SessionModelPool when enabled). | ||
| # Key: session_key, Value: dict with model/provider/context_length. | ||
| # These are weaker than manual /model overrides and are released | ||
| # when the session ends or when a manual override takes effect. | ||
| self._pool_assigned_models: Dict[str, Dict[str, Any]] = {} | ||
| self._pool_assigned_models_lock = threading.Lock() | ||
| self._kanban_notifier_profile = self._active_profile_name() | ||
| # Teams meeting pipeline runtime (bound later when msgraph_webhook adapter exists). | ||
| self._teams_pipeline_runtime = None | ||
|
|
@@ -2513,6 +2520,41 @@ def _recover_telegram_topic_thread_id( | |
| return None | ||
| return None | ||
|
|
||
| def _mark_pool_override(self, session_key: str) -> None: | ||
| """Mark a session as manually overridden in the pool. | ||
|
|
||
| Called after ``_release_pool_slot`` so the pool knows not to | ||
| reassign a model on the next turn. | ||
| """ | ||
| try: | ||
| from gateway.session_model_pool import get_session_model_pool as _get_pool | ||
| _p = _get_pool({}) | ||
| if _p: | ||
| _p.mark_manual_override(session_key) | ||
| except Exception as _exc: | ||
| logger.debug("SessionModelPool: failed to mark override for %s: %s", session_key, _exc) | ||
|
|
||
| def _release_pool_slot(self, session_key: str) -> None: | ||
| """Release a pool-assigned slot for a session (if one exists). | ||
|
|
||
| Centralizes the release pattern used in 3 places: session reset, | ||
| /model override, in-place model switch, and any other override path. | ||
| Thread-safe: acquires ``_pool_assigned_models_lock`` internally. | ||
| """ | ||
| try: | ||
| with self._pool_assigned_models_lock: | ||
| _old_pool = self._pool_assigned_models.pop(session_key, None) | ||
| if _old_pool: | ||
| from gateway.session_model_pool import get_session_model_pool as _get_pool | ||
| # The singleton ignores config after first init; pass {} | ||
| # to avoid unnecessary disk I/O via _load_gateway_config(). | ||
| _p = _get_pool({}) | ||
| if _p: | ||
| _p.release_session_slot(session_key) | ||
| except Exception as _exc: | ||
| logger.debug("SessionModelPool: failed to release slot for %s: %s", session_key, _exc) | ||
|
|
||
|
|
||
| def _resolve_session_agent_runtime( | ||
| self, | ||
| *, | ||
|
|
@@ -2535,6 +2577,42 @@ def _resolve_session_agent_runtime( | |
|
|
||
| model = _resolve_gateway_model(user_config) | ||
| override = self._session_model_overrides.get(resolved_session_key) if resolved_session_key else None | ||
| # Will be set by pool integration below if a pool-assigned provider | ||
| # is available. Applied after runtime_kwargs is created. | ||
| _pool_provider_override = None | ||
|
|
||
| # --- Session Model Pool integration --- | ||
| # If no manual override exists for this session, check whether the | ||
| # pool wants to assign a different model. Pool assignments are | ||
| # weaker than manual /model overrides and are released when the | ||
| # session ends or when a manual override takes effect. | ||
| if not override and resolved_session_key: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Current main resolves |
||
| try: | ||
| from gateway.session_model_pool import get_session_model_pool as _get_pool | ||
| _cfg = user_config if user_config else _load_gateway_config() | ||
| _pool = _get_pool(_cfg) | ||
| if _pool and _pool.enabled: | ||
| # Always call acquire_session_slot — it is thread-safe | ||
| # internally and refreshes the session timestamp on every | ||
| # call. This prevents premature eviction of active sessions | ||
| # and avoids a TOCTOU race between the local cache check | ||
| # and the pool's own state. | ||
| _pool_assign = _pool.acquire_session_slot(resolved_session_key) | ||
| if _pool_assign: | ||
| with self._pool_assigned_models_lock: | ||
| self._pool_assigned_models[resolved_session_key] = _pool_assign | ||
| model = _pool_assign.get("model", model) | ||
| # Stash pool provider so it can be injected into | ||
| # runtime_kwargs after _resolve_runtime_agent_kwargs(). | ||
| _pool_provider_override = _pool_assign.get("provider") | ||
| logger.debug( | ||
| "SessionModelPool: session=%s using pool-assigned model=%s provider=%s", | ||
| resolved_session_key, model, _pool_assign.get("provider"), | ||
| ) | ||
| except Exception as _pool_exc: | ||
| logger.debug("SessionModelPool lookup failed: %s", _pool_exc) | ||
| # --- End Session Model Pool integration --- | ||
|
|
||
| if override: | ||
| override_model = override.get("model", model) | ||
| override_runtime = { | ||
|
|
@@ -2577,6 +2655,10 @@ def _resolve_session_agent_runtime( | |
| resolved_session_key, model, runtime_kwargs | ||
| ) | ||
|
|
||
| # Apply pool-assigned provider (set during pool integration above). | ||
| if not override and _pool_provider_override: | ||
| runtime_kwargs["provider"] = _pool_provider_override | ||
|
|
||
| # When the config has no model.default but a provider was resolved | ||
| # (e.g. user ran `hermes auth add openai-codex` without `hermes model`), | ||
| # fall back to the provider's first catalog model so the API call | ||
|
|
@@ -8743,6 +8825,16 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g | |
| self._set_session_reasoning_override(session_key, None) | ||
| if hasattr(self, "_pending_model_notes"): | ||
| self._pending_model_notes.pop(session_key, None) | ||
| # Release pool-assigned slot for the reset session. | ||
| self._release_pool_slot(session_key) | ||
| # Clear manual override so the pool can reassign on next turn. | ||
| try: | ||
| from gateway.session_model_pool import get_session_model_pool as _get_pool_rst | ||
| _p_rst = _get_pool_rst({}) | ||
| if _p_rst: | ||
| _p_rst.clear_manual_override(session_key) | ||
| except Exception as _exc: | ||
| logger.debug("SessionModelPool: failed to clear override for %s: %s", session_key, _exc) | ||
|
|
||
This comment was marked as resolved.
Sorry, something went wrong. |
||
| # Emit session:start for new or auto-reset sessions | ||
| _is_new_session = ( | ||
|
|
@@ -10953,6 +11045,11 @@ async def _on_model_selected( | |
| "api_mode": result.api_mode, | ||
| } | ||
|
|
||
| # Release pool-assigned slot for this session if one | ||
| # exists — the manual override takes precedence. | ||
| self._release_pool_slot(_session_key) | ||
| self._mark_pool_override(_session_key) | ||
|
|
||
| # Evict cached agent so the next turn creates a fresh | ||
| # agent from the override rather than relying on the | ||
| # stale cache signature to trigger a rebuild. | ||
|
|
@@ -11107,6 +11204,11 @@ async def _on_model_selected( | |
| "api_mode": result.api_mode, | ||
| } | ||
|
|
||
| # Release pool-assigned slot for this session if one exists — | ||
| # the manual override takes precedence. | ||
| self._release_pool_slot(session_key) | ||
| self._mark_pool_override(session_key) | ||
|
|
||
| # Evict cached agent so the next turn creates a fresh agent from the | ||
| # override rather than relying on cache signature mismatch detection. | ||
| self._evict_cached_agent(session_key) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When no pool is configured,
_aux_pool_cacheremains_UNSET, so this returns a truthy sentinel rather than the documentedNone. The callers then evaluate_pool.enabledoutside their acquisitiontryblocks (lines 5017 and 5495), breaking auxiliary calls under the default-disabled configuration. ReturnNonehere when the cache is still_UNSET, and add disabled-pool sync/async coverage.