diff --git a/skyvern/forge/forge_app.py b/skyvern/forge/forge_app.py index 8384e1d5..a38a9b14 100644 --- a/skyvern/forge/forge_app.py +++ b/skyvern/forge/forge_app.py @@ -9,6 +9,7 @@ from openai import AsyncAzureOpenAI, AsyncOpenAI from skyvern.config import Settings from skyvern.forge.agent import ForgeAgent from skyvern.forge.agent_functions import AgentFunction +from skyvern.forge.forge_openai_client import ForgeAsyncHttpxClientWrapper from skyvern.forge.sdk.api.azure import AzureClientFactory from skyvern.forge.sdk.api.llm.api_handler_factory import LLMAPIHandlerFactory from skyvern.forge.sdk.api.llm.models import LLMAPIHandler @@ -95,7 +96,10 @@ def create_forge_app() -> ForgeApp: app.EXPERIMENTATION_PROVIDER = NoOpExperimentationProvider() app.LLM_API_HANDLER = LLMAPIHandlerFactory.get_llm_api_handler(settings.LLM_KEY) - app.OPENAI_CLIENT = AsyncOpenAI(api_key=settings.OPENAI_API_KEY or "") + app.OPENAI_CLIENT = AsyncOpenAI( + api_key=settings.OPENAI_API_KEY or "", + http_client=ForgeAsyncHttpxClientWrapper(), + ) if settings.ENABLE_AZURE_CUA: app.OPENAI_CLIENT = AsyncAzureOpenAI( api_key=settings.AZURE_CUA_API_KEY, @@ -113,6 +117,7 @@ def create_forge_app() -> ForgeApp: app.UI_TARS_CLIENT = AsyncOpenAI( api_key=settings.VOLCENGINE_API_KEY, base_url=settings.VOLCENGINE_API_BASE, + http_client=ForgeAsyncHttpxClientWrapper(), ) app.SECONDARY_LLM_API_HANDLER = LLMAPIHandlerFactory.get_llm_api_handler( diff --git a/skyvern/forge/forge_openai_client.py b/skyvern/forge/forge_openai_client.py new file mode 100644 index 00000000..4d514566 --- /dev/null +++ b/skyvern/forge/forge_openai_client.py @@ -0,0 +1,24 @@ +import asyncio + +from openai import DefaultAsyncHttpxClient + + +class ForgeAsyncHttpxClientWrapper(DefaultAsyncHttpxClient): + """ + Wrapper around OpenAI's AsyncHttpxClientWrapper to mask teardown races. + + The upstream `__del__` checks `self.is_closed`, but during interpreter + shutdown httpx internals may already be None, which raises: + + AttributeError: 'NoneType' object has no attribute 'CLOSED' + + We defensively swallow that destructor error so shutdown logs stay clean. + """ + + def __del__(self) -> None: + try: + if self.is_closed: + return + asyncio.get_running_loop().create_task(self.aclose()) + except Exception: + pass diff --git a/skyvern/forge/sdk/api/llm/api_handler_factory.py b/skyvern/forge/sdk/api/llm/api_handler_factory.py index b53414e2..8fa3b1e1 100644 --- a/skyvern/forge/sdk/api/llm/api_handler_factory.py +++ b/skyvern/forge/sdk/api/llm/api_handler_factory.py @@ -18,6 +18,7 @@ from pydantic import BaseModel from skyvern.config import settings from skyvern.exceptions import SkyvernContextWindowExceededError from skyvern.forge import app +from skyvern.forge.forge_openai_client import ForgeAsyncHttpxClientWrapper from skyvern.forge.sdk.api.llm.config_registry import LLMConfigRegistry from skyvern.forge.sdk.api.llm.exceptions import ( DuplicateCustomLLMProviderError, @@ -1100,7 +1101,11 @@ class LLMCaller: self.openai_client = None if self.llm_key.startswith("openrouter/"): self.llm_key = self.llm_key.replace("openrouter/", "") - self.openai_client = AsyncOpenAI(api_key=settings.OPENROUTER_API_KEY, base_url=settings.OPENROUTER_API_BASE) + self.openai_client = AsyncOpenAI( + api_key=settings.OPENROUTER_API_KEY, + base_url=settings.OPENROUTER_API_BASE, + http_client=ForgeAsyncHttpxClientWrapper(), + ) def add_tool_result(self, tool_result: dict[str, Any]) -> None: self.current_tool_results.append(tool_result) @@ -1513,7 +1518,8 @@ class LLMCaller: if isinstance(response, UITarsResponse): ui_tars_usage = response.usage return LLMCallStats( - llm_cost=0, # TODO: calculate the cost according to the price: https://www.volcengine.com/docs/82379/1544106 + llm_cost=0, + # TODO: calculate the cost according to the price: https://www.volcengine.com/docs/82379/1544106 input_tokens=ui_tars_usage.get("prompt_tokens", 0), output_tokens=ui_tars_usage.get("completion_tokens", 0), cached_tokens=0, # only part of model support cached tokens