Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 59 additions & 11 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,10 @@ permissions:
contents: read

jobs:
build-and-attest:
quality:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
attestations: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand All @@ -30,20 +28,34 @@ jobs:
env:
RELEASE_TAG: ${{ github.ref_name }}
run: python -c "import os, unified_llm; expected = f'v{unified_llm.__version__}'; assert os.environ['RELEASE_TAG'] == expected, f'tag must be {expected}'"
- name: Require the tag to identify the current public main commit
if: github.ref_type == 'tag'
run: |
git fetch --no-tags origin main
test "$GITHUB_SHA" = "$(git rev-parse FETCH_HEAD)"
- name: Verify the exact release source
run: |
python -m ruff check .
python -m ruff format --check .
python -m mypy unified_llm tests examples
python -m pytest --cov=unified_llm --cov-report=term-missing
- name: Build and validate distributions
run: |
python -m build
python -m twine check dist/*
- name: Generate SLSA provenance
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2

build-distributions:
needs: quality
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
subject-path: dist/*
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"
- name: Install the hash-locked artifact builder
run: python -m pip install --require-hashes -r requirements/release-build.txt
- name: Build distributions without isolated dependency resolution
run: python -m build --no-isolation
- name: Store distributions
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
Expand All @@ -52,9 +64,45 @@ jobs:
if-no-files-found: error
retention-days: 30

validate-distributions:
needs: build-distributions
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"
- name: Install validation tooling
run: python -m pip install "twine>=6,<7"
- name: Download completed distributions
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: python-package-distributions
path: dist/
- name: Validate distribution metadata
run: python -m twine check dist/*

attest:
needs: validate-distributions
runs-on: ubuntu-latest
permissions:
id-token: write
attestations: write
steps:
- name: Download completed distributions
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: python-package-distributions
path: dist/
- name: Generate SLSA provenance
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-path: dist/*

publish-to-pypi:
if: github.event_name == 'push' && github.ref_type == 'tag'
needs: build-and-attest
needs: attest
runs-on: ubuntu-latest
environment:
name: pypi
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,12 @@ All notable changes to this project will be documented here. The project follows
### Changed

- Request byte accounting now measures each exact outbound provider body, including its resolved model and generation fields.
- Request validation and one-at-a-time route serialization now run inside the configured concurrency boundary.
- The built-in HTTP adapter now streams successful response bodies into a bounded buffer before JSON decoding.
- Built-in provider-specific payload builders now let the router account for exact Chat Completions and Responses request bodies.
- Sanitized adapter failures no longer retain secret-bearing exception causes or contexts.
- Release artifacts use a hash-locked builder closure, a current-main tag gate, and an isolated attestation-only OIDC job.
- Runnable provider examples represent untrusted text safely before writing it to a terminal.

## 0.1.0 - 2026-07-28

Expand Down
1 change: 1 addition & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Samsarix LLC does not publish a response-time or remediation-time service-level

- Endpoint URLs, API keys, model IDs, custom headers, and custom provider adapters are trusted operator configuration.
- Message and tool content can be untrusted and crosses the configured provider boundary as JSON.
- Normalized provider text and metadata remain untrusted output; terminal and log consumers must encode control characters.
- The core stores no prompts, responses, keys, usage records, or telemetry.
- Remote provider retention, training, authorization, quotas, and billing remain operator responsibilities.
- Retries are bounded and transient-only, but a lost response can still duplicate provider work.
Expand Down
4 changes: 2 additions & 2 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ No package has been published from this repository yet. This process makes relea
1. Confirm that the `unified-llm` PyPI project name is available or controlled by Samsarix LLC.
2. In PyPI, register a pending GitHub Trusted Publisher for owner `Deathcharge`, repository `unified-llm`, workflow `release.yml`, and environment `pypi`.
3. Create the GitHub `pypi` environment and require manual approval. Restrict deployment to protected version tags when repository policy permits it.
4. Protect `main` and release tags, require the CI checks, and review any change to `.github/workflows/release.yml` as a credential-equivalent security change. Release builds intentionally do not restore dependency caches.
4. Protect `main` and release tags, require the CI checks, and review any change to `.github/workflows/release.yml` or `requirements/release-build.txt` as a credential-equivalent security change. Release builds intentionally do not restore dependency caches.
5. Ensure at least two Samsarix-controlled recovery methods exist for the PyPI and GitHub owner accounts.

Do not add a long-lived PyPI token to repository secrets. The workflow requests a short-lived OIDC credential only inside the `pypi` environment.
Expand All @@ -24,7 +24,7 @@ Before tagging:

## Publication

Create and push an annotated tag exactly matching `v` plus the package version, for example `v0.1.0`. The release workflow rejects a mismatched tag, rebuilds the source distribution and wheel, checks their metadata, creates GitHub-hosted SLSA provenance, and pauses at the protected `pypi` environment. An owner must inspect the run and approve that deployment before PyPI receives anything.
Create and push an annotated tag exactly matching `v` plus the package version, for example `v0.1.0`, on the current public `main` commit. The release workflow rejects a mismatched or off-main tag. It builds the source distribution and wheel with the hash-locked builder closure in `requirements/release-build.txt`, validates them in a separate unprivileged job, creates GitHub-hosted SLSA provenance in an attestation-only OIDC job, and pauses at the protected `pypi` environment. An owner must inspect the run and approve that deployment before PyPI receives anything.

The official PyPA publishing action uses Trusted Publishing and uploads PyPI attestations by default. After approval, verify the project page, both distributions, their hashes and attestations, and installation in a clean supported Python environment. Then create the GitHub release from the same tag and attach or link the verification record.

Expand Down
4 changes: 2 additions & 2 deletions examples/from_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ async def main() -> None:
)
except UnifiedLLMError as exc:
raise SystemExit(f"Request failed safely: {exc}") from exc
print(result.content)
print(f"provider={result.provider} model={result.model} total_tokens={result.total_tokens}")
print(f"content={result.content!r}")
print(f"provider={result.provider!r} model={result.model!r} total_tokens={result.total_tokens}")


if __name__ == "__main__":
Expand Down
4 changes: 2 additions & 2 deletions examples/responses_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ async def main() -> None:
max_tokens=300,
temperature=0.2,
)
print(response.content)
print(f"provider={response.provider} model={response.model} tokens={response.total_tokens}")
print(f"content={response.content!r}")
print(f"provider={response.provider!r} model={response.model!r} tokens={response.total_tokens}")


if __name__ == "__main__":
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[build-system]
requires = ["setuptools>=77"]
requires = ["setuptools==83.0.0"]
build-backend = "setuptools.build_meta"

[project]
Expand Down
11 changes: 11 additions & 0 deletions requirements/release-build.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Reviewed pure-Python artifact-build closure. Update versions and hashes together.
build==1.5.0 \
--hash=sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f
colorama==0.4.6; os_name == "nt" \
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
packaging==24.2 \
--hash=sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759
pyproject-hooks==1.2.0 \
--hash=sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913
setuptools==83.0.0 \
--hash=sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3
17 changes: 17 additions & 0 deletions tests/test_release_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,20 @@ def test_release_workflow_uses_tokenless_approved_publication() -> None:
assert "python -m pytest --cov=unified_llm" in source
assert "password:" not in source
assert "secrets." not in source
assert 'test "$GITHUB_SHA" = "$(git rev-parse FETCH_HEAD)"' in source
assert "pip install --require-hashes -r requirements/release-build.txt" in source
assert "python -m build --no-isolation" in source


def test_release_attestation_privileges_are_isolated_from_project_code() -> None:
source = (WORKFLOWS / "release.yml").read_text(encoding="utf-8")
build_jobs, after_attest = source.split(" attest:\n", maxsplit=1)
attest_job, publish_job = after_attest.split(" publish-to-pypi:\n", maxsplit=1)

assert "id-token: write" not in build_jobs
assert "attestations: write" not in build_jobs
assert "id-token: write" in attest_job
assert "actions/attest@" in attest_job
assert "python -m pip install" not in attest_job
assert "python -m pytest" not in attest_job
Comment thread
coderabbitai[bot] marked this conversation as resolved.
assert "needs: attest" in publish_job
16 changes: 16 additions & 0 deletions tests/test_router.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import asyncio
import traceback

import pytest

Expand Down Expand Up @@ -222,9 +223,24 @@ async def test_unexpected_adapter_exception_is_sanitized() -> None:
with pytest.raises(ProviderError) as caught:
await client.generate("Hi")
assert "secret-token" not in str(caught.value)
assert "secret-token" not in "".join(traceback.format_exception(caught.value))
assert caught.value.__cause__ is None
assert caught.value.__context__ is None
assert caught.value.attempts[0].error == "adapter_error"


async def test_permanent_provider_error_does_not_retain_secret_context() -> None:
provider = ScriptedProvider("primary", [ProviderError("primary", "secret-body", retryable=False)])
client = UnifiedLLM([Route(provider, "model-a")])

with pytest.raises(ProviderError) as caught:
await client.generate("Hi")

assert "secret-body" not in "".join(traceback.format_exception(caught.value))
assert caught.value.__cause__ is None
assert caught.value.__context__ is None


async def test_context_manager_closes_provider() -> None:
provider = ScriptedProvider("primary", [response()])
async with UnifiedLLM([Route(provider, "model-a")]) as client:
Expand Down
49 changes: 28 additions & 21 deletions unified_llm/unified_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -943,11 +943,11 @@ async def chat_with_metadata(
"""Run the request through the selected bounded route set."""

selected = self._select_routes(provider=provider, model=model)
normalized_messages = self._validate_request(messages, selected, max_tokens, temperature, tools)
attempts: list[Attempt] = []
total_attempts = 0

async with self._semaphore:
normalized_messages = self._validate_request(messages, selected, max_tokens, temperature, tools)
if provider is None:
selected = self._prioritize_healthy_routes(selected)
for route, resolved_model in selected:
Expand All @@ -956,6 +956,7 @@ async def chat_with_metadata(
raise FallbackExhausted(attempts)
total_attempts += 1
started = time.perf_counter()
terminal_error: ProviderError | None = None
try:
response = await asyncio.wait_for(
route.provider.complete(
Expand Down Expand Up @@ -989,18 +990,21 @@ async def chat_with_metadata(
continue
break
except ProviderError as exc:
should_continue = await self._handle_provider_error(
outcome = await self._handle_provider_error(
exc,
route,
resolved_model,
route_attempt,
started,
attempts,
)
if should_continue:
if isinstance(outcome, ProviderError):
terminal_error = outcome
elif outcome:
continue
break
except Exception as exc:
else:
break
except Exception:
attempt = Attempt(
route.provider.name,
resolved_model,
Expand All @@ -1011,12 +1015,12 @@ async def chat_with_metadata(
)
attempts.append(attempt)
await self._notify_attempt(attempt)
raise ProviderError(
terminal_error = ProviderError(
route.provider.name,
f"Provider adapter {route.provider.name!r} failed unexpectedly.",
retryable=False,
attempts=attempts,
) from exc
)
else:
attempt = Attempt(
route.provider.name,
Expand All @@ -1033,6 +1037,10 @@ async def chat_with_metadata(
model=response.model or resolved_model,
attempts=tuple(attempts),
)
if terminal_error is not None:
# Raise outside the adapter exception handler so secret-bearing
# causes and contexts cannot cross the public SDK boundary.
raise terminal_error from None

raise FallbackExhausted(attempts)

Expand All @@ -1044,7 +1052,7 @@ async def _handle_provider_error(
route_attempt: int,
started: float,
attempts: list[Attempt],
) -> bool:
) -> bool | ProviderError:
attempt = Attempt(
route.provider.name,
model,
Expand All @@ -1061,13 +1069,13 @@ async def _handle_provider_error(
if error.status_code is not None
else f"Provider {route.provider.name!r} failed."
)
raise ProviderError(
return ProviderError(
route.provider.name,
message,
status_code=error.status_code,
retryable=False,
attempts=attempts,
) from error
)
cooling_down = self._record_failure(route.provider.name)
if cooling_down:
return False
Expand Down Expand Up @@ -1169,9 +1177,9 @@ def _validate_request(
)
if tools is not None and not all(isinstance(tool, Mapping) for tool in tools):
raise RequestValidationError("Every tool definition must be a mapping.")
try:
serialized_requests = [
json.dumps(
for route, resolved_model in selected:
try:
serialized = json.dumps(
self._request_payload(
route.provider,
normalized,
Expand All @@ -1184,14 +1192,13 @@ def _validate_request(
allow_nan=False,
separators=(",", ":"),
).encode("utf-8")
for route, resolved_model in selected
]
except (TypeError, ValueError, RecursionError) as exc:
raise RequestValidationError("Messages and tools must be JSON serializable.") from exc
if any(len(serialized) > self.max_request_bytes for serialized in serialized_requests):
raise RequestValidationError(
f"Serialized provider request exceeds the configured {self.max_request_bytes}-byte request limit."
)
except (TypeError, ValueError, RecursionError) as exc:
raise RequestValidationError("Messages and tools must be JSON serializable.") from exc
if len(serialized) > self.max_request_bytes:
error_message = (
f"Serialized provider request exceeds the configured {self.max_request_bytes}-byte request limit."
)
raise RequestValidationError(error_message)
return tuple(normalized)

@staticmethod
Expand Down