Skip to content

ci: fail PRs that change the public litellm API without declaring it - #36224

Open
ryan-crabbe-berri wants to merge 2 commits into
litellm_internal_stagingfrom
litellm_griffe_api_break_check
Open

ci: fail PRs that change the public litellm API without declaring it#36224
ryan-crabbe-berri wants to merge 2 commits into
litellm_internal_stagingfrom
litellm_griffe_api_break_check

Conversation

@ryan-crabbe-berri

@ryan-crabbe-berri ryan-crabbe-berri commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Nothing stops a PR silently breaking import litellm
  • Removed classes and renamed params reach PyPI unannounced
  • A chore: PR can quietly widen the public API

How it solves it:

  • griffe diffs the SDK surface against the base ref
  • Breaking changes need feat!: or a BREAKING CHANGE: footer
  • New top-level names need a feat: or fix: title

Relevant issues

Linear ticket

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

Screenshots / Proof of Fix

Run at commit f9df01b675, against real repo history rather than fixtures. The commands below are what the CI step runs, with the base ref pointed at a past commit so there is something to find.

  1. A window that really did break the SDK, undeclared. 214945a is 17 days of history back from HEAD
uv run --no-project --with griffe==2.1.0 \
  python .github/scripts/check_api_breaking_changes.py \
  --repo . --base-ref 214945a223837c721cd8c1b15eaa812636fda221 --head-ref HEAD \
  --pr-title "chore: tidy things"
echo "exit=$?"
## Public API check

10 breaking change(s) to the public `litellm` API are not declared. Add `!` after the type in the PR title (`feat!: ...`) or a `BREAKING CHANGE:` footer in the PR body, and document the migration.

### Breaking changes
- `litellm.repositories.user_repository.UserRepository.find_by_id` litellm/repositories/user_repository.py:38: UserRepository.find_by_id(user_id): Parameter was removed
- `litellm.repositories.user_repository.UserRepository.find_by_id` litellm/repositories/user_repository.py:38: UserRepository.find_by_id(id_value): Parameter was added as required
- `litellm.integrations.s3_v2.S3Logger.__init__` litellm/integrations/s3_v2.py:34: S3Logger.__init__(s3_callback_params_override): Positional parameter was moved
- `litellm.integrations.rubrik.RubrikLogger.sampling_rate` litellm/integrations/rubrik.py:0: RubrikLogger.sampling_rate: Public object was removed
- `litellm.integrations.rubrik.RubrikLogger.tool_blocking_endpoint` litellm/integrations/rubrik.py:0: RubrikLogger.tool_blocking_endpoint: Public object was removed
- `litellm.integrations.rubrik.RubrikLogger.logging_endpoint` litellm/integrations/rubrik.py:0: RubrikLogger.logging_endpoint: Public object was removed
- `litellm.integrations.rubrik.RubrikLogger.async_httpx_client` litellm/integrations/rubrik.py:0: RubrikLogger.async_httpx_client: Public object was removed
- `litellm.integrations.rubrik.RubrikLogger.tool_blocking_client` litellm/integrations/rubrik.py:0: RubrikLogger.tool_blocking_client: Public object was removed
- `litellm.integrations.otel.model.utils.parse_headers` litellm/integrations/otel/model/utils.py:0: parse_headers: Public object was removed
- `litellm.BedrockLLM` litellm/__init__.py:0: BedrockLLM: Public object was removed

### New public names
- `anthropic_sse_ping_interval_seconds`
- `autorouter_savings_baseline_model`
- `overwrite_user_with_key_hash`
- `prometheus_exclude_labels`
- `prometheus_exclude_metrics`

### Advisory (value changes, not gated)
- `litellm.caching.disk_cache.Span` litellm/caching/disk_cache.py:9: Span: Attribute value was changed: Union[_Span, Any] -> _Span | Any
...

exit=1
  1. Same window, same code, declared properly. This is the escape hatch, and it is the only difference between the two runs
uv run --no-project --with griffe==2.1.0 \
  python .github/scripts/check_api_breaking_changes.py \
  --repo . --base-ref 214945a223837c721cd8c1b15eaa812636fda221 --head-ref HEAD \
  --pr-title "feat!: drop BedrockLLM"
echo "exit=$?"
## Public API check

No undeclared public API changes.

