mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-07 12:49:06 +00:00
feat: optional separate LangSmith key/endpoint for sandboxes (#1760)
* feat: optional separate LangSmith key/endpoint for sandboxes Adds optional SANDBOX_LANGSMITH_API_KEY / SANDBOX_LANGSMITH_ENDPOINT env overrides so sandboxes can run against a different LangSmith workspace than the one used for tracing and other API calls. Both fall back to the existing LANGSMITH_API_KEY / LANGSMITH_ENDPOINT resolution, so default behavior is unchanged. Applied to sandbox create/connect/delete, the GitHub proxy config, and repo snapshot builds. * feat: name langsmith sandboxes openswe-<b32(thread id)> New sandboxes get a deterministic, thread-traceable name derived from the LangGraph thread id (UUID base32-encoded lowercase, no padding), e.g. openswe-ci2fm6asgrlhqerukz4bencwpa. Falls back to an unset name when no thread id is present. Reconnect/delete still key off the server-assigned sandbox id. * fix: pass sandbox base URL (root + /v2/sandboxes) to langsmith SDK clients The SDK's api_endpoint is the sandbox base, not the API root — its methods append /boxes, /snapshots, etc. Passing the bare root sent calls to <root>/boxes instead of <root>/v2/sandboxes/boxes. Add _get_sandbox_api_endpoint for the SDK clients (async client, provider, snapshot SandboxClient) while the proxy-config PATCH keeps using the root. --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> (cherry picked from commit e826864dce0e56cda7decbc48254b1e13eef07e2) Co-authored-by: Ramon Nogueira <ramon.nogueira@langchain.dev>
This commit is contained in:
parent
6d34c42578
commit
a2794118ff
5 changed files with 180 additions and 12 deletions
|
|
@ -342,9 +342,9 @@ def _build_snapshot_sync(record: dict[str, Any], snapshot_name: str) -> tuple[st
|
||||||
"""
|
"""
|
||||||
from langsmith.sandbox import SandboxClient
|
from langsmith.sandbox import SandboxClient
|
||||||
|
|
||||||
from agent.integrations.langsmith import _get_langsmith_api_key
|
from agent.integrations.langsmith import _get_sandbox_api_endpoint, _get_sandbox_api_key
|
||||||
|
|
||||||
api_key = _get_langsmith_api_key()
|
api_key = _get_sandbox_api_key()
|
||||||
if not api_key:
|
if not api_key:
|
||||||
raise RuntimeError("LANGSMITH_API_KEY is not configured")
|
raise RuntimeError("LANGSMITH_API_KEY is not configured")
|
||||||
|
|
||||||
|
|
@ -356,7 +356,7 @@ def _build_snapshot_sync(record: dict[str, Any], snapshot_name: str) -> tuple[st
|
||||||
timeout = int(
|
timeout = int(
|
||||||
os.environ.get("REPO_SNAPSHOT_BUILD_TIMEOUT_SECONDS", DEFAULT_BUILD_TIMEOUT_SECONDS)
|
os.environ.get("REPO_SNAPSHOT_BUILD_TIMEOUT_SECONDS", DEFAULT_BUILD_TIMEOUT_SECONDS)
|
||||||
)
|
)
|
||||||
client = SandboxClient(api_key=api_key)
|
client = SandboxClient(api_key=api_key, api_endpoint=_get_sandbox_api_endpoint())
|
||||||
try:
|
try:
|
||||||
with tempfile.TemporaryDirectory(prefix="openswe-snapshot-") as context_dir:
|
with tempfile.TemporaryDirectory(prefix="openswe-snapshot-") as context_dir:
|
||||||
dockerfile_path = Path(context_dir) / "Dockerfile"
|
dockerfile_path = Path(context_dir) / "Dockerfile"
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from concurrent.futures import TimeoutError as FuturesTimeout
|
from concurrent.futures import TimeoutError as FuturesTimeout
|
||||||
|
|
@ -45,6 +46,69 @@ def _get_langsmith_api_key() -> str | None:
|
||||||
return os.environ.get("LANGSMITH_API_KEY") or os.environ.get("LANGSMITH_API_KEY_PROD")
|
return os.environ.get("LANGSMITH_API_KEY") or os.environ.get("LANGSMITH_API_KEY_PROD")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sandbox_api_key() -> str | None:
|
||||||
|
"""LangSmith API key for sandbox operations.
|
||||||
|
|
||||||
|
``SANDBOX_LANGSMITH_API_KEY`` lets sandboxes run against a different
|
||||||
|
LangSmith workspace than the one used for tracing/other API calls; falls
|
||||||
|
back to the standard key.
|
||||||
|
"""
|
||||||
|
return os.environ.get("SANDBOX_LANGSMITH_API_KEY") or _get_langsmith_api_key()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sandbox_endpoint() -> str:
|
||||||
|
"""LangSmith API **root** for sandbox operations.
|
||||||
|
|
||||||
|
Overridable via ``SANDBOX_LANGSMITH_ENDPOINT`` to pair with
|
||||||
|
``SANDBOX_LANGSMITH_API_KEY``; falls back to ``LANGSMITH_ENDPOINT``. This is
|
||||||
|
the bare root (e.g. ``https://api.smith.langchain.com``) used to build the
|
||||||
|
proxy-config URL; the SDK clients take :func:`_get_sandbox_api_endpoint`.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
os.environ.get("SANDBOX_LANGSMITH_ENDPOINT")
|
||||||
|
or os.environ.get("LANGSMITH_ENDPOINT")
|
||||||
|
or "https://api.smith.langchain.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sandbox_api_endpoint() -> str:
|
||||||
|
"""Sandbox API base URL for the langsmith SDK clients.
|
||||||
|
|
||||||
|
The SDK's ``api_endpoint`` is the sandbox base (root + ``/v2/sandboxes``),
|
||||||
|
not the API root, and its methods append ``/boxes``, ``/snapshots``, etc.
|
||||||
|
"""
|
||||||
|
root = _get_sandbox_endpoint().rstrip("/")
|
||||||
|
suffix = "/v2/sandboxes"
|
||||||
|
return root if root.endswith(suffix) else f"{root}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def _current_thread_id() -> str | None:
|
||||||
|
"""The LangGraph thread id for the active run, if any."""
|
||||||
|
try:
|
||||||
|
from langgraph.config import get_config
|
||||||
|
|
||||||
|
return get_config().get("configurable", {}).get("thread_id")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sandbox_name_for_thread(thread_id: str | None) -> str | None:
|
||||||
|
"""Deterministic, thread-traceable sandbox name: ``openswe-<b32(thread uuid)>``.
|
||||||
|
|
||||||
|
The thread id (a UUID) is base32-encoded lowercase without padding so the
|
||||||
|
name is a compact, hyphen-free token that maps back to the thread. Returns
|
||||||
|
None when the thread id is missing or not a UUID, leaving the name unset.
|
||||||
|
"""
|
||||||
|
if not thread_id:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
raw = uuid.UUID(thread_id).bytes
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
encoded = base64.b32encode(raw).decode("ascii").rstrip("=").lower()
|
||||||
|
return f"openswe-{encoded}"
|
||||||
|
|
||||||
|
|
||||||
def _parse_optional_int(name: str, default: int) -> int:
|
def _parse_optional_int(name: str, default: int) -> int:
|
||||||
raw = os.environ.get(name)
|
raw = os.environ.get(name)
|
||||||
if not raw:
|
if not raw:
|
||||||
|
|
@ -172,6 +236,22 @@ def _is_retryable_proxy_config_error(exc: BaseException) -> bool:
|
||||||
return isinstance(exc, httpx.TransportError)
|
return isinstance(exc, httpx.TransportError)
|
||||||
|
|
||||||
|
|
||||||
|
def _release_sandbox_name(client: SandboxClient, name: str | None) -> None:
|
||||||
|
"""Best-effort delete of any existing sandbox holding ``name``.
|
||||||
|
|
||||||
|
Sandbox names are unique in LangSmith and thread-deterministic, so the only
|
||||||
|
box that can hold this name is this thread's own — typically a dead one
|
||||||
|
(idle-stopped past its TTL) we're recreating. Provisioning is serialized per
|
||||||
|
thread, so this never races a live box. Without this, recreate would 409.
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
client.delete_sandbox(name)
|
||||||
|
except Exception as exc: # noqa: BLE001 - name is free if nothing to delete
|
||||||
|
logger.debug("No pre-existing sandbox %s to release (%s)", name, type(exc).__name__)
|
||||||
|
|
||||||
|
|
||||||
def _configure_github_proxy(sandbox_name: str, github_token: str) -> None:
|
def _configure_github_proxy(sandbox_name: str, github_token: str) -> None:
|
||||||
"""Configure sandbox proxy to inject GitHub auth for GitHub traffic.
|
"""Configure sandbox proxy to inject GitHub auth for GitHub traffic.
|
||||||
|
|
||||||
|
|
@ -183,11 +263,11 @@ def _configure_github_proxy(sandbox_name: str, github_token: str) -> None:
|
||||||
sandbox_name: The sandbox name/ID returned by the LangSmith API.
|
sandbox_name: The sandbox name/ID returned by the LangSmith API.
|
||||||
github_token: GitHub token to inject as Authorization header.
|
github_token: GitHub token to inject as Authorization header.
|
||||||
"""
|
"""
|
||||||
api_key = _get_langsmith_api_key()
|
api_key = _get_sandbox_api_key()
|
||||||
if not api_key:
|
if not api_key:
|
||||||
logger.warning("No LangSmith API key found, skipping GitHub proxy configuration")
|
logger.warning("No LangSmith API key found, skipping GitHub proxy configuration")
|
||||||
return
|
return
|
||||||
langsmith_endpoint = os.environ.get("LANGSMITH_ENDPOINT", "https://api.smith.langchain.com")
|
langsmith_endpoint = _get_sandbox_endpoint()
|
||||||
url = f"{langsmith_endpoint}/v2/sandboxes/boxes/{sandbox_name}"
|
url = f"{langsmith_endpoint}/v2/sandboxes/boxes/{sandbox_name}"
|
||||||
payload = {"proxy_config": {"rules": _github_proxy_rules(github_token)}}
|
payload = {"proxy_config": {"rules": _github_proxy_rules(github_token)}}
|
||||||
with httpx.Client(timeout=PROXY_CONFIG_TIMEOUT_SECONDS) as client:
|
with httpx.Client(timeout=PROXY_CONFIG_TIMEOUT_SECONDS) as client:
|
||||||
|
|
@ -249,7 +329,7 @@ def create_langsmith_sandbox(
|
||||||
Returns:
|
Returns:
|
||||||
SandboxBackendProtocol instance
|
SandboxBackendProtocol instance
|
||||||
"""
|
"""
|
||||||
api_key = _get_langsmith_api_key()
|
api_key = _get_sandbox_api_key()
|
||||||
(
|
(
|
||||||
default_snapshot_id,
|
default_snapshot_id,
|
||||||
fs_capacity_bytes,
|
fs_capacity_bytes,
|
||||||
|
|
@ -265,6 +345,7 @@ def create_langsmith_sandbox(
|
||||||
backend = provider.get_or_create(
|
backend = provider.get_or_create(
|
||||||
sandbox_id=sandbox_id,
|
sandbox_id=sandbox_id,
|
||||||
snapshot_id=effective_snapshot_id,
|
snapshot_id=effective_snapshot_id,
|
||||||
|
name=_sandbox_name_for_thread(_current_thread_id()),
|
||||||
fs_capacity_bytes=fs_capacity_bytes,
|
fs_capacity_bytes=fs_capacity_bytes,
|
||||||
vcpus=vcpus,
|
vcpus=vcpus,
|
||||||
mem_bytes=mem_bytes,
|
mem_bytes=mem_bytes,
|
||||||
|
|
@ -284,11 +365,9 @@ def _update_thread_sandbox_metadata(sandbox_id: str) -> None:
|
||||||
try:
|
try:
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from langgraph.config import get_config
|
|
||||||
from langgraph_sdk import get_client
|
from langgraph_sdk import get_client
|
||||||
|
|
||||||
config = get_config()
|
thread_id = _current_thread_id()
|
||||||
thread_id = config.get("configurable", {}).get("thread_id")
|
|
||||||
if not thread_id:
|
if not thread_id:
|
||||||
return
|
return
|
||||||
client = get_client()
|
client = get_client()
|
||||||
|
|
@ -454,11 +533,14 @@ class LangSmithProvider(SandboxProvider):
|
||||||
def __init__(self, api_key: str | None = None) -> None:
|
def __init__(self, api_key: str | None = None) -> None:
|
||||||
from langsmith import sandbox
|
from langsmith import sandbox
|
||||||
|
|
||||||
self._api_key = api_key or _get_langsmith_api_key()
|
self._api_key = api_key or _get_sandbox_api_key()
|
||||||
|
self._api_endpoint = _get_sandbox_api_endpoint()
|
||||||
if not self._api_key:
|
if not self._api_key:
|
||||||
msg = "LANGSMITH_API_KEY (or LANGSMITH_API_KEY_PROD) not set"
|
msg = "LANGSMITH_API_KEY (or LANGSMITH_API_KEY_PROD) not set"
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
self._client: SandboxClient = sandbox.SandboxClient(api_key=self._api_key)
|
self._client: SandboxClient = sandbox.SandboxClient(
|
||||||
|
api_key=self._api_key, api_endpoint=self._api_endpoint
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_startup_config(cls) -> None:
|
def validate_startup_config(cls) -> None:
|
||||||
|
|
@ -499,6 +581,7 @@ class LangSmithProvider(SandboxProvider):
|
||||||
sandbox_id: str | None = None,
|
sandbox_id: str | None = None,
|
||||||
timeout: int = 180,
|
timeout: int = 180,
|
||||||
snapshot_id: str | None = None,
|
snapshot_id: str | None = None,
|
||||||
|
name: str | None = None,
|
||||||
fs_capacity_bytes: int | None = None,
|
fs_capacity_bytes: int | None = None,
|
||||||
vcpus: int | None = None,
|
vcpus: int | None = None,
|
||||||
mem_bytes: int | None = None,
|
mem_bytes: int | None = None,
|
||||||
|
|
@ -523,10 +606,12 @@ class LangSmithProvider(SandboxProvider):
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
|
|
||||||
_install_create_extra_fields(self._client, _get_sandbox_create_extra_fields())
|
_install_create_extra_fields(self._client, _get_sandbox_create_extra_fields())
|
||||||
|
_release_sandbox_name(self._client, name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sandbox = self._client.create_sandbox(
|
sandbox = self._client.create_sandbox(
|
||||||
snapshot_id=snapshot_id,
|
snapshot_id=snapshot_id,
|
||||||
|
name=name,
|
||||||
fs_capacity_bytes=fs_capacity_bytes,
|
fs_capacity_bytes=fs_capacity_bytes,
|
||||||
vcpus=vcpus,
|
vcpus=vcpus,
|
||||||
mem_bytes=mem_bytes,
|
mem_bytes=mem_bytes,
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,8 @@ Set the `SANDBOX_TYPE` environment variable to switch providers. Each provider h
|
||||||
|
|
||||||
> **Warning**: `local` runs commands directly on your host with no sandboxing. Only use for local development with human-in-the-loop enabled.
|
> **Warning**: `local` runs commands directly on your host with no sandboxing. Only use for local development with human-in-the-loop enabled.
|
||||||
|
|
||||||
|
For `langsmith`, sandboxes default to the same LangSmith credentials as tracing. To run sandboxes against a **different** LangSmith workspace, set `SANDBOX_LANGSMITH_API_KEY` (falls back to `LANGSMITH_API_KEY` / `LANGSMITH_API_KEY_PROD`) and optionally `SANDBOX_LANGSMITH_ENDPOINT` (falls back to `LANGSMITH_ENDPOINT`). These apply to sandbox create/connect/delete, the GitHub proxy config, and repo snapshot builds — the `DEFAULT_SANDBOX_SNAPSHOT_ID` must exist in whichever workspace these credentials point at.
|
||||||
|
|
||||||
### Adding a new sandbox provider
|
### Adding a new sandbox provider
|
||||||
|
|
||||||
1. **Create an integration file** at `agent/integrations/my_provider.py` with a factory function matching this signature:
|
1. **Create an integration file** at `agent/integrations/my_provider.py` with a factory function matching this signature:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
"""Tests for LangSmith sandbox env-var configuration parsing."""
|
"""Tests for LangSmith sandbox env-var configuration parsing."""
|
||||||
|
|
||||||
from unittest.mock import patch
|
import base64
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -11,12 +13,62 @@ from agent.integrations.langsmith import (
|
||||||
DEFAULT_SANDBOX_VCPUS,
|
DEFAULT_SANDBOX_VCPUS,
|
||||||
DEFAULT_SNAPSHOT_FS_CAPACITY_BYTES,
|
DEFAULT_SNAPSHOT_FS_CAPACITY_BYTES,
|
||||||
LangSmithProvider,
|
LangSmithProvider,
|
||||||
|
_get_sandbox_api_endpoint,
|
||||||
_get_sandbox_create_extra_fields,
|
_get_sandbox_create_extra_fields,
|
||||||
_get_sandbox_snapshot_config,
|
_get_sandbox_snapshot_config,
|
||||||
_install_create_extra_fields,
|
_install_create_extra_fields,
|
||||||
|
_release_sandbox_name,
|
||||||
|
_sandbox_name_for_thread,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sandbox_api_endpoint_appends_v2_sandboxes() -> None:
|
||||||
|
with patch.dict("os.environ", {"LANGSMITH_ENDPOINT": "https://eu.smith.langchain.com"}):
|
||||||
|
assert _get_sandbox_api_endpoint() == "https://eu.smith.langchain.com/v2/sandboxes"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sandbox_api_endpoint_no_double_suffix() -> None:
|
||||||
|
with patch.dict(
|
||||||
|
"os.environ",
|
||||||
|
{"SANDBOX_LANGSMITH_ENDPOINT": "https://x.smith.langchain.com/v2/sandboxes"},
|
||||||
|
):
|
||||||
|
assert _get_sandbox_api_endpoint() == "https://x.smith.langchain.com/v2/sandboxes"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sandbox_name_for_thread_encodes_uuid() -> None:
|
||||||
|
thread_id = "12345678-1234-5678-1234-567812345678"
|
||||||
|
name = _sandbox_name_for_thread(thread_id)
|
||||||
|
assert name is not None
|
||||||
|
prefix, _, encoded = name.partition("-")
|
||||||
|
assert prefix == "openswe"
|
||||||
|
assert encoded == encoded.lower()
|
||||||
|
assert "=" not in encoded and "-" not in encoded
|
||||||
|
# Round-trips back to the original UUID.
|
||||||
|
padded = encoded.upper() + "=" * (-len(encoded) % 8)
|
||||||
|
assert uuid.UUID(bytes=base64.b32decode(padded)) == uuid.UUID(thread_id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sandbox_name_for_thread_none_or_invalid() -> None:
|
||||||
|
assert _sandbox_name_for_thread(None) is None
|
||||||
|
assert _sandbox_name_for_thread("not-a-uuid") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_sandbox_name_deletes_stale_box() -> None:
|
||||||
|
client = MagicMock()
|
||||||
|
_release_sandbox_name(client, "openswe-abc")
|
||||||
|
client.delete_sandbox.assert_called_once_with("openswe-abc")
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_sandbox_name_swallows_missing_and_skips_none() -> None:
|
||||||
|
client = MagicMock()
|
||||||
|
client.delete_sandbox.side_effect = RuntimeError("not found")
|
||||||
|
_release_sandbox_name(client, "openswe-abc") # must not raise
|
||||||
|
|
||||||
|
client.delete_sandbox.reset_mock(side_effect=True)
|
||||||
|
_release_sandbox_name(client, None)
|
||||||
|
client.delete_sandbox.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_defaults_when_env_unset() -> None:
|
def test_defaults_when_env_unset() -> None:
|
||||||
with patch.dict(
|
with patch.dict(
|
||||||
"os.environ",
|
"os.environ",
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,35 @@ class TestConfigureGithubProxy:
|
||||||
headers = mock_client.patch.call_args.kwargs["headers"]
|
headers = mock_client.patch.call_args.kwargs["headers"]
|
||||||
assert headers == {"X-API-Key": "my-api-key"}
|
assert headers == {"X-API-Key": "my-api-key"}
|
||||||
|
|
||||||
|
def test_sandbox_overrides_take_precedence(self) -> None:
|
||||||
|
"""SANDBOX_LANGSMITH_* override the shared key/endpoint for the proxy call."""
|
||||||
|
with (
|
||||||
|
patch("agent.integrations.langsmith.httpx.Client") as mock_client_cls,
|
||||||
|
patch.dict(
|
||||||
|
"os.environ",
|
||||||
|
{
|
||||||
|
"LANGSMITH_API_KEY": "shared-key",
|
||||||
|
"LANGSMITH_ENDPOINT": "https://shared.smith.langchain.com",
|
||||||
|
"SANDBOX_LANGSMITH_API_KEY": "sandbox-key",
|
||||||
|
"SANDBOX_LANGSMITH_ENDPOINT": "https://sandbox.smith.langchain.com",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_client.patch.return_value = mock_response
|
||||||
|
mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
|
||||||
|
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
|
||||||
|
_configure_github_proxy("sandbox-abc", "token")
|
||||||
|
|
||||||
|
assert (
|
||||||
|
mock_client.patch.call_args.args[0]
|
||||||
|
== "https://sandbox.smith.langchain.com/v2/sandboxes/boxes/sandbox-abc"
|
||||||
|
)
|
||||||
|
assert mock_client.patch.call_args.kwargs["headers"] == {"X-API-Key": "sandbox-key"}
|
||||||
|
|
||||||
def test_retries_transient_http_error(self) -> None:
|
def test_retries_transient_http_error(self) -> None:
|
||||||
"""Transient proxy API errors should be retried on the same sandbox."""
|
"""Transient proxy API errors should be retried on the same sandbox."""
|
||||||
request = httpx.Request(
|
request = httpx.Request(
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue