Repository navigation
fix(as_llm): close owned OpenAI clients safely #725
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,7 @@ | ||
| """LLM model wrappers for AgentScope.""" | ||
|
|
||
| import asyncio | ||
|
|
||
| from agentscope.credential import ( | ||
| AnthropicCredential, | ||
| CredentialBase, | ||
|
|
@@ -31,6 +33,32 @@ class BaseAsLLM(BaseComponent): | |
| def __init__(self, **kwargs) -> None: | ||
| super().__init__(**kwargs) | ||
| self._model: ChatModelBase | None = None | ||
| self._owns_model = False | ||
| self._closed = False | ||
|
|
||
| async def start(self) -> None: | ||
| """Start the wrapper and clean up a lazy model if startup fails early.""" | ||
| try: | ||
| await super().start() | ||
| except BaseException: | ||
| if self._owns_model and self._model is not None: | ||
| try: | ||
| await self._close() | ||
| except BaseException: | ||
| self.logger.exception("Failed to clean up model after startup failure") | ||
| raise | ||
| self._closed = False | ||
|
|
||
| async def close(self) -> None: | ||
| """Close started or pre-start resources while preserving base semantics elsewhere.""" | ||
| try: | ||
| async with self._lock: | ||
| if not self._is_started: | ||
| await self._close() | ||
| return | ||
| await super().close() | ||
| finally: | ||
| self._closed = True | ||
|
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. [P2] Prevent lazy model construction throughout shutdown
I reproduced this by setting an asyncio.Event in the first client's async close method and having another task wait for that event before reading |
||
|
|
||
| async def _start(self) -> None: | ||
| """Keep service startup independent of provider credentials.""" | ||
|
|
@@ -46,17 +74,21 @@ def model(self) -> ChatModelBase: | |
| def model(self, value: ChatModelBase | None) -> None: | ||
| """Preserve explicit model injection used by standalone consumers.""" | ||
| self._model = value | ||
| self._owns_model = False | ||
|
|
||
| def initialize_model(self) -> None: | ||
| """Construct the configured provider once, without making a remote request.""" | ||
| if self._model is not None: | ||
| return | ||
| if self._closed: | ||
| raise RuntimeError("Start the LLM component before accessing its model after close.") | ||
| kwargs = dict(self.kwargs) | ||
| credential = self.credential_cls(**kwargs.pop("credential", {})) | ||
| model_cls = credential.get_chat_model_class() | ||
| params_dict = kwargs.pop("parameters", None) | ||
| parameters = model_cls.Parameters(**params_dict) if params_dict else None | ||
| self._model = model_cls(credential=credential, parameters=parameters, **kwargs) | ||
| self._owns_model = True | ||
|
|
||
|
|
||
| @R.register("openai") | ||
|
|
@@ -65,6 +97,42 @@ class OpenAIAsLLM(BaseAsLLM): | |
|
|
||
| credential_cls = OpenAICredential | ||
|
|
||
| @BaseAsLLM.model.setter | ||
| def model(self, value: ChatModelBase | None) -> None: | ||
| """Reject replacement while an internally owned client is still live.""" | ||
| if value is self._model: | ||
| return | ||
| if self._owns_model: | ||
| raise RuntimeError("Close the OpenAI component before replacing its owned model.") | ||
| BaseAsLLM.model.fset(self, value) | ||
|
|
||
| async def _close(self) -> None: | ||
| """Close an internally owned async client without losing cancellation-safe cleanup.""" | ||
| if not self._owns_model or self._model is None: | ||
| return | ||
| cleanup = asyncio.create_task(self._release_model()) | ||
| cancellation: asyncio.CancelledError | None = None | ||
| while not cleanup.done(): | ||
| try: | ||
| await asyncio.shield(cleanup) | ||
| except asyncio.CancelledError as exc: | ||
| if cancellation is None: | ||
| cancellation = exc | ||
| cleanup.result() | ||
| if cancellation is not None: | ||
| raise cancellation | ||
|
|
||
| async def _release_model(self) -> None: | ||
| """Release the owned model and clear state even when provider close fails.""" | ||
| model = self._model | ||
| try: | ||
| client = getattr(model, "client", None) | ||
| if client is not None: | ||
| await client.close() | ||
| finally: | ||
| self._model = None | ||
| self._owns_model = False | ||
|
|
||
|
|
||
| @R.register("anthropic") | ||
| class AnthropicAsLLM(BaseAsLLM): | ||
|
|
||
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.
[P2] Do not clean up another lifecycle operation's model when a startup waiter is cancelled
super().start()can raise CancelledError while this invocation is still waiting to acquire_lock. This handler then calls_close()without owning that lock, even though another startup or shutdown operation owns the model. With a running component whose client close is blocked, queueing astart()and cancelling it causesclient.close()to run twice concurrently. With one startup blocked and a pre-created model, cancelling a second queued startup closes and discards the first startup's model while that startup is still running.Please distinguish cancellation before acquiring the lifecycle lock from failure within this startup invocation, and serialize rollback with the other lifecycle operations. A cancelled lock waiter should not tear down their resources.