exit=0
  1. A real merged PR (feat(guardrails): add scan_only_tool_results to scope unified guardrails to tool results #36014), to show the gate is quiet on ordinary work
uv run --no-project --with griffe==2.1.0 \
  python .github/scripts/check_api_breaking_changes.py \
  --repo . --base-ref 729bec69f5^ --head-ref 729bec69f5 \
  --pr-title "fix: scan only tool results"
echo "exit=$?"
## Public API check

No undeclared public API changes.

exit=0

Per-PR noise was sampled on three recent merges (#36054, #35137, #36014) and all three came back clean, so this should not red-CI ordinary work. The whole run takes about 9 seconds.

Type

🚄 Infrastructure

Changes

.github/scripts/check_api_breaking_changes.py loads the litellm package at the base ref and at the PR head with griffe, statically, never importing it, then applies two gates.

The first is griffe's own breaking-change detection: removed objects, removed or reordered parameters, parameters that became required, changed defaults, incompatible return types. Those fail the job unless the PR declares a breaking change the Conventional Commits way, either a ! after the type in the title or a BREAKING CHANGE: footer in the body. The repo already gates PR titles on Conventional Commits and squash-merge uses the PR title and body as the commit message, so the declaration ends up in the history where a changelog can find it.

The second gate covers additions. A new litellm.<name> export needs a feat: or fix: title, which stops a chore: or refactor: PR from widening the public surface as a side effect.

Scope is narrower than raw griffe output, deliberately. litellm.proxy.* is excluded because the proxy's contract is HTTP and belongs to a spec diff rather than a Python API diff, and re-exported stdlib names are excluded because deleting from typing import Union during the ongoing X | None modernization is not a break for anyone. On the 17-day sample window that scoping cut findings from 46 to 11, and the 11 above are all real. Attribute value changes stay advisory and never block, since the same modernization rewrites them constantly.

Ownership is decided by following the alias chain to where a name actually lives, which matters because the re-export is often indirect: litellm.Final reaches typing.Final through litellm.scheduler, so a one-hop check would have called it ours and failed any refactor: PR that added a type import to litellm/__init__.py. litellm.BedrockLLM resolves inside the package over the same machinery and is still caught.

The 44 unit tests cover the decision logic, the scope filter, alias-chain resolution, and rendering, using fakes for griffe objects rather than a live git repo. Thirteen targeted mutations of the gate (letting chore widen the surface, putting proxy internals back in scope, following only one alias hop, skipping the scope filter on added names, dropping the dedupe, reordering the two checks, loosening the footer regex, ignoring the !) were each killed by the suite.

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

Adds a griffe-based CI gate over the importable litellm SDK surface. Breaking
changes (removed objects, changed signatures, changed defaults) need a
Conventional Commits declaration, either a bang after the type or a
BREAKING CHANGE: footer. Newly exported top-level names need a feat: or fix:
title so a chore/refactor PR cannot quietly widen the public API.

Scope is deliberately narrower than raw griffe output: litellm.proxy.* is
excluded because the proxy's contract is HTTP rather than Python, and
re-exported stdlib names are excluded because their canonical home is another
package. On a 17-day sample window that took the finding count from 46 to 11,
with all 11 genuine.
@ryan-crabbe-berri
ryan-crabbe-berri requested a review from a team August 7, 2026 20:37
@greptile-apps

greptile-apps Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds a CI gate that compares the public litellm Python API against the pull request’s base revision and requires API changes to be declared through Conventional Commit metadata.

  • Detects breaking API changes and newly exported top-level names.
  • Filters proxy internals and externally owned re-exports from the SDK surface.
  • Adds a SHA-pinned GitHub Actions workflow and focused unit coverage for classification, decisions, and rendering.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
.github/scripts/check_api_breaking_changes.py Implements static Griffe-based API comparison, ownership filtering, declaration parsing, verdict selection, and CI reporting; the previously reported added-name filtering defect is addressed.
.github/workflows/check-api-breaking-changes.yml Runs the API comparison for relevant Python and gate changes using the pull request base SHA and pinned tooling.
tests/test_litellm/test_github_api_breaking_changes.py Covers declaration parsing, scope and alias handling, API-delta decisions, deduplication, and output rendering.

Reviews (2): Last reviewed commit: "fix: apply canonical-ownership scoping t..." | Re-trigger Greptile

Comment thread .github/scripts/check_api_breaking_changes.py Outdated
Comment thread .github/workflows/check-api-breaking-changes.yml
The surface-widening layer compared raw top-level member names, so adding
`from typing import Final` to litellm/__init__.py under a chore/refactor title
would fail the gate even though the breaking layer explicitly excludes external
re-exports. Scoping now follows the alias chain: litellm.Final resolves through
litellm.scheduler.Final to typing.Final and is ignored, while litellm.BedrockLLM
resolves inside the package and is still caught.
@codecov

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@ryan-crabbe-berri

Copy link
Copy Markdown
Contributor Author

@greptileai re review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant