codekingpro/portable-devtools
115k
1"""Generic utility functions."""2 3from __future__ import annotations4 5import contextlib6import contextvars7import copy8import enum9import functools10import logging11import os12import pathlib13import socket14import subprocess15import sys16import threading17import traceback18from collections.abc import Generator, Iterable, Iterator, Mapping, Sequence19from concurrent.futures import Future, ThreadPoolExecutor20from typing import (21 Any,22 Callable,23 Literal,24 Optional,25 TypeVar,26 Union,27 cast,28 overload,29)30from urllib import parse as urllib_parse31 32import httpx33import requests34from typing_extensions import ParamSpec35from urllib3.util import Retry # type: ignore[import-untyped]36 37from langsmith import schemas as ls_schemas38 39_LOGGER = logging.getLogger(__name__)40 41 42class LangSmithError(Exception):43 """An error occurred while communicating with the LangSmith API."""44 45 46class LangSmithAPIError(LangSmithError):47 """Internal server error while communicating with LangSmith."""48 49 50class LangSmithRequestTimeout(LangSmithError):51 """Client took too long to send request body."""52 53 54class LangSmithUserError(LangSmithError):55 """User error caused an exception when communicating with LangSmith."""56 57 58class LangSmithRateLimitError(LangSmithError):59 """You have exceeded the rate limit for the LangSmith API."""60 61 62class LangSmithAuthError(LangSmithError):63 """Couldn't authenticate with the LangSmith API."""64 65 66class LangSmithNotFoundError(LangSmithError):67 """Couldn't find the requested resource."""68 69 70class LangSmithConflictError(LangSmithError):71 """The resource already exists."""72 73 74class LangSmithConnectionError(LangSmithError):75 """Couldn't connect to the LangSmith API."""76 77 78class LangSmithExceptionGroup(LangSmithError):79 """Port of ExceptionGroup for Py < 3.11."""80 81 def __init__(82 self, *args: Any, exceptions: Sequence[Exception], **kwargs: Any83 ) -> None:84 """Initialize."""85 super().__init__(*args, **kwargs)86 self.exceptions = exceptions87 88 89def get_invalid_prompt_identifier_msg(identifier: str) -> str:90 """Get the error message for an invalid prompt identifier.91 92 Used consistently across the codebase when parsing prompt identifiers fails.93 94 Args:95 identifier: The invalid identifier that was provided.96 97 Returns:98 A formatted error message explaining the valid formats.99 """100 return (101 f'Invalid prompt identifier format: "{identifier}". '102 f"Expected one of:\n"103 f' - "prompt-name" (for private prompts)\n'104 f' - "owner/prompt-name" (for prompts with explicit owner)\n'105 f' - "prompt-name:commit-hash" (with commit reference)\n'106 f' - "owner/prompt-name:commit-hash" (with owner and commit)'107 )108 109 110## Warning classes111 112 113class LangSmithWarning(UserWarning):114 """Base class for warnings."""115 116 117class LangSmithMissingAPIKeyWarning(LangSmithWarning):118 """Warning for missing API key."""119 120 121def tracing_is_enabled(ctx: Optional[dict] = None) -> Union[bool, Literal["local"]]:122 """Return True if tracing is enabled."""123 # Access global fallbacks via context module to avoid stale references.124 import langsmith._internal._context as _context125 from langsmith.run_helpers import get_current_run_tree, get_tracing_context126 127 tc = ctx or get_tracing_context()128 # You can manually override the environment using context vars.129 # Check that first.130 # Doing this before checking the run tree lets us131 # disable a branch within a trace.132 if tc["enabled"] is not None:133 return tc["enabled"]134 # Next check if we're mid-trace135 if get_current_run_tree():136 return True137 # If a global fallback was configured, use it next.138 if _context._GLOBAL_TRACING_ENABLED is not None:139 return _context._GLOBAL_TRACING_ENABLED140 # Finally, check the global environment141 var_result = get_env_var("TRACING_V2", default=get_env_var("TRACING", default=""))142 return var_result == "true"143 144 145def test_tracking_is_disabled() -> bool:146 """Return True if testing is enabled."""147 return get_env_var("TEST_TRACKING", default="") == "false"148 149 150def xor_args(*arg_groups: tuple[str, ...]) -> Callable:151 """Validate specified keyword args are mutually exclusive."""152 153 def decorator(func: Callable) -> Callable:154 @functools.wraps(func)155 def wrapper(*args: Any, **kwargs: Any) -> Any:156 """Validate exactly one arg in each group is not None."""157 counts = [158 sum(1 for arg in arg_group if kwargs.get(arg) is not None)159 for arg_group in arg_groups160 ]161 invalid_groups = [i for i, count in enumerate(counts) if count != 1]162 if invalid_groups:163 invalid_group_names = [", ".join(arg_groups[i]) for i in invalid_groups]164 raise ValueError(165 "Exactly one argument in each of the following"166 " groups must be defined:"167 f" {', '.join(invalid_group_names)}"168 )169 return func(*args, **kwargs)170 171 return wrapper172 173 return decorator174 175 176def raise_for_status_with_text(177 response: Union[requests.Response, httpx.Response],178) -> None:179 """Raise an error with the response text."""180 try:181 response.raise_for_status()182 except requests.HTTPError as e:183 raise requests.HTTPError(str(e), response.text) from e # type: ignore[call-arg]184 except httpx.HTTPStatusError as e:185 raise httpx.HTTPStatusError(186 f"{str(e)}: {response.text}",187 request=response.request, # type: ignore[arg-type]188 response=response, # type: ignore[arg-type]189 ) from e190 191 192def get_enum_value(enu: Union[enum.Enum, str]) -> str:193 """Get the value of a string enum."""194 if isinstance(enu, enum.Enum):195 return enu.value196 return enu197 198 199@functools.lru_cache(maxsize=1)200def log_once(level: int, message: str) -> None:201 """Log a message at the specified level, but only once."""202 _LOGGER.log(level, message)203 204 205def _get_message_type(message: Mapping[str, Any]) -> str:206 if not message:207 raise ValueError("Message is empty.")208 if "lc" in message:209 if "id" not in message:210 raise ValueError(211 f"Unexpected format for serialized message: {message}"212 " Message does not have an id."213 )214 return message["id"][-1].replace("Message", "").lower()215 else:216 if "type" not in message:217 raise ValueError(218 f"Unexpected format for stored message: {message}"219 " Message does not have a type."220 )221 return message["type"]222 223 224def _get_message_fields(message: Mapping[str, Any]) -> Mapping[str, Any]:225 if not message:226 raise ValueError("Message is empty.")227 if "lc" in message:228 if "kwargs" not in message:229 raise ValueError(230 f"Unexpected format for serialized message: {message}"231 " Message does not have kwargs."232 )233 return message["kwargs"]234 else:235 if "data" not in message:236 raise ValueError(237 f"Unexpected format for stored message: {message}"238 " Message does not have data."239 )240 return message["data"]241 242 243def _convert_message(message: Mapping[str, Any]) -> dict[str, Any]:244 """Extract message from a message object."""245 message_type = _get_message_type(message)246 message_data = _get_message_fields(message)247 return {"type": message_type, "data": message_data}248 249 250def get_messages_from_inputs(inputs: Mapping[str, Any]) -> list[dict[str, Any]]:251 """Extract messages from the given inputs dictionary.252 253 Args:254 inputs: The inputs dictionary.255 256 Returns:257 A list of dictionaries representing the extracted messages.258 259 Raises:260 ValueError: If no message(s) are found in the inputs dictionary.261 """262 if "messages" in inputs:263 return [_convert_message(message) for message in inputs["messages"]]264 if "message" in inputs:265 return [_convert_message(inputs["message"])]266 raise ValueError(f"Could not find message(s) in run with inputs {inputs}.")267 268 269def get_message_generation_from_outputs(outputs: Mapping[str, Any]) -> dict[str, Any]:270 """Retrieve the message generation from the given outputs.271 272 Args:273 outputs: The outputs dictionary.274 275 Returns:276 The message generation.277 278 Raises:279 ValueError: If no generations are found or if multiple generations are present.280 """281 if "generations" not in outputs:282 raise ValueError(f"No generations found in in run with output: {outputs}.")283 generations = outputs["generations"]284 if len(generations) != 1:285 raise ValueError(286 "Chat examples expect exactly one generation."287 f" Found {len(generations)} generations: {generations}."288 )289 first_generation = generations[0]290 if "message" not in first_generation:291 raise ValueError(292 f"Unexpected format for generation: {first_generation}."293 " Generation does not have a message."294 )295 return _convert_message(first_generation["message"])296 297 298def get_prompt_from_inputs(inputs: Mapping[str, Any]) -> str:299 """Retrieve the prompt from the given inputs.300 301 Args:302 inputs: The inputs dictionary.303 304 Returns:305 str: The prompt.306 307 Raises:308 ValueError: If the prompt is not found or if multiple prompts are present.309 """310 if "prompt" in inputs:311 return inputs["prompt"]312 if "prompts" in inputs:313 prompts = inputs["prompts"]314 if len(prompts) == 1:315 return prompts[0]316 raise ValueError(317 f"Multiple prompts in run with inputs {inputs}."318 " Please create example manually."319 )320 raise ValueError(f"Could not find prompt in run with inputs {inputs}.")321 322 323def get_llm_generation_from_outputs(outputs: Mapping[str, Any]) -> str:324 """Get the LLM generation from the outputs."""325 if "generations" not in outputs:326 raise ValueError(f"No generations found in in run with output: {outputs}.")327 generations = outputs["generations"]328 if len(generations) != 1:329 raise ValueError(f"Multiple generations in run: {generations}")330 first_generation = generations[0]331 if "text" not in first_generation:332 raise ValueError(f"No text in generation: {first_generation}")333 return first_generation["text"]334 335 336@functools.lru_cache(maxsize=1)337def get_docker_compose_command() -> list[str]:338 """Get the correct docker compose command for this system."""339 try:340 subprocess.check_call(341 ["docker", "compose", "--version"],342 stdout=subprocess.DEVNULL,343 stderr=subprocess.DEVNULL,344 )345 return ["docker", "compose"]346 except (subprocess.CalledProcessError, FileNotFoundError):347 try:348 subprocess.check_call(349 ["docker-compose", "--version"],350 stdout=subprocess.DEVNULL,351 stderr=subprocess.DEVNULL,352 )353 return ["docker-compose"]354 except (subprocess.CalledProcessError, FileNotFoundError):355 raise ValueError(356 "Neither 'docker compose' nor 'docker-compose'"357 " commands are available. Please install the Docker"358 " server following the instructions for your operating"359 " system at https://docs.docker.com/engine/install/"360 )361 362 363def convert_langchain_message(message: ls_schemas.BaseMessageLike) -> dict:364 """Convert a LangChain message to an example."""365 converted: dict[str, Any] = {366 "type": message.type,367 "data": {"content": message.content},368 }369 # Check for presence of keys in additional_kwargs370 if message.additional_kwargs and len(message.additional_kwargs) > 0:371 converted["data"]["additional_kwargs"] = {**message.additional_kwargs}372 return converted373 374 375def is_base_message_like(obj: object) -> bool:376 """Check if the given object is similar to `BaseMessage`.377 378 Args:379 obj: The object to check.380 381 Returns:382 bool: True if the object is similar to `BaseMessage`, `False` otherwise.383 """384 return all(385 [386 isinstance(getattr(obj, "content", None), str),387 isinstance(getattr(obj, "additional_kwargs", None), dict),388 hasattr(obj, "type") and isinstance(getattr(obj, "type"), str),389 ]390 )391 392 393def is_env_var_truish(value: Optional[str]) -> bool:394 """Check if the given environment variable is truish."""395 if value is None:396 return False397 return is_truish(get_env_var(value))398 399 400@overload401def get_env_var(402 name: str,403 default: str,404 *,405 namespaces: tuple = ("LANGSMITH", "LANGCHAIN"),406) -> str: ...407 408 409@overload410def get_env_var(411 name: str,412 default: None = None,413 *,414 namespaces: tuple = ("LANGSMITH", "LANGCHAIN"),415) -> Optional[str]: ...416 417 418@functools.lru_cache(maxsize=100)419def get_env_var(420 name: str,421 default: Optional[str] = None,422 *,423 namespaces: tuple = ("LANGSMITH", "LANGCHAIN"),424) -> Optional[str]:425 """Retrieve an environment variable from a list of namespaces.426 427 Args:428 name: The name of the environment variable.429 default: The default value to return if the environment variable is not found.430 namespaces: A tuple of namespaces to search for the environment variable.431 432 Defaults to `('LANGSMITH', 'LANGCHAINs')`.433 434 Returns:435 The value of the environment variable if found, otherwise the default value.436 """437 names = [f"{namespace}_{name}" for namespace in namespaces]438 for name in names:439 value = os.environ.get(name)440 if value is not None and value.strip() != "":441 return value442 return default443 444 445@functools.lru_cache(maxsize=1)446def get_tracer_project(return_default_value=True) -> Optional[str]:447 """Get the project name for a LangSmith tracer."""448 return os.environ.get(449 # Hosted LangServe projects get precedence over all other defaults.450 # This is to make sure that we always use the associated project451 # for a hosted langserve deployment even if the customer sets some452 # other project name in their environment.453 "HOSTED_LANGSERVE_PROJECT_NAME",454 get_env_var(455 "PROJECT",456 # This is the legacy name for a LANGCHAIN_PROJECT, so it457 # has lower precedence than LANGCHAIN_PROJECT458 default=get_env_var(459 "SESSION", default="default" if return_default_value else None460 ),461 ),462 )463 464 465class FilterPoolFullWarning(logging.Filter):466 """Filter `urllib3` warnings logged when the connection pool isn't reused."""467 468 def __init__(self, name: str = "", host: str = "") -> None:469 """Initialize the `FilterPoolFullWarning` filter.470 471 Args:472 name: The name of the filter. Defaults to `""`.473 host: The host to filter. Defaults to `""`.474 """475 super().__init__(name)476 self._host = host477 478 def filter(self, record) -> bool:479 """urllib3.connectionpool:Connection pool is full, discarding connection: ..."""480 msg = record.getMessage()481 if "Connection pool is full, discarding connection" not in msg:482 return True483 return self._host not in msg484 485 486class FilterLangSmithRetry(logging.Filter):487 """Filter for retries from this lib."""488 489 def filter(self, record) -> bool:490 """Filter retries from this library."""491 # We re-raise/log manually.492 msg = record.getMessage()493 return "LangSmithRetry" not in msg494 495 496class LangSmithRetry(Retry):497 """Wrapper to filter logs with this name."""498 499 500_FILTER_LOCK = threading.RLock()501 502 503@contextlib.contextmanager504def filter_logs(505 logger: logging.Logger, filters: Sequence[logging.Filter]506) -> Generator[None, None, None]:507 """Temporarily adds specified filters to a logger.508 509 Parameters:510 - logger: The logger to which the filters will be added.511 - filters: A sequence of `logging.Filter` objects to be temporarily added512 to the logger.513 """514 with _FILTER_LOCK:515 for filter in filters:516 logger.addFilter(filter)517 # Not actually perfectly thread-safe, but it's only log filters518 try:519 yield520 finally:521 with _FILTER_LOCK:522 for filter in filters:523 try:524 logger.removeFilter(filter)525 except BaseException:526 _LOGGER.warning("Failed to remove filter")527 528 529def get_cache_dir(cache: Optional[str]) -> Optional[str]:530 """Get the testing cache directory.531 532 Args:533 cache: The cache path.534 535 Returns:536 The cache path if provided, otherwise the value from the `LANGSMITH_TEST_CACHE`537 environment variable.538 """539 if cache is not None:540 return cache541 return get_env_var("TEST_CACHE", default=None)542 543 544def filter_request_headers(545 request: Any,546 *,547 ignore_hosts: Optional[Sequence[str]] = None,548 allow_hosts: Optional[Sequence[str]] = None,549) -> Any:550 """Filter request headers based on `ignore_hosts` and `allow_hosts`."""551 # Legacy behavior552 if ignore_hosts and any(request.url.startswith(host) for host in ignore_hosts):553 return None554 555 if allow_hosts:556 try:557 parsed_url = urllib_parse.urlparse(request.url)558 except Exception:559 # If URL parsing fails, don't cache to be safe560 return None561 request_host = parsed_url.hostname or ""562 # Check if request matches any allowed host563 host_matches = any(564 # Handle both full URLs (https://api.openai.com)565 # and hostnames (api.openai.com)566 (567 request.url.startswith(host)568 if host.startswith(("http://", "https://"))569 else request_host == host or request_host.endswith(f".{host}")570 )571 for host in allow_hosts572 )573 if not host_matches:574 return None575 576 request.headers = {}577 return request578 579 580@contextlib.contextmanager581def with_cache(582 path: Union[str, pathlib.Path],583 ignore_hosts: Optional[Sequence[str]] = None,584 allow_hosts: Optional[Sequence[str]] = None,585) -> Generator[None, None, None]:586 """Use a cache for requests."""587 try:588 import vcr # type: ignore[import-untyped]589 except ImportError:590 raise ImportError(591 "vcrpy is required to use caching. Install with:"592 'pip install -U "langsmith[vcr]"'593 )594 # Fix concurrency issue in vcrpy's patching595 from langsmith._internal import _patch as patch_urllib3596 597 patch_urllib3.patch_urllib3()598 599 cache_dir, cache_file = os.path.split(path)600 601 ls_vcr = vcr.VCR(602 serializer=(603 "yaml"604 if cache_file.endswith(".yaml") or cache_file.endswith(".yml")605 else "json"606 ),607 cassette_library_dir=cache_dir,608 # Replay previous requests, record new ones609 # TODO: Support other modes610 record_mode="new_episodes",611 match_on=["uri", "method", "path", "body"],612 filter_headers=["authorization", "Set-Cookie"],613 before_record_request=lambda request: filter_request_headers(614 request, ignore_hosts=ignore_hosts, allow_hosts=allow_hosts615 ),616 )617 with ls_vcr.use_cassette(cache_file):618 yield619 620 621@contextlib.contextmanager622def with_optional_cache(623 path: Optional[Union[str, pathlib.Path]],624 ignore_hosts: Optional[Sequence[str]] = None,625 allow_hosts: Optional[Sequence[str]] = None,626) -> Generator[None, None, None]:627 """Use a cache for requests."""628 if path is not None:629 with with_cache(path, ignore_hosts, allow_hosts):630 yield631 else:632 yield633 634 635def _format_exc() -> str:636 # Used internally to format exceptions without cluttering the traceback637 tb_lines = traceback.format_exception(*sys.exc_info())638 filtered_lines = [line for line in tb_lines if "langsmith/" not in line]639 return "".join(filtered_lines)640 641 642T = TypeVar("T")643 644 645def _middle_copy(646 val: T, memo: dict[int, Any], max_depth: int = 4, _depth: int = 0647) -> T:648 cls = type(val)649 650 copier = getattr(cls, "__deepcopy__", None)651 if copier is not None:652 try:653 return copier(memo)654 except BaseException:655 pass656 if _depth >= max_depth:657 return val658 if isinstance(val, dict):659 return { # type: ignore[return-value]660 _middle_copy(k, memo, max_depth, _depth + 1): _middle_copy(661 v, memo, max_depth, _depth + 1662 )663 for k, v in val.items()664 }665 if isinstance(val, list):666 return [_middle_copy(item, memo, max_depth, _depth + 1) for item in val] # type: ignore[return-value]667 if isinstance(val, tuple):668 return tuple(_middle_copy(item, memo, max_depth, _depth + 1) for item in val) # type: ignore[return-value]669 if isinstance(val, set):670 return {_middle_copy(item, memo, max_depth, _depth + 1) for item in val} # type: ignore[return-value]671 672 return val673 674 675def deepish_copy(val: T) -> T:676 """Deep copy a value with a compromise for uncopyable objects.677 678 Args:679 val: The value to be deep copied.680 681 Returns:682 The deep copied value.683 """684 memo: dict[int, Any] = {}685 try:686 return copy.deepcopy(val, memo)687 except BaseException as e:688 # Generators, locks, etc. cannot be copied689 # and raise a TypeError (mentioning pickling, since the dunder methods)690 # are re-used for copying. We'll try to do a compromise and copy691 # what we can692 _LOGGER.debug("Failed to deepcopy input: %s", repr(e))693 return _middle_copy(val, memo)694 695 696def is_version_greater_or_equal(current_version: str, target_version: str) -> bool:697 """Check if the current version is greater or equal to the target version."""698 from packaging import version699 700 current = version.parse(current_version)701 target = version.parse(target_version)702 return current >= target703 704 705def parse_prompt_identifier(identifier: str) -> tuple[str, str, str]:706 """Parse a string in the format of `owner/name:hash`, `name:hash`, `owner/name`, or `name`.707 708 Args:709 identifier: The prompt identifier to parse.710 711 Returns:712 A tuple containing `(owner, name, hash)`.713 714 Raises:715 ValueError: If the identifier doesn't match the expected formats.716 """ # noqa: E501717 if (718 not identifier719 or identifier.count("/") > 1720 or identifier.startswith("/")721 or identifier.endswith("/")722 ):723 raise ValueError(get_invalid_prompt_identifier_msg(identifier))724 725 parts = identifier.split(":", 1)726 owner_name = parts[0]727 commit = parts[1] if len(parts) > 1 else "latest"728 729 if "/" in owner_name:730 owner, name = owner_name.split("/", 1)731 if not owner or not name:732 raise ValueError(get_invalid_prompt_identifier_msg(identifier))733 return owner, name, commit734 else:735 if not owner_name:736 raise ValueError(get_invalid_prompt_identifier_msg(identifier))737 return "-", owner_name, commit738 739 740def parse_hub_identifier(identifier: str) -> tuple[str, str, str]:741 """Parse a hub repo identifier (``owner/name:hash``, ``name``, etc.).742 743 Agents, skills, and prompts share the same identifier grammar on Hub.744 745 Args:746 identifier: The hub identifier to parse.747 748 Returns:749 A tuple containing ``(owner, name, hash)``.750 751 Raises:752 ValueError: If the identifier doesn't match the expected formats.753 """754 return parse_prompt_identifier(identifier)755 756 757P = ParamSpec("P")758 759 760class ContextThreadPoolExecutor(ThreadPoolExecutor):761 """ThreadPoolExecutor that copies the context to the child thread."""762 763 def submit( # type: ignore[override]764 self,765 func: Callable[P, T],766 *args: P.args,767 **kwargs: P.kwargs,768 ) -> Future[T]:769 """Submit a function to the executor.770 771 Args:772 func (Callable[..., T]): The function to submit.773 *args (Any): The positional arguments to the function.774 **kwargs (Any): The keyword arguments to the function.775 776 Returns:777 Future[T]: The future for the function.778 """779 return super().submit(780 cast(781 Callable[..., T],782 functools.partial(783 contextvars.copy_context().run, func, *args, **kwargs784 ),785 )786 )787 788 def map(789 self,790 fn: Callable[..., T],791 *iterables: Iterable[Any],792 timeout: Optional[float] = None,793 chunksize: int = 1,794 ) -> Iterator[T]:795 """Return an iterator equivalent to stdlib map.796 797 Each function will receive its own copy of the context from the parent thread.798 799 Args:800 fn: A callable that will take as many arguments as there are801 passed iterables.802 timeout: The maximum number of seconds to wait. If None, then there803 is no limit on the wait time.804 chunksize: The size of the chunks the iterable will be broken into805 before being passed to a child process. This argument is only806 used by ProcessPoolExecutor; it is ignored by807 ThreadPoolExecutor.808 809 Returns:810 An iterator equivalent to: map(func, *iterables) but the calls may811 be evaluated out-of-order.812 813 Raises:814 TimeoutError: If the entire result iterator could not be generated815 before the given timeout.816 Exception: If fn(*args) raises for any values.817 """818 contexts = [contextvars.copy_context() for _ in range(len(iterables[0]))] # type: ignore[arg-type]819 820 def _wrapped_fn(*args: Any) -> T:821 return contexts.pop().run(fn, *args)822 823 return super().map(824 _wrapped_fn,825 *iterables,826 timeout=timeout,827 chunksize=chunksize,828 )829 830 831def get_api_url(api_url: Optional[str]) -> str:832 """Get the LangSmith API URL from the environment or the given value."""833 _api_url = api_url or cast(834 str,835 get_env_var(836 "ENDPOINT",837 default="https://api.smith.langchain.com",838 ),839 )840 if not _api_url.strip():841 raise LangSmithUserError("LangSmith API URL cannot be empty")842 return _api_url.strip().strip('"').strip("'").rstrip("/")843 844 845def get_api_key(api_key: Optional[str]) -> Optional[str]:846 """Get the API key from the environment or the given value."""847 api_key_ = api_key if api_key is not None else get_env_var("API_KEY", default=None)848 if api_key_ is None or not api_key_.strip():849 return None850 return api_key_.strip().strip('"').strip("'")851 852 853def get_workspace_id(workspace_id: Optional[str]) -> Optional[str]:854 """Get workspace ID."""855 workspace_id_ = (856 workspace_id857 if workspace_id is not None858 else get_env_var("WORKSPACE_ID", default=None)859 )860 if workspace_id_ is None or not workspace_id_.strip():861 return None862 return workspace_id_.strip().strip('"').strip("'")863 864 865def _is_localhost(url: str) -> bool:866 """Check if the URL is localhost.867 868 Parameters869 ----------870 url : str871 The URL to check.872 873 Returns:874 -------875 bool876 True if the URL is localhost, False otherwise.877 """878 try:879 netloc = urllib_parse.urlsplit(url).netloc.split(":")[0]880 ip = socket.gethostbyname(netloc)881 return ip == "127.0.0.1" or ip.startswith("0.0.0.0") or ip.startswith("::")882 except socket.gaierror:883 return False884 885 886@functools.lru_cache(maxsize=2)887def get_host_url(web_url: Optional[str], api_url: str):888 """Get the host URL based on the web URL or API URL."""889 if web_url:890 return web_url891 parsed_url = urllib_parse.urlparse(api_url)892 if _is_localhost(api_url):893 link = "http://localhost"894 elif str(parsed_url.path).endswith("/api"):895 new_path = str(parsed_url.path).rsplit("/api", 1)[0]896 link = urllib_parse.urlunparse(parsed_url._replace(path=new_path))897 elif str(parsed_url.path).endswith("/api/v1"):898 new_path = str(parsed_url.path).rsplit("/api/v1", 1)[0]899 link = urllib_parse.urlunparse(parsed_url._replace(path=new_path))900 elif str(parsed_url.netloc).startswith("eu."):901 link = "https://eu.smith.langchain.com"902 elif str(parsed_url.netloc).startswith("aws."):903 link = "https://aws.smith.langchain.com"904 elif str(parsed_url.netloc).startswith("apac."):905 link = "https://apac.smith.langchain.com"906 elif str(parsed_url.netloc).startswith("dev."):907 link = "https://dev.smith.langchain.com"908 elif str(parsed_url.netloc).startswith("beta."):909 link = "https://beta.smith.langchain.com"910 else:911 link = "https://smith.langchain.com"912 return link913 914 915def _get_function_name(fn: Callable, depth: int = 0) -> str:916 if depth > 2 or not callable(fn):917 return str(fn)918 919 if hasattr(fn, "__name__"):920 return fn.__name__921 922 if isinstance(fn, functools.partial):923 return _get_function_name(fn.func, depth + 1)924 925 if hasattr(fn, "__call__"):926 if hasattr(fn, "__class__") and hasattr(fn.__class__, "__name__"):927 return fn.__class__.__name__928 return _get_function_name(fn.__call__, depth + 1)929 930 return str(fn)931 932 933def is_truish(val: Any) -> bool:934 """Check if the value is truish.935 936 Args:937 val (Any): The value to check.938 939 Returns:940 bool: True if the value is truish, False otherwise.941 """942 if isinstance(val, str):943 return val.lower() == "true" or val == "1"944 return bool(val)945 