mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-03 01:03:26 +00:00
fix: proxy config restored the branch yogesh/GitHub auth proxy (#1173)
* feat: authenticate git operations via sandbox proxy instead of credential files * feat: authenticate git operations via sandbox proxy instead of credential files * feat: authenticate git operations via sandbox proxy instead of credential files * removing logger.info * formatting and linting * fix: resolve lint errors in server.py (imports, unused vars, undefined names) * feat: use opaque proxy headers for GitHub auth in sandbox * linting formatting and test changes * linting * Delete .claude directory * Delete tests/evals directory * fix: address PR review — guard missing tokens, quote shell paths, add proxy auth tests * fix: restore authorship, branch_name support, and installation token for PR creation * linitng * fix: move installation token fetch before commit, clean up dead proxy validation code * fix: drop github_token arg from sandbox creation, use generic create_sandbox factory with langsmith-only proxy config * fix: use _get_langsmith_api_key() for prod key fallback, warn when API key missing for proxy config * linting * linting * fix: resolve merge conflicts with main, adopt deepagents v0.5.0a4 LangSmithSandbox
This commit is contained in:
parent
9e5088b75a
commit
4d4f5fbfc7
9 changed files with 293 additions and 143 deletions
|
|
@ -2,16 +2,21 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
from deepagents.backends import LangSmithSandbox
|
from deepagents.backends import LangSmithSandbox
|
||||||
from deepagents.backends.protocol import SandboxBackendProtocol
|
from deepagents.backends.protocol import SandboxBackendProtocol
|
||||||
from langsmith.sandbox import SandboxClient, SandboxTemplate
|
from langsmith.sandbox import SandboxClient, SandboxTemplate
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _get_langsmith_api_key() -> str | None:
|
def _get_langsmith_api_key() -> str | None:
|
||||||
"""Get LangSmith API key from environment.
|
"""Get LangSmith API key from environment.
|
||||||
|
|
@ -29,14 +34,62 @@ def _get_sandbox_template_config() -> tuple[str | None, str | None]:
|
||||||
return template_name, template_image
|
return template_name, template_image
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_github_proxy(sandbox_name: str, github_token: str) -> None:
|
||||||
|
"""Configure sandbox proxy to inject GitHub auth for all github.com requests.
|
||||||
|
|
||||||
|
Uses the LangSmith proxy-config API to set up header injection so that
|
||||||
|
git operations (clone, pull, push) authenticate via the proxy rather than
|
||||||
|
writing credentials to disk in the sandbox.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sandbox_name: The sandbox name/ID returned by the LangSmith API.
|
||||||
|
github_token: GitHub token to inject as Authorization header.
|
||||||
|
"""
|
||||||
|
api_key = _get_langsmith_api_key()
|
||||||
|
if not api_key:
|
||||||
|
logger.warning("No LangSmith API key found, skipping GitHub proxy configuration")
|
||||||
|
return
|
||||||
|
langsmith_endpoint = os.environ.get("LANGSMITH_ENDPOINT", "https://api.smith.langchain.com")
|
||||||
|
url = f"{langsmith_endpoint}/v2/sandboxes/boxes/{sandbox_name}"
|
||||||
|
basic_auth = base64.b64encode(f"x-access-token:{github_token}".encode()).decode()
|
||||||
|
payload = {
|
||||||
|
"proxy_config": {
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"name": "github",
|
||||||
|
"match_hosts": ["github.com", "*.github.com"],
|
||||||
|
"headers": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"type": "opaque",
|
||||||
|
"value": f"Basic {basic_auth}",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with httpx.Client() as client:
|
||||||
|
response = client.patch(
|
||||||
|
url,
|
||||||
|
json=payload,
|
||||||
|
headers={"X-API-Key": api_key},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
logger.info("Configured GitHub proxy for sandbox %s", sandbox_name)
|
||||||
|
|
||||||
|
|
||||||
def create_langsmith_sandbox(
|
def create_langsmith_sandbox(
|
||||||
sandbox_id: str | None = None,
|
sandbox_id: str | None = None,
|
||||||
|
github_token: str | None = None,
|
||||||
) -> SandboxBackendProtocol:
|
) -> SandboxBackendProtocol:
|
||||||
"""Create or connect to a LangSmith sandbox without automatic cleanup.
|
"""Create or connect to a LangSmith sandbox without automatic cleanup.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
sandbox_id: Optional existing sandbox ID to connect to.
|
sandbox_id: Optional existing sandbox ID to connect to.
|
||||||
If None, creates a new sandbox.
|
If None, creates a new sandbox.
|
||||||
|
github_token: Optional GitHub token. Used to configure proxy auth on
|
||||||
|
new sandboxes. Ignored when connecting to an existing sandbox.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
SandboxBackendProtocol instance
|
SandboxBackendProtocol instance
|
||||||
|
|
@ -51,6 +104,10 @@ def create_langsmith_sandbox(
|
||||||
template_image=template_image,
|
template_image=template_image,
|
||||||
)
|
)
|
||||||
_update_thread_sandbox_metadata(backend.id)
|
_update_thread_sandbox_metadata(backend.id)
|
||||||
|
|
||||||
|
if sandbox_id is None and github_token:
|
||||||
|
_configure_github_proxy(backend.id, github_token)
|
||||||
|
|
||||||
return backend
|
return backend
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -108,7 +165,7 @@ class SandboxProvider(ABC):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_TEMPLATE_NAME = "open-swe"
|
DEFAULT_TEMPLATE_NAME = "open-swe-new"
|
||||||
DEFAULT_TEMPLATE_IMAGE = "python:3"
|
DEFAULT_TEMPLATE_IMAGE = "python:3"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -118,9 +175,9 @@ 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 os.environ.get("LANGSMITH_API_KEY")
|
self._api_key = api_key or _get_langsmith_api_key()
|
||||||
if not self._api_key:
|
if not self._api_key:
|
||||||
msg = "LANGSMITH_API_KEY environment variable 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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -160,9 +160,7 @@ async def open_pr_if_needed(
|
||||||
await asyncio.to_thread(git_add_all, sandbox_backend, repo_dir)
|
await asyncio.to_thread(git_add_all, sandbox_backend, repo_dir)
|
||||||
await asyncio.to_thread(git_commit, sandbox_backend, repo_dir, commit_message)
|
await asyncio.to_thread(git_commit, sandbox_backend, repo_dir, commit_message)
|
||||||
|
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(git_push, sandbox_backend, repo_dir, target_branch)
|
||||||
git_push, sandbox_backend, repo_dir, target_branch, installation_token
|
|
||||||
)
|
|
||||||
|
|
||||||
base_branch = await get_github_default_branch(repo_owner, repo_name, installation_token)
|
base_branch = await get_github_default_branch(repo_owner, repo_name, installation_token)
|
||||||
logger.info("Using base branch: %s", base_branch)
|
logger.info("Using base branch: %s", base_branch)
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ from deepagents import create_deep_agent
|
||||||
from deepagents.backends.protocol import SandboxBackendProtocol
|
from deepagents.backends.protocol import SandboxBackendProtocol
|
||||||
from langsmith.sandbox import SandboxClientError
|
from langsmith.sandbox import SandboxClientError
|
||||||
|
|
||||||
|
from .integrations.langsmith import _configure_github_proxy
|
||||||
from .middleware import (
|
from .middleware import (
|
||||||
ToolErrorMiddleware,
|
ToolErrorMiddleware,
|
||||||
check_message_queue_before_model,
|
check_message_queue_before_model,
|
||||||
|
|
@ -57,6 +58,7 @@ from .tools import (
|
||||||
web_search,
|
web_search,
|
||||||
)
|
)
|
||||||
from .utils.auth import resolve_github_token
|
from .utils.auth import resolve_github_token
|
||||||
|
from .utils.github_app import get_github_app_installation_token
|
||||||
from .utils.model import make_model
|
from .utils.model import make_model
|
||||||
from .utils.sandbox import create_sandbox
|
from .utils.sandbox import create_sandbox
|
||||||
|
|
||||||
|
|
@ -68,32 +70,28 @@ SANDBOX_POLL_INTERVAL = 1.0
|
||||||
|
|
||||||
from .utils.agents_md import read_agents_md_in_sandbox
|
from .utils.agents_md import read_agents_md_in_sandbox
|
||||||
from .utils.github import (
|
from .utils.github import (
|
||||||
_CRED_FILE_PATH,
|
|
||||||
cleanup_git_credentials,
|
|
||||||
git_current_branch,
|
|
||||||
git_has_uncommitted_changes,
|
git_has_uncommitted_changes,
|
||||||
git_pull_branch,
|
|
||||||
is_valid_git_repo,
|
is_valid_git_repo,
|
||||||
remove_directory,
|
remove_directory,
|
||||||
setup_git_credentials,
|
|
||||||
)
|
)
|
||||||
from .utils.sandbox_paths import aresolve_repo_dir, aresolve_sandbox_work_dir
|
from .utils.sandbox_paths import aresolve_repo_dir, aresolve_sandbox_work_dir
|
||||||
from .utils.sandbox_state import SANDBOX_BACKENDS, get_sandbox_id_from_metadata
|
from .utils.sandbox_state import SANDBOX_BACKENDS, get_sandbox_id_from_metadata
|
||||||
|
|
||||||
|
|
||||||
async def _clone_or_pull_repo_in_sandbox( # noqa: PLR0915
|
async def _clone_or_pull_repo_in_sandbox(
|
||||||
sandbox_backend: SandboxBackendProtocol,
|
sandbox_backend: SandboxBackendProtocol,
|
||||||
owner: str,
|
owner: str,
|
||||||
repo: str,
|
repo: str,
|
||||||
github_token: str | None = None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Clone a GitHub repo into the sandbox, or pull if it already exists.
|
"""Clone a GitHub repo into the sandbox, or pull if it already exists.
|
||||||
|
|
||||||
|
Authentication is handled by the sandbox proxy (configured at sandbox creation
|
||||||
|
time), so no token is needed here.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
sandbox_backend: The sandbox backend to execute commands in
|
sandbox_backend: The sandbox backend to execute commands in
|
||||||
owner: GitHub repo owner
|
owner: GitHub repo owner
|
||||||
repo: GitHub repo name
|
repo: GitHub repo name
|
||||||
github_token: GitHub access token (from agent auth or env var)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Path to the cloned/updated repo directory
|
Path to the cloned/updated repo directory
|
||||||
|
|
@ -101,18 +99,9 @@ async def _clone_or_pull_repo_in_sandbox( # noqa: PLR0915
|
||||||
logger.info("_clone_or_pull_repo_in_sandbox called for %s/%s", owner, repo)
|
logger.info("_clone_or_pull_repo_in_sandbox called for %s/%s", owner, repo)
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
token = github_token
|
|
||||||
if not token:
|
|
||||||
msg = "No GitHub token provided"
|
|
||||||
logger.error(msg)
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
work_dir = await aresolve_sandbox_work_dir(sandbox_backend)
|
work_dir = await aresolve_sandbox_work_dir(sandbox_backend)
|
||||||
repo_dir = await aresolve_repo_dir(sandbox_backend, repo)
|
repo_dir = await aresolve_repo_dir(sandbox_backend, repo)
|
||||||
clean_url = f"https://github.com/{owner}/{repo}.git"
|
clean_url = f"https://github.com/{owner}/{repo}.git"
|
||||||
cred_helper = shlex.quote(f"store --file={_CRED_FILE_PATH}")
|
|
||||||
safe_repo_dir = shlex.quote(repo_dir)
|
|
||||||
safe_clean_url = shlex.quote(clean_url)
|
|
||||||
|
|
||||||
logger.info("Resolved sandbox work dir to %s", work_dir)
|
logger.info("Resolved sandbox work dir to %s", work_dir)
|
||||||
|
|
||||||
|
|
@ -143,21 +132,10 @@ async def _clone_or_pull_repo_in_sandbox( # noqa: PLR0915
|
||||||
logger.info("Repo is clean, pulling latest changes from %s/%s", owner, repo)
|
logger.info("Repo is clean, pulling latest changes from %s/%s", owner, repo)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
current_branch = await loop.run_in_executor(
|
|
||||||
None, git_current_branch, sandbox_backend, repo_dir
|
|
||||||
)
|
|
||||||
if not current_branch:
|
|
||||||
msg = f"Failed to determine current branch for repo at {repo_dir}"
|
|
||||||
logger.error(msg)
|
|
||||||
raise RuntimeError(msg)
|
|
||||||
|
|
||||||
pull_result = await loop.run_in_executor(
|
pull_result = await loop.run_in_executor(
|
||||||
None,
|
None,
|
||||||
git_pull_branch,
|
sandbox_backend.execute,
|
||||||
sandbox_backend,
|
f"cd {shlex.quote(repo_dir)} && git pull origin $(git rev-parse --abbrev-ref HEAD)",
|
||||||
repo_dir,
|
|
||||||
current_branch,
|
|
||||||
token,
|
|
||||||
)
|
)
|
||||||
logger.debug("Git pull result: exit_code=%s", pull_result.exit_code)
|
logger.debug("Git pull result: exit_code=%s", pull_result.exit_code)
|
||||||
if pull_result.exit_code != 0:
|
if pull_result.exit_code != 0:
|
||||||
|
|
@ -174,19 +152,16 @@ async def _clone_or_pull_repo_in_sandbox( # noqa: PLR0915
|
||||||
return repo_dir
|
return repo_dir
|
||||||
|
|
||||||
logger.info("Cloning repo %s/%s to %s", owner, repo, repo_dir)
|
logger.info("Cloning repo %s/%s to %s", owner, repo, repo_dir)
|
||||||
await loop.run_in_executor(None, setup_git_credentials, sandbox_backend, token)
|
|
||||||
try:
|
try:
|
||||||
result = await loop.run_in_executor(
|
result = await loop.run_in_executor(
|
||||||
None,
|
None,
|
||||||
sandbox_backend.execute,
|
sandbox_backend.execute,
|
||||||
f"git -c credential.helper={cred_helper} clone {safe_clean_url} {safe_repo_dir}",
|
f"git clone {shlex.quote(clean_url)} {shlex.quote(repo_dir)}",
|
||||||
)
|
)
|
||||||
logger.debug("Git clone result: exit_code=%s", result.exit_code)
|
logger.debug("Git clone result: exit_code=%s", result.exit_code)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to execute git clone")
|
logger.exception("Failed to execute git clone")
|
||||||
raise
|
raise
|
||||||
finally:
|
|
||||||
await loop.run_in_executor(None, cleanup_git_credentials, sandbox_backend)
|
|
||||||
|
|
||||||
if result.exit_code != 0:
|
if result.exit_code != 0:
|
||||||
msg = f"Failed to clone repo {owner}/{repo}: {result.output}"
|
msg = f"Failed to clone repo {owner}/{repo}: {result.output}"
|
||||||
|
|
@ -197,17 +172,35 @@ async def _clone_or_pull_repo_in_sandbox( # noqa: PLR0915
|
||||||
return repo_dir
|
return repo_dir
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_sandbox_with_proxy() -> SandboxBackendProtocol:
|
||||||
|
"""Create a new sandbox with GitHub proxy auth configured.
|
||||||
|
|
||||||
|
Uses create_sandbox (generic factory) so non-langsmith providers still work.
|
||||||
|
For langsmith sandboxes, configures the proxy with the installation token.
|
||||||
|
"""
|
||||||
|
sandbox_backend = await asyncio.to_thread(create_sandbox)
|
||||||
|
|
||||||
|
sandbox_type = os.getenv("SANDBOX_TYPE", "langsmith")
|
||||||
|
if sandbox_type == "langsmith":
|
||||||
|
installation_token = await get_github_app_installation_token()
|
||||||
|
if not installation_token:
|
||||||
|
msg = "Cannot configure proxy: GitHub App installation token is unavailable"
|
||||||
|
logger.error(msg)
|
||||||
|
raise ValueError(msg)
|
||||||
|
await asyncio.to_thread(_configure_github_proxy, sandbox_backend.id, installation_token)
|
||||||
|
|
||||||
|
return sandbox_backend
|
||||||
|
|
||||||
|
|
||||||
async def _recreate_sandbox(
|
async def _recreate_sandbox(
|
||||||
thread_id: str,
|
thread_id: str,
|
||||||
repo_owner: str,
|
repo_owner: str,
|
||||||
repo_name: str,
|
repo_name: str,
|
||||||
*,
|
|
||||||
github_token: str | None,
|
|
||||||
) -> tuple[SandboxBackendProtocol, str]:
|
) -> tuple[SandboxBackendProtocol, str]:
|
||||||
"""Recreate a sandbox and clone the repo after a connection failure.
|
"""Recreate a sandbox and clone the repo after a connection failure.
|
||||||
|
|
||||||
Clears the stale cache entry, sets the SANDBOX_CREATING sentinel,
|
Clears the stale cache entry, sets the SANDBOX_CREATING sentinel,
|
||||||
creates a fresh sandbox, and clones the repo.
|
creates a fresh sandbox (with proxy auth configured), and clones the repo.
|
||||||
"""
|
"""
|
||||||
SANDBOX_BACKENDS.pop(thread_id, None)
|
SANDBOX_BACKENDS.pop(thread_id, None)
|
||||||
await client.threads.update(
|
await client.threads.update(
|
||||||
|
|
@ -215,10 +208,8 @@ async def _recreate_sandbox(
|
||||||
metadata={"sandbox_id": SANDBOX_CREATING},
|
metadata={"sandbox_id": SANDBOX_CREATING},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
sandbox_backend = await asyncio.to_thread(create_sandbox)
|
sandbox_backend = await _create_sandbox_with_proxy()
|
||||||
repo_dir = await _clone_or_pull_repo_in_sandbox(
|
repo_dir = await _clone_or_pull_repo_in_sandbox(sandbox_backend, repo_owner, repo_name)
|
||||||
sandbox_backend, repo_owner, repo_name, github_token
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to recreate sandbox after connection failure")
|
logger.exception("Failed to recreate sandbox after connection failure")
|
||||||
await client.threads.update(thread_id=thread_id, metadata={"sandbox_id": None})
|
await client.threads.update(thread_id=thread_id, metadata={"sandbox_id": None})
|
||||||
|
|
@ -296,7 +287,7 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915
|
||||||
logger.info("Pulling latest changes for repo %s/%s", repo_owner, repo_name)
|
logger.info("Pulling latest changes for repo %s/%s", repo_owner, repo_name)
|
||||||
try:
|
try:
|
||||||
repo_dir = await _clone_or_pull_repo_in_sandbox(
|
repo_dir = await _clone_or_pull_repo_in_sandbox(
|
||||||
sandbox_backend, repo_owner, repo_name, github_token
|
sandbox_backend, repo_owner, repo_name
|
||||||
)
|
)
|
||||||
except SandboxClientError:
|
except SandboxClientError:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
@ -304,7 +295,7 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915
|
||||||
thread_id,
|
thread_id,
|
||||||
)
|
)
|
||||||
sandbox_backend, repo_dir = await _recreate_sandbox(
|
sandbox_backend, repo_dir = await _recreate_sandbox(
|
||||||
thread_id, repo_owner, repo_name, github_token=github_token
|
thread_id, repo_owner, repo_name
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to pull repo in cached sandbox")
|
logger.exception("Failed to pull repo in cached sandbox")
|
||||||
|
|
@ -315,15 +306,14 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915
|
||||||
await client.threads.update(thread_id=thread_id, metadata={"sandbox_id": SANDBOX_CREATING})
|
await client.threads.update(thread_id=thread_id, metadata={"sandbox_id": SANDBOX_CREATING})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Create sandbox without context manager cleanup (sandbox persists)
|
sandbox_backend = await _create_sandbox_with_proxy()
|
||||||
sandbox_backend = await asyncio.to_thread(create_sandbox)
|
|
||||||
logger.info("Sandbox created: %s", sandbox_backend.id)
|
logger.info("Sandbox created: %s", sandbox_backend.id)
|
||||||
|
|
||||||
repo_dir = None
|
repo_dir = None
|
||||||
if repo_owner and repo_name:
|
if repo_owner and repo_name:
|
||||||
logger.info("Cloning repo %s/%s into sandbox", repo_owner, repo_name)
|
logger.info("Cloning repo %s/%s into sandbox", repo_owner, repo_name)
|
||||||
repo_dir = await _clone_or_pull_repo_in_sandbox(
|
repo_dir = await _clone_or_pull_repo_in_sandbox(
|
||||||
sandbox_backend, repo_owner, repo_name, github_token
|
sandbox_backend, repo_owner, repo_name
|
||||||
)
|
)
|
||||||
logger.info("Repo cloned to %s", repo_dir)
|
logger.info("Repo cloned to %s", repo_dir)
|
||||||
|
|
||||||
|
|
@ -342,19 +332,19 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915
|
||||||
else:
|
else:
|
||||||
logger.info("Connecting to existing sandbox %s", sandbox_id)
|
logger.info("Connecting to existing sandbox %s", sandbox_id)
|
||||||
try:
|
try:
|
||||||
# Connect to existing sandbox without context manager cleanup
|
# Connect to existing sandbox (no proxy reconfiguration needed)
|
||||||
sandbox_backend = await asyncio.to_thread(create_sandbox, sandbox_id)
|
sandbox_backend = await asyncio.to_thread(create_sandbox, sandbox_id)
|
||||||
logger.info("Connected to existing sandbox %s", sandbox_id)
|
logger.info("Connected to existing sandbox %s", sandbox_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to connect to existing sandbox %s, creating new one", sandbox_id)
|
logger.warning("Failed to connect to existing sandbox %s, creating new one", sandbox_id)
|
||||||
# Reset sandbox_id and create a new sandbox
|
# Reset sandbox_id and create a new sandbox with proxy auth configured
|
||||||
await client.threads.update(
|
await client.threads.update(
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
metadata={"sandbox_id": SANDBOX_CREATING},
|
metadata={"sandbox_id": SANDBOX_CREATING},
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sandbox_backend = await asyncio.to_thread(create_sandbox)
|
sandbox_backend = await _create_sandbox_with_proxy()
|
||||||
logger.info("New sandbox created: %s", sandbox_backend.id)
|
logger.info("New sandbox created: %s", sandbox_backend.id)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to create replacement sandbox")
|
logger.exception("Failed to create replacement sandbox")
|
||||||
|
|
@ -368,7 +358,7 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915
|
||||||
logger.info("Pulling latest changes for repo %s/%s", repo_owner, repo_name)
|
logger.info("Pulling latest changes for repo %s/%s", repo_owner, repo_name)
|
||||||
try:
|
try:
|
||||||
repo_dir = await _clone_or_pull_repo_in_sandbox(
|
repo_dir = await _clone_or_pull_repo_in_sandbox(
|
||||||
sandbox_backend, repo_owner, repo_name, github_token
|
sandbox_backend, repo_owner, repo_name
|
||||||
)
|
)
|
||||||
except SandboxClientError:
|
except SandboxClientError:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
@ -376,7 +366,7 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915
|
||||||
thread_id,
|
thread_id,
|
||||||
)
|
)
|
||||||
sandbox_backend, repo_dir = await _recreate_sandbox(
|
sandbox_backend, repo_dir = await _recreate_sandbox(
|
||||||
thread_id, repo_owner, repo_name, github_token=github_token
|
thread_id, repo_owner, repo_name
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to pull repo in existing sandbox")
|
logger.exception("Failed to pull repo in existing sandbox")
|
||||||
|
|
|
||||||
|
|
@ -151,14 +151,6 @@ def commit_and_open_pr(
|
||||||
if not (has_uncommitted_changes or has_unpushed_commits):
|
if not (has_uncommitted_changes or has_unpushed_commits):
|
||||||
return {"success": False, "error": "No changes detected", "pr_url": None}
|
return {"success": False, "error": "No changes detected", "pr_url": None}
|
||||||
|
|
||||||
installation_token = asyncio.run(get_github_app_installation_token())
|
|
||||||
if not installation_token:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"error": "Failed to get GitHub App installation token",
|
|
||||||
"pr_url": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata = config.get("metadata", {})
|
metadata = config.get("metadata", {})
|
||||||
branch_name = metadata.get("branch_name")
|
branch_name = metadata.get("branch_name")
|
||||||
current_branch = git_current_branch(sandbox_backend, repo_dir)
|
current_branch = git_current_branch(sandbox_backend, repo_dir)
|
||||||
|
|
@ -198,7 +190,15 @@ def commit_and_open_pr(
|
||||||
"pr_url": None,
|
"pr_url": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
push_result = git_push(sandbox_backend, repo_dir, target_branch, installation_token)
|
installation_token = asyncio.run(get_github_app_installation_token())
|
||||||
|
if not installation_token:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "Failed to get GitHub App installation token",
|
||||||
|
"pr_url": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
push_result = git_push(sandbox_backend, repo_dir, target_branch)
|
||||||
if push_result.exit_code != 0:
|
if push_result.exit_code != 0:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
|
|
|
||||||
|
|
@ -122,66 +122,18 @@ def git_get_remote_url(sandbox_backend: SandboxBackendProtocol, repo_dir: str) -
|
||||||
return result.output.strip()
|
return result.output.strip()
|
||||||
|
|
||||||
|
|
||||||
_CRED_FILE_PATH = "/tmp/.git-credentials"
|
|
||||||
|
|
||||||
|
|
||||||
def setup_git_credentials(sandbox_backend: SandboxBackendProtocol, github_token: str) -> None:
|
|
||||||
"""Write GitHub credentials to a temporary file using the sandbox write API.
|
|
||||||
|
|
||||||
The write API sends content in the HTTP body (not via a shell command),
|
|
||||||
so the token never appears in shell history or process listings.
|
|
||||||
"""
|
|
||||||
sandbox_backend.write(_CRED_FILE_PATH, f"https://git:{github_token}@github.com\n")
|
|
||||||
sandbox_backend.execute(f"chmod 600 {_CRED_FILE_PATH}")
|
|
||||||
|
|
||||||
|
|
||||||
def cleanup_git_credentials(sandbox_backend: SandboxBackendProtocol) -> None:
|
|
||||||
"""Remove the temporary credentials file."""
|
|
||||||
sandbox_backend.execute(f"rm -f {_CRED_FILE_PATH}")
|
|
||||||
|
|
||||||
|
|
||||||
def _git_with_credentials(
|
|
||||||
sandbox_backend: SandboxBackendProtocol,
|
|
||||||
repo_dir: str,
|
|
||||||
command: str,
|
|
||||||
) -> ExecuteResponse:
|
|
||||||
"""Run a git command using the temporary credential file."""
|
|
||||||
cred_helper = shlex.quote(f"store --file={_CRED_FILE_PATH}")
|
|
||||||
return _run_git(sandbox_backend, repo_dir, f"git -c credential.helper={cred_helper} {command}")
|
|
||||||
|
|
||||||
|
|
||||||
def git_push(
|
def git_push(
|
||||||
sandbox_backend: SandboxBackendProtocol,
|
sandbox_backend: SandboxBackendProtocol,
|
||||||
repo_dir: str,
|
repo_dir: str,
|
||||||
branch: str,
|
branch: str,
|
||||||
github_token: str | None = None,
|
|
||||||
) -> ExecuteResponse:
|
) -> ExecuteResponse:
|
||||||
"""Push the branch to origin, using a token if needed."""
|
"""Push the branch to origin.
|
||||||
safe_branch = shlex.quote(branch)
|
|
||||||
if not github_token:
|
|
||||||
return _run_git(sandbox_backend, repo_dir, f"git push origin {safe_branch}")
|
|
||||||
setup_git_credentials(sandbox_backend, github_token)
|
|
||||||
try:
|
|
||||||
return _git_with_credentials(sandbox_backend, repo_dir, f"push origin {safe_branch}")
|
|
||||||
finally:
|
|
||||||
cleanup_git_credentials(sandbox_backend)
|
|
||||||
|
|
||||||
|
Authentication is handled by the sandbox proxy (configured at sandbox creation
|
||||||
def git_pull_branch(
|
time via the LangSmith proxy-config API), so no token is needed here.
|
||||||
sandbox_backend: SandboxBackendProtocol,
|
"""
|
||||||
repo_dir: str,
|
|
||||||
branch: str,
|
|
||||||
github_token: str | None = None,
|
|
||||||
) -> ExecuteResponse:
|
|
||||||
"""Pull a specific branch from origin, using a token if needed."""
|
|
||||||
safe_branch = shlex.quote(branch)
|
safe_branch = shlex.quote(branch)
|
||||||
if not github_token:
|
return _run_git(sandbox_backend, repo_dir, f"git push origin {safe_branch}")
|
||||||
return _run_git(sandbox_backend, repo_dir, f"git pull origin {safe_branch}")
|
|
||||||
setup_git_credentials(sandbox_backend, github_token)
|
|
||||||
try:
|
|
||||||
return _git_with_credentials(sandbox_backend, repo_dir, f"pull origin {safe_branch}")
|
|
||||||
finally:
|
|
||||||
cleanup_git_credentials(sandbox_backend)
|
|
||||||
|
|
||||||
|
|
||||||
async def create_github_pr(
|
async def create_github_pr(
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ dependencies = [
|
||||||
"markdownify>=1.2.2",
|
"markdownify>=1.2.2",
|
||||||
"langchain-anthropic>1.1.0",
|
"langchain-anthropic>1.1.0",
|
||||||
"langgraph-cli[inmem]>=0.4.12",
|
"langgraph-cli[inmem]>=0.4.12",
|
||||||
"langsmith>=0.7.1",
|
"langsmith>=0.7.20",
|
||||||
"langchain-openai==1.1.10",
|
"langchain-openai==1.1.10",
|
||||||
"langchain-daytona>=0.0.3",
|
"langchain-daytona>=0.0.3",
|
||||||
"langchain-modal>=0.0.2",
|
"langchain-modal>=0.0.2",
|
||||||
|
|
|
||||||
|
|
@ -27,22 +27,3 @@ def test_git_checkout_existing_branch_quotes_repo_dir_and_branch() -> None:
|
||||||
github.git_checkout_existing_branch(sandbox, repo_dir, branch)
|
github.git_checkout_existing_branch(sandbox, repo_dir, branch)
|
||||||
|
|
||||||
assert sandbox.commands == [f"cd {shlex.quote(repo_dir)} && git checkout {shlex.quote(branch)}"]
|
assert sandbox.commands == [f"cd {shlex.quote(repo_dir)} && git checkout {shlex.quote(branch)}"]
|
||||||
|
|
||||||
|
|
||||||
def test_git_pull_branch_quotes_repo_dir_and_branch_when_using_credentials() -> None:
|
|
||||||
sandbox = FakeSandboxBackend()
|
|
||||||
repo_dir = "/tmp/repo; curl attacker"
|
|
||||||
branch = "main; curl attacker"
|
|
||||||
|
|
||||||
github.git_pull_branch(sandbox, repo_dir, branch, github_token="secret-token")
|
|
||||||
|
|
||||||
assert sandbox.writes == [(github._CRED_FILE_PATH, "https://git:secret-token@github.com\n")]
|
|
||||||
assert sandbox.commands == [
|
|
||||||
f"chmod 600 {github._CRED_FILE_PATH}",
|
|
||||||
(
|
|
||||||
f"cd {shlex.quote(repo_dir)} && git -c "
|
|
||||||
f"credential.helper={shlex.quote(f'store --file={github._CRED_FILE_PATH}')}"
|
|
||||||
f" pull origin {shlex.quote(branch)}"
|
|
||||||
),
|
|
||||||
f"rm -f {github._CRED_FILE_PATH}",
|
|
||||||
]
|
|
||||||
|
|
|
||||||
172
tests/test_proxy_auth.py
Normal file
172
tests/test_proxy_auth.py
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
"""Tests for GitHub proxy auth configuration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agent.integrations.langsmith import _configure_github_proxy
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigureGithubProxy:
|
||||||
|
"""Tests for _configure_github_proxy payload shape and error handling."""
|
||||||
|
|
||||||
|
def test_sends_correct_payload_shape(self) -> None:
|
||||||
|
"""Verify the PATCH request uses opaque headers with correct structure."""
|
||||||
|
token = "ghs_testtoken123"
|
||||||
|
expected_basic = base64.b64encode(f"x-access-token:{token}".encode()).decode()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("agent.integrations.langsmith.httpx.Client") as mock_client_cls,
|
||||||
|
patch.dict("os.environ", {"LANGSMITH_API_KEY": "ls-api-key"}),
|
||||||
|
):
|
||||||
|
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-abc123", token)
|
||||||
|
|
||||||
|
mock_client.patch.assert_called_once()
|
||||||
|
call_kwargs = mock_client.patch.call_args
|
||||||
|
payload = call_kwargs.kwargs["json"]
|
||||||
|
|
||||||
|
# Verify proxy_config structure
|
||||||
|
assert "proxy_config" in payload
|
||||||
|
rules = payload["proxy_config"]["rules"]
|
||||||
|
assert len(rules) == 1
|
||||||
|
|
||||||
|
rule = rules[0]
|
||||||
|
assert rule["name"] == "github"
|
||||||
|
assert rule["match_hosts"] == ["github.com", "*.github.com"]
|
||||||
|
|
||||||
|
headers = rule["headers"]
|
||||||
|
assert len(headers) == 1
|
||||||
|
assert headers[0]["name"] == "Authorization"
|
||||||
|
assert headers[0]["type"] == "opaque"
|
||||||
|
assert headers[0]["value"] == f"Basic {expected_basic}"
|
||||||
|
|
||||||
|
def test_sends_to_correct_url(self) -> None:
|
||||||
|
"""Verify the PATCH hits the right endpoint."""
|
||||||
|
with (
|
||||||
|
patch("agent.integrations.langsmith.httpx.Client") as mock_client_cls,
|
||||||
|
patch.dict(
|
||||||
|
"os.environ",
|
||||||
|
{
|
||||||
|
"LANGSMITH_ENDPOINT": "https://test.api.smith.langchain.com",
|
||||||
|
"LANGSMITH_API_KEY": "api-key",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
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-xyz", "token")
|
||||||
|
|
||||||
|
url = mock_client.patch.call_args.args[0]
|
||||||
|
assert url == "https://test.api.smith.langchain.com/v2/sandboxes/boxes/sandbox-xyz"
|
||||||
|
|
||||||
|
def test_sends_api_key_header(self) -> None:
|
||||||
|
"""Verify the PATCH includes the LangSmith API key."""
|
||||||
|
with (
|
||||||
|
patch("agent.integrations.langsmith.httpx.Client") as mock_client_cls,
|
||||||
|
patch.dict("os.environ", {"LANGSMITH_API_KEY": "my-api-key"}),
|
||||||
|
):
|
||||||
|
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")
|
||||||
|
|
||||||
|
headers = mock_client.patch.call_args.kwargs["headers"]
|
||||||
|
assert headers == {"X-API-Key": "my-api-key"}
|
||||||
|
|
||||||
|
def test_raises_on_http_error(self) -> None:
|
||||||
|
"""Verify HTTP errors propagate."""
|
||||||
|
with (
|
||||||
|
patch("agent.integrations.langsmith.httpx.Client") as mock_client_cls,
|
||||||
|
patch.dict("os.environ", {"LANGSMITH_API_KEY": "api-key"}),
|
||||||
|
):
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.patch.side_effect = httpx.HTTPStatusError(
|
||||||
|
"Server error", request=MagicMock(), response=MagicMock(status_code=500)
|
||||||
|
)
|
||||||
|
mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client)
|
||||||
|
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
|
||||||
|
with pytest.raises(httpx.HTTPStatusError):
|
||||||
|
_configure_github_proxy("sandbox-abc", "token")
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateSandboxWithProxy:
|
||||||
|
"""Tests for _create_sandbox_with_proxy token source selection."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_uses_installation_token_for_langsmith(self) -> None:
|
||||||
|
"""Installation token should be used for proxy auth on langsmith sandboxes."""
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"agent.server.get_github_app_installation_token",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value="ghs_install",
|
||||||
|
),
|
||||||
|
patch("agent.server.create_sandbox") as mock_create,
|
||||||
|
patch("agent.server._configure_github_proxy") as mock_proxy,
|
||||||
|
patch.dict("os.environ", {"SANDBOX_TYPE": "langsmith", "LANGSMITH_API_KEY": "ls-key"}),
|
||||||
|
):
|
||||||
|
mock_create.return_value = MagicMock(id="sandbox-123")
|
||||||
|
|
||||||
|
from agent.server import _create_sandbox_with_proxy
|
||||||
|
|
||||||
|
await _create_sandbox_with_proxy()
|
||||||
|
|
||||||
|
mock_create.assert_called_once_with()
|
||||||
|
mock_proxy.assert_called_once_with("sandbox-123", "ghs_install")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skips_proxy_for_non_langsmith(self) -> None:
|
||||||
|
"""Non-langsmith sandboxes should skip proxy configuration."""
|
||||||
|
with (
|
||||||
|
patch("agent.server.create_sandbox") as mock_create,
|
||||||
|
patch("agent.server._configure_github_proxy") as mock_proxy,
|
||||||
|
patch.dict("os.environ", {"SANDBOX_TYPE": "daytona"}),
|
||||||
|
):
|
||||||
|
mock_create.return_value = MagicMock(id="sandbox-456")
|
||||||
|
|
||||||
|
from agent.server import _create_sandbox_with_proxy
|
||||||
|
|
||||||
|
await _create_sandbox_with_proxy()
|
||||||
|
|
||||||
|
mock_create.assert_called_once_with()
|
||||||
|
mock_proxy.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_raises_when_no_installation_token_for_langsmith(self) -> None:
|
||||||
|
"""Should raise ValueError when installation token is unavailable for langsmith."""
|
||||||
|
with (
|
||||||
|
patch("agent.server.create_sandbox") as mock_create,
|
||||||
|
patch(
|
||||||
|
"agent.server.get_github_app_installation_token",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch.dict("os.environ", {"SANDBOX_TYPE": "langsmith"}),
|
||||||
|
):
|
||||||
|
mock_create.return_value = MagicMock(id="sandbox-789")
|
||||||
|
|
||||||
|
from agent.server import _create_sandbox_with_proxy
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="installation token is unavailable"):
|
||||||
|
await _create_sandbox_with_proxy()
|
||||||
8
uv.lock
generated
8
uv.lock
generated
|
|
@ -1505,7 +1505,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langsmith"
|
name = "langsmith"
|
||||||
version = "0.7.1"
|
version = "0.7.25"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
|
|
@ -1518,9 +1518,9 @@ dependencies = [
|
||||||
{ name = "xxhash" },
|
{ name = "xxhash" },
|
||||||
{ name = "zstandard" },
|
{ name = "zstandard" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/67/48/3151de6df96e0977b8d319b03905e29db0df6929a85df1d922a030b7e68d/langsmith-0.7.1.tar.gz", hash = "sha256:e3fec2f97f7c5192f192f4873d6a076b8c6469768022323dded07087d8cb70a4", size = 984367, upload-time = "2026-02-10T01:55:24.696Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/7e/d7/21ffae5ccdc3c9b8de283e8f8bf48a92039681df0d39f15133d8ff8965bd/langsmith-0.7.25.tar.gz", hash = "sha256:d17da71f156ca69eafd28ac9627c8e0e93170260ec37cd27cedc83205a067598", size = 1145410, upload-time = "2026-04-03T13:11:42.36Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/87/6f2b008a456b4f5fd0fb1509bb7e1e9368c1a0c9641a535f224a9ddc10f3/langsmith-0.7.1-py3-none-any.whl", hash = "sha256:92cfa54253d35417184c297ad25bfd921d95f15d60a1ca75f14d4e7acd152a29", size = 322515, upload-time = "2026-02-10T01:55:22.531Z" },
|
{ url = "https://files.pythonhosted.org/packages/29/13/67889d41baf7dbaf13ffd0b334a0f284e107fad1cc8782a1abb1e56e5eeb/langsmith-0.7.25-py3-none-any.whl", hash = "sha256:55ecc24c547f6c79b5a684ff8685c669eec34e52fcac5d2c0af7d613aef5a632", size = 359417, upload-time = "2026-04-03T13:11:40.729Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
|
|
@ -1826,7 +1826,7 @@ requires-dist = [
|
||||||
{ name = "langgraph", specifier = ">=1.0.8" },
|
{ name = "langgraph", specifier = ">=1.0.8" },
|
||||||
{ name = "langgraph-cli", extras = ["inmem"], specifier = ">=0.4.12" },
|
{ name = "langgraph-cli", extras = ["inmem"], specifier = ">=0.4.12" },
|
||||||
{ name = "langgraph-sdk", specifier = ">=0.1.0" },
|
{ name = "langgraph-sdk", specifier = ">=0.1.0" },
|
||||||
{ name = "langsmith", specifier = ">=0.7.1" },
|
{ name = "langsmith", specifier = ">=0.7.20" },
|
||||||
{ name = "markdownify", specifier = ">=1.2.2" },
|
{ name = "markdownify", specifier = ">=1.2.2" },
|
||||||
{ name = "pygments", marker = "extra == 'dev'", specifier = ">=2.20.0" },
|
{ name = "pygments", marker = "extra == 'dev'", specifier = ">=2.20.0" },
|
||||||
{ name = "pyjwt", specifier = ">=2.12.0" },
|
{ name = "pyjwt", specifier = ">=2.12.0" },
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue