Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-langsmith for openSUSE:Factory checked in at 2026-10-15 12:20:00 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-langsmith (Old) and /work/SRC/openSUSE:Factory/.python-langsmith.new.3698736 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-langsmith" Thu Oct 15 12:20:00 2026 rev:36 rq:1384905 version:0.14.8 Changes: -------- --- /work/SRC/openSUSE:Factory/python-langsmith/python-langsmith.changes 2026-10-10 15:00:42.660323586 +0200 +++ /work/SRC/openSUSE:Factory/.python-langsmith.new.3698736/python-langsmith.changes 2026-10-15 12:21:02.207318780 +0200 @@ -1,0 +2,9 @@ +Thu Oct 15 04:58:48 UTC 2026 - Martin Pluskal <[email protected]> + +- Update to 0.14.8: + * Keep session_id when copying a run tree; fall back to + /runs/batch when a run is not found + * Harden sandbox token/callback JWT verification with + algorithm allowlists; S3 mounts gain KMS encryption option + +------------------------------------------------------------------- Old: ---- langsmith-0.14.7.tar.gz New: ---- langsmith-0.14.8.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-langsmith.spec ++++++ --- /var/tmp/diff_new_pack.mnemQH/_old 2026-10-15 12:21:02.850345676 +0200 +++ /var/tmp/diff_new_pack.mnemQH/_new 2026-10-15 12:21:02.852345759 +0200 @@ -17,7 +17,7 @@ Name: python-langsmith -Version: 0.14.7 +Version: 0.14.8 Release: 0 Summary: Client library for the LangSmith LLM tracing and evaluation platform License: MIT ++++++ langsmith-0.14.7.tar.gz -> langsmith-0.14.8.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/langsmith-0.14.7/.bumpversion.cfg new/langsmith-0.14.8/.bumpversion.cfg --- old/langsmith-0.14.7/.bumpversion.cfg 2020-02-02 01:00:00.000000000 +0100 +++ new/langsmith-0.14.8/.bumpversion.cfg 2020-02-02 01:00:00.000000000 +0100 @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.14.7 +current_version = 0.14.8 parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+) serialize = {major}.{minor}.{patch} search = {current_version} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/langsmith-0.14.7/PKG-INFO new/langsmith-0.14.8/PKG-INFO --- old/langsmith-0.14.7/PKG-INFO 2020-02-02 01:00:00.000000000 +0100 +++ new/langsmith-0.14.8/PKG-INFO 2020-02-02 01:00:00.000000000 +0100 @@ -1,6 +1,6 @@ Metadata-Version: 2.5 Name: langsmith -Version: 0.14.7 +Version: 0.14.8 Summary: Client library to connect to the LangSmith Observability and Evaluation Platform. Project-URL: Homepage, https://smith.langchain.com/ Project-URL: Documentation, https://docs.smith.langchain.com/ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/langsmith-0.14.7/langsmith/__init__.py new/langsmith-0.14.8/langsmith/__init__.py --- old/langsmith-0.14.7/langsmith/__init__.py 2020-02-02 01:00:00.000000000 +0100 +++ new/langsmith-0.14.8/langsmith/__init__.py 2020-02-02 01:00:00.000000000 +0100 @@ -57,7 +57,7 @@ # Avoid calling into importlib on every call to __version__ -__version__ = "0.14.7" +__version__ = "0.14.8" version = __version__ # for backwards compatibility # Metadata key to hide a traced run from LangSmith's Messages View. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/langsmith-0.14.7/langsmith/client.py new/langsmith-0.14.8/langsmith/client.py --- old/langsmith-0.14.7/langsmith/client.py 2020-02-02 01:00:00.000000000 +0100 +++ new/langsmith-0.14.8/langsmith/client.py 2020-02-02 01:00:00.000000000 +0100 @@ -3697,6 +3697,9 @@ break except ls_utils.LangSmithConflictError: break + except ls_utils.LangSmithNotFoundError: + # Caller falls back to /runs/batch + raise except ( ls_utils.LangSmithConnectionError, ls_utils.LangSmithRequestTimeout, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/langsmith-0.14.7/langsmith/run_trees.py new/langsmith-0.14.8/langsmith/run_trees.py --- old/langsmith-0.14.7/langsmith/run_trees.py 2020-02-02 01:00:00.000000000 +0100 +++ new/langsmith-0.14.8/langsmith/run_trees.py 2020-02-02 01:00:00.000000000 +0100 @@ -1126,6 +1126,7 @@ outputs=self.outputs.copy() if self.outputs else None, error=self.error, parent_run_id=self.parent_run_id, + session_id=self.session_id, session_name=self.session_name, address=self.address, reference_example_id=self.reference_example_id, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/langsmith-0.14.7/langsmith/sandbox/README.md new/langsmith-0.14.8/langsmith/sandbox/README.md --- old/langsmith-0.14.7/langsmith/sandbox/README.md 2020-02-02 01:00:00.000000000 +0100 +++ new/langsmith-0.14.8/langsmith/sandbox/README.md 2020-02-02 01:00:00.000000000 +0100 @@ -881,9 +881,10 @@ ## Verifying Requests from LangSmith Install the extra: `pip install "langsmith[sandbox-auth]"`. `SandboxTokenVerifier` -checks EdDSA signatures against LangSmith's JWKS (derived from -`LANGSMITH_ENDPOINT`, cached). The JWKS must be served over HTTPS (localhost is -exempt); for a plain-HTTP self-hosted deployment on a trusted network, pass +checks signatures against LangSmith's JWKS (derived from `LANGSMITH_ENDPOINT`, +cached); user tokens may use any JOSE asymmetric algorithm (RS*, PS*, ES*, +EdDSA), chosen by the JWKS key. The JWKS must be served over HTTPS (localhost +is exempt); for a plain-HTTP self-hosted deployment on a trusted network, pass `allow_insecure_jwks=True`. ### Service URL Users diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/langsmith-0.14.7/langsmith/sandbox/__init__.py new/langsmith-0.14.8/langsmith/sandbox/__init__.py --- old/langsmith-0.14.7/langsmith/sandbox/__init__.py 2020-02-02 01:00:00.000000000 +0100 +++ new/langsmith-0.14.8/langsmith/sandbox/__init__.py 2020-02-02 01:00:00.000000000 +0100 @@ -94,6 +94,7 @@ GitMountRefSpec, GitMountSpec, MountCacheConfig, + MountEncryptionConfig, S3MountConfig, S3MountSpec, SandboxMount, @@ -184,6 +185,7 @@ "ContextHubMountSpec", "GCPMountAuthConfig", "MountCacheConfig", + "MountEncryptionConfig", "GCSMountConfig", "GCSMountSpec", "GitMountConfig", diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/langsmith-0.14.7/langsmith/sandbox/_mounts.py new/langsmith-0.14.8/langsmith/sandbox/_mounts.py --- old/langsmith-0.14.7/langsmith/sandbox/_mounts.py 2020-02-02 01:00:00.000000000 +0100 +++ new/langsmith-0.14.8/langsmith/sandbox/_mounts.py 2020-02-02 01:00:00.000000000 +0100 @@ -21,6 +21,16 @@ writeback_seconds: int +class MountEncryptionConfig(TypedDict): + """Customer-managed key that uploads through a mount are encrypted with. + + For S3 mounts, ``kms_key`` is a KMS key ARN in the mount's region and uploads + use SSE-KMS with it. + """ + + kms_key: str + + class BucketMountSpecBase(TypedDict, total=False): """Optional fields applied per bucket-backed sandbox mount.""" @@ -43,7 +53,13 @@ path_style: bool -class S3MountSpec(BucketMountSpecBase): +class S3MountSpecBase(BucketMountSpecBase, total=False): + """Optional fields applied per S3 sandbox mount.""" + + encryption: MountEncryptionConfig + + +class S3MountSpec(S3MountSpecBase): """S3-backed sandbox mount specification.""" id: str @@ -232,6 +248,7 @@ path_style: bool = False, read_only: bool | None = None, cache: MountCacheConfig | None = None, + encryption: MountEncryptionConfig | None = None, ) -> S3MountSpec: """Build an S3-backed sandbox mount specification.""" s3: S3MountConfig = { @@ -253,6 +270,12 @@ mount["read_only"] = read_only if cache is not None: mount["cache"] = _copy_cache_config(cache) + if encryption is not None: + mount["encryption"] = { + "kms_key": _require_non_empty_string( + encryption.get("kms_key", ""), "encryption.kms_key" + ) + } return mount diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/langsmith-0.14.7/langsmith/sandbox/_verify.py new/langsmith-0.14.8/langsmith/sandbox/_verify.py --- old/langsmith-0.14.7/langsmith/sandbox/_verify.py 2020-02-02 01:00:00.000000000 +0100 +++ new/langsmith-0.14.8/langsmith/sandbox/_verify.py 2020-02-02 01:00:00.000000000 +0100 @@ -33,6 +33,20 @@ CALLBACK_SIGNATURE_HEADER = "X-LangSmith-Signature-JWT" _CALLBACK_SUBJECT = "langsmith-sandbox-callback" +# Any JOSE asymmetric algorithm; PyJWK binds the algorithm to the key the kid selects. +_USER_TOKEN_ALGORITHMS = [ + "RS256", + "RS384", + "RS512", + "PS256", + "PS384", + "PS512", + "ES256", + "ES384", + "ES512", + "EdDSA", +] +_CALLBACK_ALGORITHMS = ["EdDSA"] _JWKS_PATH = "/.well-known/jwks.json" _JWKS_TTL_SECONDS = 300.0 # Bounds refetches when a token names a kid the cached set lacks. @@ -211,14 +225,14 @@ ``request.headers["Host"]``. A full service URL is also accepted. issuer: If set, the LangSmith app URL the token must be issued by. """ - kid = self._kid(token) + kid = self._kid(token, _USER_TOKEN_ALGORITHMS) return self._decode_user_token(token, self._key_sync(kid), audience, issuer) async def averify_user_token( self, token: str, *, audience: str, issuer: Optional[str] = None ) -> SandboxUser: """Async version of :meth:`verify_user_token`.""" - kid = self._kid(token) + kid = self._kid(token, _USER_TOKEN_ALGORITHMS) key = await self._key_async(kid) return self._decode_user_token(token, key, audience, issuer) @@ -242,7 +256,7 @@ issuer: If set, the LangSmith OAuth issuer the signature must be issued by. """ - kid = self._kid(signature) + kid = self._kid(signature, _CALLBACK_ALGORITHMS) return self._decode_callback(signature, self._key_sync(kid), body, aud, issuer) async def averify_callback( @@ -254,18 +268,18 @@ issuer: Optional[str] = None, ) -> SandboxCallback: """Async version of :meth:`verify_callback`.""" - kid = self._kid(signature) + kid = self._kid(signature, _CALLBACK_ALGORITHMS) key = await self._key_async(kid) return self._decode_callback(signature, key, body, aud, issuer) - def _kid(self, token: str) -> str: + def _kid(self, token: str, algorithms: list[str]) -> str: if not token: raise SandboxTokenVerificationError("token is empty") try: header = self._jwt.get_unverified_header(token) except self._jwt.PyJWTError as e: raise SandboxTokenVerificationError(f"malformed token: {e}") from e - if header.get("alg") != "EdDSA": + if header.get("alg") not in algorithms: raise SandboxTokenVerificationError( f"unexpected signing algorithm: {header.get('alg')!r}" ) @@ -281,12 +295,13 @@ audience: Optional[str], issuer: Optional[str], required: list[str], + algorithms: list[str], ) -> dict[str, Any]: try: return self._jwt.decode( token, key, - algorithms=["EdDSA"], + algorithms=algorithms, audience=audience, issuer=issuer.rstrip("/") if issuer else None, leeway=_LEEWAY_SECONDS, @@ -304,6 +319,7 @@ _service_host(audience), issuer, ["exp", "iat", "iss", "aud", "sub"], + _USER_TOKEN_ALGORITHMS, ) subject = claims["sub"] if not isinstance(subject, str) or not subject or subject == _CALLBACK_SUBJECT: @@ -329,6 +345,7 @@ aud if isinstance(aud, str) else None, issuer, ["exp", "iat", "iss", "aud", "sub", "body_sha256"], + _CALLBACK_ALGORITHMS, ) if callable(aud): audiences = claims["aud"] @@ -420,7 +437,7 @@ def _find_key(keys: Any, kid: str) -> Any: for k in keys.keys: if k.key_id == kid: - return k.key + return k return None diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/langsmith-0.14.7/tests/unit_tests/sandbox/test_proxy_config.py new/langsmith-0.14.8/tests/unit_tests/sandbox/test_proxy_config.py --- old/langsmith-0.14.7/tests/unit_tests/sandbox/test_proxy_config.py 2020-02-02 01:00:00.000000000 +0100 +++ new/langsmith-0.14.8/tests/unit_tests/sandbox/test_proxy_config.py 2020-02-02 01:00:00.000000000 +0100 @@ -215,6 +215,25 @@ } +def test_s3_mount_sets_kms_key_only_when_encryption_given() -> None: + kms_key = ( + "arn:aws:kms:us-east-1:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab" + ) + + assert "encryption" not in s3_mount(id="s3_data", mount_path="/mnt/s3", bucket="b") + mount = s3_mount( + id="s3_data", + mount_path="/mnt/s3", + bucket="b", + encryption={"kms_key": kms_key}, + ) + assert mount["encryption"] == {"kms_key": kms_key} + with pytest.raises(ValueError, match="encryption.kms_key"): + s3_mount( + id="s3_data", mount_path="/mnt/s3", bucket="b", encryption={"kms_key": " "} + ) + + def test_git_mount_serializes_backend_shape() -> None: assert git_mount( id="repo", diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/langsmith-0.14.7/tests/unit_tests/sandbox/test_verify.py new/langsmith-0.14.8/tests/unit_tests/sandbox/test_verify.py --- old/langsmith-0.14.7/tests/unit_tests/sandbox/test_verify.py 2020-02-02 01:00:00.000000000 +0100 +++ new/langsmith-0.14.8/tests/unit_tests/sandbox/test_verify.py 2020-02-02 01:00:00.000000000 +0100 @@ -10,8 +10,9 @@ import jwt import pytest +from cryptography.hazmat.primitives.asymmetric import ec, rsa from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey -from jwt.algorithms import OKPAlgorithm +from jwt.algorithms import ECAlgorithm, OKPAlgorithm, RSAAlgorithm from pytest_httpx import HTTPXMock from langsmith import utils as ls_utils @@ -27,6 +28,7 @@ SERVICE_HOST = "0190aaaa-0000-7000-8000-000000000001--8080.svc.example.com" CALLBACK_URL = "https://integrator.example.com/sandbox-callback" KID = "test-kid" +RSA_KID = "rsa-kid" @pytest.fixture(autouse=True) @@ -54,15 +56,57 @@ @pytest.fixture +def rsa_key(): + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + [email protected] +def rsa_jwks(httpx_mock: HTTPXMock, key, rsa_key): + rsa_jwk = { + **json.loads(RSAAlgorithm.to_jwk(rsa_key.public_key())), + "kid": RSA_KID, + "alg": "RS256", + "use": "sig", + } + httpx_mock.add_response( + url=JWKS_URL, json={"keys": [_jwk(key, KID), rsa_jwk]}, is_reusable=True + ) + + [email protected] +def ec_key(): + return ec.generate_private_key(ec.SECP256R1()) + + [email protected] +def mixed_jwks(httpx_mock: HTTPXMock, key, rsa_key, ec_key): + rsa_jwk = { + **json.loads(RSAAlgorithm.to_jwk(rsa_key.public_key())), + "kid": RSA_KID, + "alg": "PS256", + } + ec_jwk = {**json.loads(ECAlgorithm.to_jwk(ec_key.public_key())), "kid": "ec-kid"} + httpx_mock.add_response( + url=JWKS_URL, json={"keys": [_jwk(key, KID), rsa_jwk, ec_jwk]}, is_reusable=True + ) + + [email protected] def jwks(httpx_mock: HTTPXMock, key): httpx_mock.add_response( url=JWKS_URL, json={"keys": [_jwk(key, KID)]}, is_reusable=True ) -def _sign(key, claims: dict, kid: str = KID, headers: dict | None = None) -> str: +def _sign( + key, + claims: dict, + kid: str = KID, + headers: dict | None = None, + algorithm: str = "EdDSA", +) -> str: return jwt.encode( - claims, key, algorithm="EdDSA", headers={"kid": kid, **(headers or {})} + claims, key, algorithm=algorithm, headers={"kid": kid, **(headers or {})} ) @@ -165,6 +209,39 @@ with pytest.raises(SandboxTokenVerificationError, match="algorithm"): verifier.verify_user_token(token, audience=SERVICE_HOST) + def test_accepts_rs256(self, rsa_jwks, rsa_key, verifier): + token = _sign(rsa_key, _user_claims(), RSA_KID, algorithm="RS256") + user = verifier.verify_user_token(token, audience=SERVICE_HOST) + assert user.subject == "user-123" + + def test_rejects_algorithm_key_type_mismatch( + self, rsa_jwks, key, rsa_key, verifier + ): + eddsa_under_rsa_kid = _sign(key, _user_claims(), RSA_KID) + rs256_under_ed_kid = _sign(rsa_key, _user_claims(), KID, algorithm="RS256") + for token in (eddsa_under_rsa_kid, rs256_under_ed_kid): + with pytest.raises(SandboxTokenVerificationError): + verifier.verify_user_token(token, audience=SERVICE_HOST) + + @pytest.mark.parametrize("algorithm", ["ES256", "PS256"]) + def test_accepts_other_jose_algorithms( + self, mixed_jwks, rsa_key, ec_key, verifier, algorithm + ): + signer, kid = (ec_key, "ec-kid") if algorithm == "ES256" else (rsa_key, RSA_KID) + token = _sign(signer, _user_claims(), kid, algorithm=algorithm) + user = verifier.verify_user_token(token, audience=SERVICE_HOST) + assert user.subject == "user-123" + + def test_rejects_es256_under_rsa_key(self, mixed_jwks, ec_key, verifier): + token = _sign(ec_key, _user_claims(), RSA_KID, algorithm="ES256") + with pytest.raises(SandboxTokenVerificationError): + verifier.verify_user_token(token, audience=SERVICE_HOST) + + def test_rejects_alg_none(self, verifier): + token = jwt.encode(_user_claims(), None, algorithm="none", headers={"kid": KID}) + with pytest.raises(SandboxTokenVerificationError, match="algorithm"): + verifier.verify_user_token(token, audience=SERVICE_HOST) + def test_rejects_callback_signature(self, jwks, key, verifier): body = _callback_body() token = _sign(key, _callback_claims(body, aud=[SERVICE_HOST])) @@ -262,6 +339,12 @@ assert cb.identity.sandbox_id == json.loads(body)["identity"]["sandbox_id"] assert cb.request is None + def test_rejects_rs256(self, rsa_key, verifier): + body = _callback_body() + signature = _sign(rsa_key, _callback_claims(body), RSA_KID, algorithm="RS256") + with pytest.raises(SandboxTokenVerificationError, match="algorithm"): + verifier.verify_callback(body=body, signature=signature) + def test_full_request(self, jwks, key, verifier): body = _callback_body( request={ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/langsmith-0.14.7/tests/unit_tests/test_multipart_retry_matrix.py new/langsmith-0.14.8/tests/unit_tests/test_multipart_retry_matrix.py --- old/langsmith-0.14.7/tests/unit_tests/test_multipart_retry_matrix.py 2020-02-02 01:00:00.000000000 +0100 +++ new/langsmith-0.14.8/tests/unit_tests/test_multipart_retry_matrix.py 2020-02-02 01:00:00.000000000 +0100 @@ -9,7 +9,9 @@ *only* when the response actually carries a ``Retry-After`` header. 2. The ``for idx in range(1, attempts + 1)`` loop in ``_send_multipart_req``, which retries only ``LangSmithConnectionError`` / ``LangSmithRequestTimeout`` - / ``LangSmithAPIError`` (500) and swallows everything else. + / ``LangSmithAPIError`` (500), re-raises ``LangSmithNotFoundError`` (404) + so ``_multipart_ingest_ops`` can fall back to ``/runs/batch``, and swallows + everything else. A ``MagicMock`` session cannot see layer 1 at all, so these tests drive a real local HTTP server through the real adapter and count sockets hit. @@ -41,8 +43,10 @@ def __init__(self): self.count = 0 self.status = 200 + self.status_by_path: dict[str, int] = {} self.retry_after = None self.url = "" + self.paths: list[str] = [] @pytest.fixture @@ -65,8 +69,9 @@ if size == 0: break ep.count += 1 + ep.paths.append(self.path) body = b"{}" - self.send_response(ep.status) + self.send_response(ep.status_by_path.get(self.path, ep.status)) if ep.retry_after is not None: self.send_header("Retry-After", str(ep.retry_after)) self.send_header("Content-Type", "application/json") @@ -96,14 +101,8 @@ yield slept -def _payload(n_runs=1): - """Build a payload through the real serializer, so the log context is real. - - The per-op context string is ``trace=<trace_id>,id=<run_id>`` - (``_operations.py``), and ``join_multipart_parts_and_context`` concatenates - one of those per operation with ``"; "``. Hand-writing the context in a test - would pin a message shape that never occurs in production. - """ +def _ops(n_runs=1): + """Build serialized run ops; ``_payload`` turns them into a multipart body.""" ops = [] ids = [] for _ in range(n_runs): @@ -133,6 +132,18 @@ attachments=None, ) ) + return ops, ids + + +def _payload(n_runs=1): + """Build a payload through the real serializer, so the log context is real. + + The per-op context string is ``trace=<trace_id>,id=<run_id>`` + (``_operations.py``), and ``join_multipart_parts_and_context`` concatenates + one of those per operation with ``"; "``. Hand-writing the context in a test + would pin a message shape that never occurs in production. + """ + ops, ids = _ops(n_runs) parts = [ serialized_run_operation_to_multipart_parts_and_context(op)[0] for op in ops ] @@ -173,7 +184,8 @@ # Plain client errors are never retried. (401, None, 1, "none"), (403, None, 1, "none"), - (404, None, 1, "none"), + # 404 is re-raised so the caller can fall back to /runs/batch. + (404, None, 1, "none (raised)"), (422, None, 1, "none"), ] @@ -202,7 +214,11 @@ endpoint.retry_after = retry_after client = _client(endpoint) - client._send_multipart_req(_one_run_payload()) + if status == 404: + with pytest.raises(ls_utils.LangSmithNotFoundError): + client._send_multipart_req(_one_run_payload()) + else: + client._send_multipart_req(_one_run_payload()) assert endpoint.count == expected_requests, ( f"HTTP {status} (Retry-After={retry_after}) produced {endpoint.count} " @@ -351,7 +367,6 @@ (409, False), # swallowed as a duplicate -- silently dropped, no dump (500, True), (429, True), - (404, True), ], ) def test_exhausted_batch_is_dropped_not_requeued( @@ -392,6 +407,18 @@ assert isinstance(errors[0], ls_utils.LangSmithAPIError) +def test_404_falls_back_to_batch_and_disables_multipart(endpoint, no_sleep): + """A 404 on /runs/multipart resends the runs via /runs/batch (LSDK-551).""" + endpoint.status_by_path["/runs/multipart"] = 404 + client = _client(endpoint) + ops, _ = _ops(1) + + client._multipart_ingest_ops(ops) + + assert endpoint.paths == ["/runs/multipart", "/runs/batch"] + assert client._multipart_disabled is True + + # --------------------------------------------------------------------------- # The warning log is the ONLY customer-visible signal that traces were dropped # (the error callback is opt-in, the fallback dir is opt-in). These tests pin diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/langsmith-0.14.7/tests/unit_tests/test_run_trees.py new/langsmith-0.14.8/tests/unit_tests/test_run_trees.py --- old/langsmith-0.14.7/tests/unit_tests/test_run_trees.py 2020-02-02 01:00:00.000000000 +0100 +++ new/langsmith-0.14.8/tests/unit_tests/test_run_trees.py 2020-02-02 01:00:00.000000000 +0100 @@ -6,7 +6,7 @@ from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timedelta, timezone from unittest.mock import MagicMock -from uuid import UUID +from uuid import UUID, uuid4 import pytest from multipart import MultipartParser, parse_options_header @@ -808,6 +808,40 @@ assert sent is None +def test_patch_sends_project_id(): + """A run addressed by project id keeps that id on the update, so the patch + resolves the same project as the post even if the project was renamed.""" + client = MagicMock() + project_id = uuid4() + run_tree = RunTree( + name="test_run", + run_type="chain", + inputs={"a": 1}, + client=client, + project_name="test-project", + project_id=project_id, + ) + run_tree.patch() + + sent = client.update_run.call_args.kwargs + assert sent["session_id"] == project_id + assert sent["session_name"] == "test-project" + + +def test_patch_without_project_id_sends_none(): + client = MagicMock() + run_tree = RunTree( + name="test_run", + run_type="chain", + inputs={"a": 1}, + client=client, + project_name="test-project", + ) + run_tree.patch() + + assert client.update_run.call_args.kwargs["session_id"] is None + + def test_patch_exclude_inputs_env_flag_with_replicas( monkeypatch, _reset_exclude_inputs_cache ):
