diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 009b7821..ac8eac82 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -29,3 +29,27 @@ jobs:
- name: Run lints
run: ./scripts/lint
+
+ upload:
+ if: github.repository == 'stainless-sdks/openlayer-python'
+ timeout-minutes: 10
+ name: upload
+ permissions:
+ contents: read
+ id-token: write
+ runs-on: depot-ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Get GitHub OIDC Token
+ id: github-oidc
+ uses: actions/github-script@v6
+ with:
+ script: core.setOutput('github_token', await core.getIDToken());
+
+ - name: Upload tarball
+ env:
+ URL: https://pkg.stainless.com/s
+ AUTH: ${{ steps.github-oidc.outputs.github_token }}
+ SHA: ${{ github.sha }}
+ run: ./scripts/utils/upload-artifact.sh
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 5d9c21c9..fd599489 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "0.2.0-alpha.62"
+ ".": "0.2.0-alpha.63"
}
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3b10d5f5..d322990b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
+## 0.2.0-alpha.63 (2025-06-03)
+
+Full Changelog: [v0.2.0-alpha.62...v0.2.0-alpha.63](https://github.com/openlayer-ai/openlayer-python/compare/v0.2.0-alpha.62...v0.2.0-alpha.63)
+
+### Features
+
+* add MLflow notebook example ([149e85f](https://github.com/openlayer-ai/openlayer-python/commit/149e85f075db80c9800fd8dff58b277341a3384c))
+* add OpenLIT notebook example ([f71c668](https://github.com/openlayer-ai/openlayer-python/commit/f71c66895d38b0245f8a5da4c000e6bf747ef4c8))
+* **client:** add follow_redirects request option ([87d8986](https://github.com/openlayer-ai/openlayer-python/commit/87d89863dd9c4f700b8a8910ce14d2a961404336))
+
+
+### Bug Fixes
+
+* **package:** support direct resource imports ([8407753](https://github.com/openlayer-ai/openlayer-python/commit/84077531a8491bc48c8fe5d67a9076a27ba21fce))
+
+
+### Chores
+
+* **ci:** fix installation instructions ([d7d4fd2](https://github.com/openlayer-ai/openlayer-python/commit/d7d4fd2e5464f87660a30edd1067aef930b2249a))
+* **ci:** upload sdks to package manager ([0aadb0a](https://github.com/openlayer-ai/openlayer-python/commit/0aadb0a4deed48d46981fd44b308fba5bbc5a3c1))
+* **docs:** grammar improvements ([27794bc](https://github.com/openlayer-ai/openlayer-python/commit/27794bc2ff2f34c10c1635fcf14677e0711a8af0))
+* **docs:** remove reference to rye shell ([9f8db4a](https://github.com/openlayer-ai/openlayer-python/commit/9f8db4a42a79af923d55ec636e43bf49ce80bc50))
+* **internal:** avoid errors for isinstance checks on proxies ([3de384b](https://github.com/openlayer-ai/openlayer-python/commit/3de384be80ba27ba97a6079a78b75cdeadf55e5f))
+* **internal:** codegen related update ([120114a](https://github.com/openlayer-ai/openlayer-python/commit/120114ad9d40ce7c41112522f2951dd92be61eaf))
+* **internal:** codegen related update ([f990977](https://github.com/openlayer-ai/openlayer-python/commit/f990977209f13f02b1b87ab98bef5eef50414ea9))
+* link to OpenLLMetry integration guide ([ffcd085](https://github.com/openlayer-ai/openlayer-python/commit/ffcd085e1ad58e2b88fac6f739b6a9a12ba05844))
+* remove MLflow example ([17256c9](https://github.com/openlayer-ai/openlayer-python/commit/17256c96873cef5b085400ad64af860c35de4cf4))
+* sync repo ([caa47dc](https://github.com/openlayer-ai/openlayer-python/commit/caa47dc5b9d671046dca4dd5378a72018ed5d334))
+
## 0.2.0-alpha.62 (2025-04-29)
Full Changelog: [v0.2.0-alpha.61...v0.2.0-alpha.62](https://github.com/openlayer-ai/openlayer-python/compare/v0.2.0-alpha.61...v0.2.0-alpha.62)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 1a053ce9..da31df73 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -17,8 +17,7 @@ $ rye sync --all-features
You can then run scripts using `rye run python script.py` or by activating the virtual environment:
```sh
-$ rye shell
-# or manually activate - https://docs.python.org/3/library/venv.html#how-venvs-work
+# Activate the virtual environment - https://docs.python.org/3/library/venv.html#how-venvs-work
$ source .venv/bin/activate
# now you can omit the `rye run` prefix
diff --git a/SECURITY.md b/SECURITY.md
index 8614b059..dc108d01 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -16,11 +16,11 @@ before making any information public.
## Reporting Non-SDK Related Security Issues
If you encounter security issues that are not directly related to SDKs but pertain to the services
-or products provided by Openlayer please follow the respective company's security reporting guidelines.
+or products provided by Openlayer, please follow the respective company's security reporting guidelines.
### Openlayer Terms and Policies
-Please contact support@openlayer.com for any questions or concerns regarding security of our services.
+Please contact support@openlayer.com for any questions or concerns regarding the security of our services.
---
diff --git a/examples/tracing/openlit/openlit_tracing.ipynb b/examples/tracing/openlit/openlit_tracing.ipynb
deleted file mode 100644
index d43674b4..00000000
--- a/examples/tracing/openlit/openlit_tracing.ipynb
+++ /dev/null
@@ -1,125 +0,0 @@
-{
- "cells": [
- {
- "cell_type": "markdown",
- "id": "2722b419",
- "metadata": {},
- "source": [
- "[](https://colab.research.google.com/github/openlayer-ai/openlayer-python/blob/main/examples/tracing/openlit/openlit_tracing.ipynb)\n",
- "\n",
- "\n",
- "# OpenLIT quickstart\n",
- "\n",
- "This notebook shows how to export traces captured by [OpenLIT](https://docs.openlit.io/latest/features/tracing) to Openlayer. The integration is done via the Openlayer's [OpenTelemetry endpoint](https://www.openlayer.com/docs/integrations/opentelemetry). For more information, refer to the [OpenLIT integration guide](https://www.openlayer.com/docs/integrations/openlit)."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "id": "020c8f6a",
- "metadata": {},
- "outputs": [],
- "source": [
- "!pip install openai openlit"
- ]
- },
- {
- "cell_type": "markdown",
- "id": "75c2a473",
- "metadata": {},
- "source": [
- "## 1. Set the environment variables"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 1,
- "id": "f3f4fa13",
- "metadata": {},
- "outputs": [],
- "source": [
- "import os\n",
- "\n",
- "import openai\n",
- "\n",
- "os.environ[\"OPENAI_API_KEY\"] = \"YOUR_OPENAI_API_KEY_HERE\"\n",
- "\n",
- "os.environ[\"OTEL_EXPORTER_OTLP_ENDPOINT\"] = \"https://api.openlayer.com/v1/otel\"\n",
- "os.environ[\"OTEL_EXPORTER_OTLP_HEADERS\"] = \"Authorization=Bearer YOUR_OPENLAYER_API_KEY_HERE, x-bt-parent=pipeline_id:YOUR_OPENLAYER_PIPELINE_ID_HERE\""
- ]
- },
- {
- "cell_type": "markdown",
- "id": "9758533f",
- "metadata": {},
- "source": [
- "## 2. Initialize OpenLIT instrumentation"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "id": "c35d9860-dc41-4f7c-8d69-cc2ac7e5e485",
- "metadata": {},
- "outputs": [],
- "source": [
- "import openlit\n",
- "\n",
- "openlit.init(disable_batch=True)"
- ]
- },
- {
- "cell_type": "markdown",
- "id": "72a6b954",
- "metadata": {},
- "source": [
- "## 3. Use LLMs and workflows as usual\n",
- "\n",
- "That's it! Now you can continue using LLMs and workflows as usual.The trace data is automatically exported to Openlayer and you can start creating tests around it."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 3,
- "id": "e00c1c79",
- "metadata": {},
- "outputs": [],
- "source": [
- "client = openai.OpenAI()"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "id": "abaf6987-c257-4f0d-96e7-3739b24c7206",
- "metadata": {},
- "outputs": [],
- "source": [
- "client.chat.completions.create(\n",
- " model=\"gpt-4o-mini\", messages=[{\"role\": \"user\", \"content\": \"How are you doing today?\"}]\n",
- ")"
- ]
- }
- ],
- "metadata": {
- "kernelspec": {
- "display_name": "openlayer-assistant",
- "language": "python",
- "name": "python3"
- },
- "language_info": {
- "codemirror_mode": {
- "name": "ipython",
- "version": 3
- },
- "file_extension": ".py",
- "mimetype": "text/x-python",
- "name": "python",
- "nbconvert_exporter": "python",
- "pygments_lexer": "ipython3",
- "version": "3.9.18"
- }
- },
- "nbformat": 4,
- "nbformat_minor": 5
-}
diff --git a/examples/tracing/openllmetry/openllmetry_tracing.ipynb b/examples/tracing/openllmetry/openllmetry_tracing.ipynb
index bb215775..eb1833ed 100644
--- a/examples/tracing/openllmetry/openllmetry_tracing.ipynb
+++ b/examples/tracing/openllmetry/openllmetry_tracing.ipynb
@@ -10,7 +10,7 @@
"\n",
"# OpenLLMetry quickstart\n",
"\n",
- "This notebook shows how to export traces captured by [OpenLLMetry](https://github.com/traceloop/openllmetry) (by Traceloop) to Openlayer. The integration is done via the Openlayer's [OpenTelemetry endpoint](https://www.openlayer.com/docs/integrations/opentelemetry). For more information, refer to the [OpenLLMetry integration guide](https://www.openlayer.com/docs/integrations/openllmetry)."
+ "This notebook shows how to export traces captured by [OpenLLMetry](https://github.com/traceloop/openllmetry) (by Traceloop) to Openlayer. The integration is done via the Openlayer's [OpenTelemetry endpoint](https://www.openlayer.com/docs/integrations/opentelemetry)."
]
},
{
@@ -62,7 +62,15 @@
"execution_count": null,
"id": "c35d9860-dc41-4f7c-8d69-cc2ac7e5e485",
"metadata": {},
- "outputs": [],
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "Failed to export batch code: 404, reason: {\"error\": \"The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.\", \"code\": 404}\n"
+ ]
+ }
+ ],
"source": [
"from traceloop.sdk import Traceloop\n",
"\n",
diff --git a/pyproject.toml b/pyproject.toml
index 7333be69..99b45518 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "openlayer"
-version = "0.2.0-alpha.62"
+version = "0.2.0-alpha.63"
description = "The official Python library for the openlayer API"
dynamic = ["readme"]
license = "Apache-2.0"
diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh
new file mode 100755
index 00000000..e7a0c9ec
--- /dev/null
+++ b/scripts/utils/upload-artifact.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+set -exuo pipefail
+
+RESPONSE=$(curl -X POST "$URL" \
+ -H "Authorization: Bearer $AUTH" \
+ -H "Content-Type: application/json")
+
+SIGNED_URL=$(echo "$RESPONSE" | jq -r '.url')
+
+if [[ "$SIGNED_URL" == "null" ]]; then
+ echo -e "\033[31mFailed to get signed URL.\033[0m"
+ exit 1
+fi
+
+UPLOAD_RESPONSE=$(tar -cz . | curl -v -X PUT \
+ -H "Content-Type: application/gzip" \
+ --data-binary @- "$SIGNED_URL" 2>&1)
+
+if echo "$UPLOAD_RESPONSE" | grep -q "HTTP/[0-9.]* 200"; then
+ echo -e "\033[32mUploaded build to Stainless storage.\033[0m"
+ echo -e "\033[32mInstallation: pip install --pre 'https://pkg.stainless.com/s/openlayer-python/$SHA'\033[0m"
+else
+ echo -e "\033[31mFailed to upload artifact.\033[0m"
+ exit 1
+fi
diff --git a/src/openlayer/__init__.py b/src/openlayer/__init__.py
index e6918d32..8b434e24 100644
--- a/src/openlayer/__init__.py
+++ b/src/openlayer/__init__.py
@@ -1,5 +1,7 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+import typing as _t
+
from . import types
from ._types import NOT_GIVEN, Omit, NoneType, NotGiven, Transport, ProxiesTypes
from ._utils import file_from_path
@@ -78,6 +80,9 @@
"DefaultAsyncHttpxClient",
]
+if not _t.TYPE_CHECKING:
+ from ._utils._resources_proxy import resources as resources
+
_setup_logging()
# Update the __module__ attribute for exported symbols so that
diff --git a/src/openlayer/_base_client.py b/src/openlayer/_base_client.py
index df1dab62..718469f7 100644
--- a/src/openlayer/_base_client.py
+++ b/src/openlayer/_base_client.py
@@ -960,6 +960,9 @@ def request(
if self.custom_auth is not None:
kwargs["auth"] = self.custom_auth
+ if options.follow_redirects is not None:
+ kwargs["follow_redirects"] = options.follow_redirects
+
log.debug("Sending HTTP Request: %s %s", request.method, request.url)
response = None
@@ -1460,6 +1463,9 @@ async def request(
if self.custom_auth is not None:
kwargs["auth"] = self.custom_auth
+ if options.follow_redirects is not None:
+ kwargs["follow_redirects"] = options.follow_redirects
+
log.debug("Sending HTTP Request: %s %s", request.method, request.url)
response = None
diff --git a/src/openlayer/_models.py b/src/openlayer/_models.py
index 798956f1..4f214980 100644
--- a/src/openlayer/_models.py
+++ b/src/openlayer/_models.py
@@ -737,6 +737,7 @@ class FinalRequestOptionsInput(TypedDict, total=False):
idempotency_key: str
json_data: Body
extra_json: AnyMapping
+ follow_redirects: bool
@final
@@ -750,6 +751,7 @@ class FinalRequestOptions(pydantic.BaseModel):
files: Union[HttpxRequestFiles, None] = None
idempotency_key: Union[str, None] = None
post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven()
+ follow_redirects: Union[bool, None] = None
# It should be noted that we cannot use `json` here as that would override
# a BaseModel method in an incompatible fashion.
diff --git a/src/openlayer/_types.py b/src/openlayer/_types.py
index c19dc25f..75357538 100644
--- a/src/openlayer/_types.py
+++ b/src/openlayer/_types.py
@@ -100,6 +100,7 @@ class RequestOptions(TypedDict, total=False):
params: Query
extra_json: AnyMapping
idempotency_key: str
+ follow_redirects: bool
# Sentinel class used until PEP 0661 is accepted
@@ -215,3 +216,4 @@ class _GenericAlias(Protocol):
class HttpxSendArgs(TypedDict, total=False):
auth: httpx.Auth
+ follow_redirects: bool
diff --git a/src/openlayer/_utils/_proxy.py b/src/openlayer/_utils/_proxy.py
index ffd883e9..0f239a33 100644
--- a/src/openlayer/_utils/_proxy.py
+++ b/src/openlayer/_utils/_proxy.py
@@ -46,7 +46,10 @@ def __dir__(self) -> Iterable[str]:
@property # type: ignore
@override
def __class__(self) -> type: # pyright: ignore
- proxied = self.__get_proxied__()
+ try:
+ proxied = self.__get_proxied__()
+ except Exception:
+ return type(self)
if issubclass(type(proxied), LazyProxy):
return type(proxied)
return proxied.__class__
diff --git a/src/openlayer/_utils/_resources_proxy.py b/src/openlayer/_utils/_resources_proxy.py
new file mode 100644
index 00000000..d1c684e5
--- /dev/null
+++ b/src/openlayer/_utils/_resources_proxy.py
@@ -0,0 +1,24 @@
+from __future__ import annotations
+
+from typing import Any
+from typing_extensions import override
+
+from ._proxy import LazyProxy
+
+
+class ResourcesProxy(LazyProxy[Any]):
+ """A proxy for the `openlayer.resources` module.
+
+ This is used so that we can lazily import `openlayer.resources` only when
+ needed *and* so that users can just import `openlayer` and reference `openlayer.resources`
+ """
+
+ @override
+ def __load__(self) -> Any:
+ import importlib
+
+ mod = importlib.import_module("openlayer.resources")
+ return mod
+
+
+resources = ResourcesProxy().__as_proxied__()
diff --git a/src/openlayer/_version.py b/src/openlayer/_version.py
index e013ded0..37ec914f 100644
--- a/src/openlayer/_version.py
+++ b/src/openlayer/_version.py
@@ -1,4 +1,4 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
__title__ = "openlayer"
-__version__ = "0.2.0-alpha.62" # x-release-please-version
+__version__ = "0.2.0-alpha.63" # x-release-please-version
diff --git a/tests/test_client.py b/tests/test_client.py
index 265760da..7562a048 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -921,6 +921,33 @@ def retry_handler(_request: httpx.Request) -> httpx.Response:
assert response.http_request.headers.get("x-stainless-retry-count") == "42"
+ @pytest.mark.respx(base_url=base_url)
+ def test_follow_redirects(self, respx_mock: MockRouter) -> None:
+ # Test that the default follow_redirects=True allows following redirects
+ respx_mock.post("/redirect").mock(
+ return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"})
+ )
+ respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"}))
+
+ response = self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response)
+ assert response.status_code == 200
+ assert response.json() == {"status": "ok"}
+
+ @pytest.mark.respx(base_url=base_url)
+ def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None:
+ # Test that follow_redirects=False prevents following redirects
+ respx_mock.post("/redirect").mock(
+ return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"})
+ )
+
+ with pytest.raises(APIStatusError) as exc_info:
+ self.client.post(
+ "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response
+ )
+
+ assert exc_info.value.response.status_code == 302
+ assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected"
+
class TestAsyncOpenlayer:
client = AsyncOpenlayer(base_url=base_url, api_key=api_key, _strict_response_validation=True)
@@ -1847,3 +1874,30 @@ async def test_main() -> None:
raise AssertionError("calling get_platform using asyncify resulted in a hung process")
time.sleep(0.1)
+
+ @pytest.mark.respx(base_url=base_url)
+ async def test_follow_redirects(self, respx_mock: MockRouter) -> None:
+ # Test that the default follow_redirects=True allows following redirects
+ respx_mock.post("/redirect").mock(
+ return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"})
+ )
+ respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"}))
+
+ response = await self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response)
+ assert response.status_code == 200
+ assert response.json() == {"status": "ok"}
+
+ @pytest.mark.respx(base_url=base_url)
+ async def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None:
+ # Test that follow_redirects=False prevents following redirects
+ respx_mock.post("/redirect").mock(
+ return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"})
+ )
+
+ with pytest.raises(APIStatusError) as exc_info:
+ await self.client.post(
+ "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response
+ )
+
+ assert exc_info.value.response.status_code == 302
+ assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected"
diff --git a/tests/test_utils/test_proxy.py b/tests/test_utils/test_proxy.py
index 7f09e39e..da6f4851 100644
--- a/tests/test_utils/test_proxy.py
+++ b/tests/test_utils/test_proxy.py
@@ -21,3 +21,14 @@ def test_recursive_proxy() -> None:
assert dir(proxy) == []
assert type(proxy).__name__ == "RecursiveLazyProxy"
assert type(operator.attrgetter("name.foo.bar.baz")(proxy)).__name__ == "RecursiveLazyProxy"
+
+
+def test_isinstance_does_not_error() -> None:
+ class AlwaysErrorProxy(LazyProxy[Any]):
+ @override
+ def __load__(self) -> Any:
+ raise RuntimeError("Mocking missing dependency")
+
+ proxy = AlwaysErrorProxy()
+ assert not isinstance(proxy, dict)
+ assert isinstance(proxy, LazyProxy)