diff --git a/.circleci/config.yml b/.circleci/config.yml
index dbeb412506f..abcdbf45187 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -133,6 +133,26 @@ commands:
done
echo "record/replay proxy did not become ready" >&2
exit 1
+ start_fake_openai_endpoint:
+ description: "Start the canned OpenAI mock (tests/_fake_openai_endpoint_server.py) on host port 8190 and wait until healthy. Models whose api_base points here (via FAKE_OPENAI_API_BASE) get well-formed chat/text/embedding responses with realistic usage, so the E2E run neither pays for nor depends on the live provider. A request whose model is '429' returns HTTP 429 for rate-limit/cooldown tests. Run after uv deps are synced."
+ steps:
+ - run:
+ name: Start fake OpenAI endpoint
+ background: true
+ command: |
+ uv run --no-sync python tests/_fake_openai_endpoint_server.py --host 0.0.0.0 --port 8190
+ - run:
+ name: Wait for fake OpenAI endpoint
+ command: |
+ for i in $(seq 1 30); do
+ if curl -sf http://localhost:8190/health >/dev/null 2>&1; then
+ echo "fake OpenAI endpoint is up"
+ exit 0
+ fi
+ sleep 1
+ done
+ echo "fake OpenAI endpoint did not become ready" >&2
+ exit 1
setup_litellm_enterprise_pip:
steps:
- run:
@@ -168,6 +188,8 @@ jobs:
name: win/default
shell: powershell.exe
working_directory: ~/project
+ environment:
+ UV_PYTHON: "3.11"
steps:
- checkout
- run:
@@ -200,7 +222,7 @@ jobs:
if (-not (Select-String -Path $PROFILE -SimpleMatch $uvBin -Quiet)) {
Add-Content -Path $PROFILE -Value "`$env:Path = `"$uvBin;`$env:Path`""
}
- uv sync --frozen --group dev --python (Get-Command python).Source
+ uv sync --frozen --group dev --python 3.11
- run:
name: Run Windows-specific test
command: |
@@ -594,6 +616,8 @@ jobs:
working_directory: ~/project
resource_class: large
parallelism: 4
+ environment:
+ FAKE_OPENAI_API_BASE: http://127.0.0.1:8190
steps:
- checkout
- setup_google_dns
@@ -609,6 +633,7 @@ jobs:
paths:
- ~/.cache/uv
key: v1-uv-cache-{{ checksum "uv.lock" }}
+ - start_fake_openai_endpoint
# Run pytest and generate JUnit XML report
- setup_litellm_enterprise_pip
- run:
@@ -1549,6 +1574,7 @@ jobs:
name: Install Dependencies
command: |
uv sync --frozen --all-groups --all-extras --python 3.12
+ - start_fake_openai_endpoint
- start_postgres:
db_name: litellm_test
- attach_workspace:
@@ -1586,6 +1612,7 @@ jobs:
-e DATABASE_URL="postgresql://postgres:postgres@host.docker.internal:5432/litellm_test" \
-e DEFAULT_NUM_WORKERS_LITELLM_PROXY=1 \
-e DISABLE_SCHEMA_UPDATE="True" \
+ -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
--name my-app \
--add-host=host.docker.internal:host-gateway \
-v $(pwd)/litellm/proxy/example_config_yaml/bad_schema.prisma:/app/schema.prisma \
@@ -1648,6 +1675,7 @@ jobs:
zstd -d litellm-docker-database.tar.zst --stdout | docker load
docker tag litellm-docker-database:ci my-app:latest
- start_openai_record_replay_proxy
+ - start_fake_openai_endpoint
- run:
name: Run Docker container
command: |
@@ -1655,6 +1683,7 @@ jobs:
-p 4000:4000 \
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
-e USE_PRISMA_MIGRATE=True \
+ -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
-e AZURE_API_KEY=$AZURE_API_KEY \
-e REDIS_HOST=$REDIS_HOST \
-e REDIS_PASSWORD=$REDIS_PASSWORD \
@@ -1817,6 +1846,7 @@ jobs:
zstd -d litellm-docker-database.tar.zst --stdout | docker load
docker images | grep litellm-docker-database
- start_openai_record_replay_proxy
+ - start_fake_openai_endpoint
- run:
name: Run Docker container
# intentionally give bad redis credentials here
@@ -1830,6 +1860,7 @@ jobs:
-e REDIS_PORT=$REDIS_PORT \
-e LITELLM_MASTER_KEY="sk-1234" \
-e OPENAI_API_KEY=$OPENAI_API_KEY \
+ -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
-e LITELLM_LICENSE=$LITELLM_LICENSE \
-e OTEL_EXPORTER="in_memory" \
-e AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID \
@@ -1889,6 +1920,7 @@ jobs:
-e REDIS_PORT=$REDIS_PORT \
-e LITELLM_MASTER_KEY="sk-1234" \
-e OPENAI_API_KEY=$OPENAI_API_KEY \
+ -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
-e LITELLM_LICENSE="bad-license" \
--add-host host.docker.internal:host-gateway \
--name my-app-3 \
@@ -1938,6 +1970,7 @@ jobs:
uv sync --frozen --all-groups --all-extras --python 3.12
- start_postgres
- start_redis
+ - start_fake_openai_endpoint
- attach_workspace:
at: ~/project
- run:
@@ -1961,6 +1994,7 @@ jobs:
-e REDIS_PORT=6379 \
-e LITELLM_MASTER_KEY="sk-1234" \
-e OPENAI_API_KEY=$OPENAI_API_KEY \
+ -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
-e LITELLM_LICENSE=$LITELLM_LICENSE \
-e AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID \
-e AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY \
@@ -2020,6 +2054,7 @@ jobs:
command: |
uv sync --frozen --all-groups --all-extras --python 3.12
- start_postgres
+ - start_fake_openai_endpoint
- attach_workspace:
at: ~/project
- run:
@@ -2039,6 +2074,7 @@ jobs:
-e REDIS_PASSWORD=$REDIS_PASSWORD \
-e REDIS_PORT=$REDIS_PORT \
-e LITELLM_MASTER_KEY="sk-1234" \
+ -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
-e LITELLM_LICENSE=$LITELLM_LICENSE \
-e USE_DDTRACE=True \
-e DD_API_KEY=$DD_API_KEY \
@@ -2060,6 +2096,7 @@ jobs:
-e REDIS_PASSWORD=$REDIS_PASSWORD \
-e REDIS_PORT=$REDIS_PORT \
-e LITELLM_MASTER_KEY="sk-1234" \
+ -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
-e LITELLM_LICENSE=$LITELLM_LICENSE \
-e USE_DDTRACE=True \
-e DD_API_KEY=$DD_API_KEY \
@@ -2112,6 +2149,7 @@ jobs:
command: |
uv sync --frozen --all-groups --all-extras --python 3.12
- start_postgres
+ - start_fake_openai_endpoint
- attach_workspace:
at: ~/project
- run:
@@ -2129,6 +2167,7 @@ jobs:
-e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \
-e STORE_MODEL_IN_DB="True" \
-e LITELLM_MASTER_KEY="sk-1234" \
+ -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
-e LITELLM_LICENSE=$LITELLM_LICENSE \
--add-host host.docker.internal:host-gateway \
--name my-app \
@@ -2187,6 +2226,7 @@ jobs:
command: |
docker build -t my-app:latest -f docker/build_from_pip/Dockerfile.build_from_pip .
- start_postgres
+ - start_fake_openai_endpoint
- run:
name: Run Docker container
# intentionally give bad redis credentials here
@@ -2200,6 +2240,7 @@ jobs:
-e REDIS_PORT=$REDIS_PORT \
-e LITELLM_MASTER_KEY="sk-1234" \
-e OPENAI_API_KEY=$OPENAI_API_KEY \
+ -e FAKE_OPENAI_API_BASE=http://host.docker.internal:8190 \
-e LITELLM_LICENSE=$LITELLM_LICENSE \
-e OTEL_EXPORTER="in_memory" \
-e AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID \
diff --git a/.githooks/commit-msg b/.githooks/commit-msg
new file mode 100755
index 00000000000..b64e38a2286
--- /dev/null
+++ b/.githooks/commit-msg
@@ -0,0 +1,75 @@
+#!/usr/bin/env bash
+#
+# commit-msg — enforce Conventional Commits 1.0.0
+# https://www.conventionalcommits.org/en/v1.0.0/
+#
+# Subject format: ()!:
+# - must be one of the angular types (feat, fix, ...)
+# - () is optional
+# - ! is optional and marks a breaking change
+# - is mandatory and must be non-empty
+#
+# Bypass: commit with --no-verify.
+# Merge, revert, fixup!, squash!, and amend! messages are passed through.
+
+set -eu
+
+COMMIT_MSG_FILE="${1:-}"
+if [ -z "$COMMIT_MSG_FILE" ] || [ ! -f "$COMMIT_MSG_FILE" ]; then
+ echo "commit-msg: missing commit message file" >&2
+ exit 1
+fi
+
+# First non-comment, non-empty line is the subject.
+subject=""
+while IFS= read -r line || [ -n "$line" ]; do
+ case "$line" in
+ ''|'#'*) continue ;;
+ esac
+ subject="$line"
+ break
+done < "$COMMIT_MSG_FILE"
+
+if [ -z "$subject" ]; then
+ echo "commit-msg: empty commit message" >&2
+ exit 1
+fi
+
+# Pass-through commits generated by git itself.
+case "$subject" in
+ "Merge "*|"Revert \""*|"fixup! "*|"squash! "*|"amend! "*)
+ exit 0
+ ;;
+esac
+
+ALLOWED_TYPES="feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert"
+# Description must not start with an uppercase letter — kept in sync with the
+# subjectPattern in .github/workflows/conventional-commits.yml so the local
+# hook is the strictly tighter of the two gates. (Without this guard, a commit
+# like "feat: Add thing" passes locally but fails the PR-title CI check.)
+PATTERN="^(${ALLOWED_TYPES})(\([^)]+\))?!?: [^A-Z].*"
+
+if printf '%s' "$subject" | grep -Eq "$PATTERN"; then
+ exit 0
+fi
+
+cat >&2 <()!:
+ (description must start with a lowercase letter)
+
+ Allowed types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
+ Examples:
+ feat(router): add weighted round-robin strategy
+ fix(bedrock): decouple STS region from aws_region_name
+ chore(deps): bump black to 26.3.1
+ refactor!: drop Python 3.8 support
+
+See https://www.conventionalcommits.org/en/v1.0.0/
+
+To bypass (use sparingly): git commit --no-verify
+EOF
+exit 1
diff --git a/.githooks/pre-push b/.githooks/pre-push
new file mode 100755
index 00000000000..c2267c8501c
--- /dev/null
+++ b/.githooks/pre-push
@@ -0,0 +1,92 @@
+#!/usr/bin/env bash
+#
+# pre-push — enforce Conventional Branches
+# https://conventional-branch.github.io/
+#
+# Branch format: /
+# must be one of: feature, bugfix, hotfix, release, chore
+#
+# Protected branches (always allowed):
+# - main
+# - litellm_internal_staging
+# - dependabot/*
+# - gh-readonly-queue/*
+#
+# Tag pushes and branch deletions are skipped.
+# Bypass: git push --no-verify.
+
+set -eu
+
+ZERO_OID="0000000000000000000000000000000000000000"
+ZERO_OID_SHA256="0000000000000000000000000000000000000000000000000000000000000000"
+ALLOWED_TYPES="feature|bugfix|hotfix|release|chore"
+BRANCH_PATTERN="^(${ALLOWED_TYPES})/.+"
+
+PROTECTED_NAMES="main litellm_internal_staging"
+PROTECTED_PREFIXES="dependabot/ gh-readonly-queue/"
+
+is_protected() {
+ branch="$1"
+ for name in $PROTECTED_NAMES; do
+ if [ "$branch" = "$name" ]; then
+ return 0
+ fi
+ done
+ for prefix in $PROTECTED_PREFIXES; do
+ case "$branch" in "$prefix"*) return 0 ;; esac
+ done
+ return 1
+}
+
+invalid=""
+
+while read -r local_ref local_oid remote_ref remote_oid; do
+ # Branch deletion (no local commit being pushed).
+ if [ "$local_oid" = "$ZERO_OID" ] || [ "$local_oid" = "$ZERO_OID_SHA256" ]; then
+ continue
+ fi
+
+ # Only validate branch pushes; ignore tags and other ref namespaces.
+ case "$remote_ref" in
+ refs/heads/*) ;;
+ *) continue ;;
+ esac
+
+ branch="${remote_ref#refs/heads/}"
+
+ if is_protected "$branch"; then
+ continue
+ fi
+
+ if ! printf '%s' "$branch" | grep -Eq "$BRANCH_PATTERN"; then
+ invalid="$invalid $branch"
+ fi
+done
+
+if [ -n "$invalid" ]; then
+ cat >&2 </
+
+ Allowed types: feature, bugfix, hotfix, release, chore
+ Examples:
+ feature/weighted-round-robin
+ bugfix/streaming-empty-chunks
+ chore/bump-deps
+ hotfix/auth-bypass
+
+ Protected (always allowed): main, litellm_internal_staging,
+ dependabot/*, gh-readonly-queue/*.
+
+See https://conventional-branch.github.io/
+
+Rename with: git branch -m
+To bypass (use sparingly): git push --no-verify
+EOF
+ exit 1
+fi
+
+exit 0
diff --git a/.github/deploy-on-aws.png b/.github/deploy-on-aws.png
new file mode 100644
index 00000000000..06d41f2a5e0
Binary files /dev/null and b/.github/deploy-on-aws.png differ
diff --git a/.github/deploy-on-gcp.png b/.github/deploy-on-gcp.png
new file mode 100644
index 00000000000..e831a8c2e4e
Binary files /dev/null and b/.github/deploy-on-gcp.png differ
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 99f79c0b272..9658baeb89a 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -4,7 +4,7 @@
## Linear ticket
-
+
## Pre-Submission checklist
diff --git a/.github/scripts/_agent_shin_actions.py b/.github/scripts/_agent_shin_actions.py
new file mode 100644
index 00000000000..b3d1ff055b3
--- /dev/null
+++ b/.github/scripts/_agent_shin_actions.py
@@ -0,0 +1,50 @@
+"""Dry-run wrapper(s) around Agent Shin GitHub mutations.
+
+The rollout scripts currently need only one mutation wrapped, so this module
+exposes a single ``maybe_post_comment`` helper. It takes a ``dry_run: bool``
+keyword argument and the body is intentionally trivial:
+
+ if dry_run:
+ print(...) # log what we would do, return
+ return
+ real_mutation(...) # otherwise, actually do it
+
+That shape means a dry-run preview differs from the real run in exactly one
+line per side effect: the call site. So when you `python3 script.py` locally
+without ``--close``, you can be confident the actions printed are the ones the
+GitHub Action would have performed (modulo ordering on retry/error paths,
+which are deliberately simple). Any further mutation a rollout script needs
+should get the same ``maybe_*`` treatment instead of calling the raw
+``triage_with_llm`` mutation directly.
+
+Importing from this module pulls in the real mutation from ``triage_with_llm``
+— call sites in the rollout scripts should NEVER import ``post_comment``
+directly; that would skip the dry-run gate and is the bug class this module
+exists to prevent.
+"""
+
+from __future__ import annotations
+
+import sys
+import textwrap
+
+# Import the module itself rather than the bare names so monkeypatching
+# `triage_with_llm.post_comment` (or any of the other mutations) in tests is
+# reflected here — `from triage_with_llm import post_comment` would bind the
+# original function to a local name and bypass the patch, defeating the whole
+# point of these wrappers.
+import triage_with_llm
+
+
+def _log(line: str) -> None:
+ """Print a single dry-run line to stdout (one log statement per side effect)."""
+ print(line, file=sys.stdout, flush=True)
+
+
+def maybe_post_comment(repo: str, number: int, body: str, *, dry_run: bool) -> None:
+ """Post a comment on ``repo#number`` — or, in dry-run, log what we would post."""
+ if dry_run:
+ _log(f"[DRY RUN] comment {repo}#{number}:")
+ _log(textwrap.indent(body, " "))
+ return
+ triage_with_llm.post_comment(repo, number, body)
diff --git a/.github/scripts/agent_shin_shared.py b/.github/scripts/agent_shin_shared.py
new file mode 100644
index 00000000000..8f3dc3c2322
--- /dev/null
+++ b/.github/scripts/agent_shin_shared.py
@@ -0,0 +1,211 @@
+"""Constants and helpers shared by Agent Shin's triage scripts.
+
+Both `triage_with_llm.py` (the LLM-judge entrypoint) and
+`close_low_quality_prs.py` (the daily Greptile-score sweep) need to
+agree on the same notions of:
+
+ * What counts as a Greptile-authored review comment
+ (``GREPTILE_BOT_LOGINS``) and how to extract a confidence score from
+ its body (``SCORE_PATTERN`` / :func:`extract_greptile_score`).
+ * How long the 2-hour grace window is (``GRACE_PERIOD_SECONDS``) and
+ the HTML marker stamped into a grace-warning comment so the *other*
+ script can see "Agent Shin already warned" and behave accordingly
+ (``GRACE_COMMENT_MARKER``).
+ * Who Agent Shin is on GitHub (``AGENT_SHIN_DEFAULT_BOT_LOGIN``).
+ * How GitHub-style ISO-8601 timestamps round-trip into timezone-aware
+ :class:`datetime.datetime` (:func:`parse_iso8601`).
+
+Keeping these in one module means a future change (new Greptile output
+format, a longer grace window, a new allowlisted account) is a single edit
+instead of two — the original split version had to call out in comments
+that the two copies "must stay in sync" precisely because nothing
+enforced it.
+"""
+
+from __future__ import annotations
+
+import datetime as dt
+import json
+import os
+import re
+import subprocess
+from typing import Iterable
+
+GREPTILE_BOT_LOGINS = frozenset({"greptile-apps", "greptile-apps[bot]"})
+
+SCORE_PATTERN = re.compile(
+ r"confidence\s*score\s*[:\-]?\s*(\d+)\s*/\s*5",
+ re.IGNORECASE,
+)
+
+GRACE_COMMENT_MARKER = ""
+
+# Hidden HTML marker stamped on every Agent Shin auto-close comment (the LLM
+# judge's grace/review-gate close and the daily Greptile sweep's close).
+# `was_closed_by_agent_shin` requires this marker — not just the closing actor —
+# before `@agent-shin reconsider` may reopen, because the `github-actions[bot]`
+# identity is shared with every other workflow in the repo and is not unique to
+# Agent Shin. Both close paths must stamp it or the reconsider path silently
+# rejects the contributor.
+AGENT_SHIN_CLOSE_MARKER = ""
+
+# 2 hours between the grace warning and the auto-close. Short enough to
+# dogfood the "fix it before it closes" loop in one sitting; bump back up
+# (e.g. 86400 for a day) for the public rollout.
+GRACE_PERIOD_SECONDS = 7200
+
+AGENT_SHIN_DEFAULT_BOT_LOGIN = "github-actions[bot]"
+
+
+def _logins(*names: str) -> frozenset[str]:
+ """Build a login set normalized for case-insensitive membership checks.
+
+ Callers compare via ``login.lower() in ``, so the stored values
+ must be lowercase. Normalizing here lets the literals keep each
+ account's canonical GitHub casing (e.g. ``SwiftWinds``) for
+ readability without breaking the lookup.
+ """
+ return frozenset(name.lower() for name in names)
+
+
+# Dogfood rollout gate. While this set is non-empty, Agent Shin acts ONLY on
+# PRs/issues authored by these logins and skips everyone else. For an
+# allowlisted author the usual internal/external classification is bypassed, so
+# an internal account (e.g. a maintainer's own work login) still gets triaged
+# while the bot is being tested on a small set of accounts. Empty the set to
+# lift the restriction and restore full triage for the public rollout. Logins
+# are compared case-insensitively.
+ALLOWLIST_LOGINS = _logins("mateo-berri", "SwiftWinds")
+
+# `gh {pr,issue} list` has no "fetch everything" flag — `--limit` is the only
+# control and it defaults to 30. Pass a ceiling far above any realistic open
+# backlog (low thousands today) so gh paginates the API until the queue is
+# exhausted rather than silently truncating. The bulk sweeps MUST see the whole
+# backlog: gh lists newest-first, so a low cap drops the *oldest* PRs/issues —
+# exactly the stale ones a low-quality sweep is meant to catch.
+GH_LIST_ALL_LIMIT = 100_000
+
+
+def extract_greptile_score(comments: Iterable[dict]) -> tuple[int, dict] | None:
+ """Return (score, comment) for the most recent Greptile-authored comment
+ that contains a "Confidence Score: X/5". Returns None if no such comment.
+
+ "Most recent" is determined by the comment's `updated_at` (falling back to
+ `created_at`), so re-reviews override earlier passes.
+ """
+ candidates: list[tuple[str, int, dict]] = []
+ for comment in comments:
+ user = (comment.get("user") or {}).get("login", "")
+ if user not in GREPTILE_BOT_LOGINS:
+ continue
+ body = comment.get("body") or ""
+ match = SCORE_PATTERN.search(body)
+ if not match:
+ continue
+ score = int(match.group(1))
+ timestamp = comment.get("updated_at") or comment.get("created_at") or ""
+ candidates.append((timestamp, score, comment))
+
+ if not candidates:
+ return None
+
+ candidates.sort(key=lambda triple: triple[0])
+ _, score, comment = candidates[-1]
+ return score, comment
+
+
+def parse_iso8601(value: str) -> dt.datetime:
+ """Parse a GitHub ISO-8601 timestamp into a timezone-aware datetime."""
+ return dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
+
+
+def gh(*args: str) -> str:
+ """Run a `gh` CLI command and return stdout. Raises on non-zero exit.
+
+ Shared by both Agent Shin entrypoints so a future change here
+ (timeout handling, logging, retry on transient failures) only needs
+ to be made once.
+ """
+ result = subprocess.run(
+ ["gh", *args],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ return result.stdout
+
+
+def list_open_items(kind: str, *, repo: str | None, fields: str) -> list[dict]:
+ """Return EVERY open PR (``kind="pr"``) or issue (``kind="issue"``) in ``repo``.
+
+ Wraps ``gh {pr,issue} list`` with ``--limit GH_LIST_ALL_LIMIT`` so the full
+ backlog is fetched instead of the default 30 (or any other arbitrary cap).
+ Both bulk sweeps — the daily Greptile closer and the one-shot rollout
+ heads-up — rely on this seeing the whole queue, including the oldest items.
+
+ ``fields`` is the comma-separated ``--json`` field list the caller needs
+ (e.g. ``"number"`` for the rollout, the full set for the closer).
+ """
+ if kind not in ("pr", "issue"):
+ raise ValueError(f"kind must be 'pr' or 'issue', got {kind!r}")
+ repo_args = ["--repo", repo] if repo else []
+ raw = gh(
+ kind,
+ "list",
+ "--state",
+ "open",
+ "--limit",
+ str(GH_LIST_ALL_LIMIT),
+ "--json",
+ fields,
+ *repo_args,
+ )
+ return json.loads(raw)
+
+
+def seconds_since_latest_marker_comment(
+ comments: Iterable[dict],
+ *,
+ marker: str,
+ bot_login: str | None = None,
+ now: dt.datetime | None = None,
+) -> float | None:
+ """Return seconds since the bot's most recent comment containing ``marker``.
+
+ Filters comments by author so a contributor who quotes the HTML
+ marker (e.g. via GitHub's "Quote reply" feature, which preserves
+ HTML comments in the raw markdown of the quoted text) is not
+ mistaken for a bot warning — that would silently reset cooldown
+ timers and suppress legitimate notifications.
+
+ ``bot_login`` defaults to the `AGENT_SHIN_BOT_LOGIN` env override or
+ ``AGENT_SHIN_DEFAULT_BOT_LOGIN`` so callers normally don't need to
+ pass it. ``now`` is injectable for tests / callers (like the daily
+ sweep) that want every age calculation pinned to one snapshot.
+ """
+ expected_login = (
+ bot_login
+ or os.environ.get("AGENT_SHIN_BOT_LOGIN")
+ or AGENT_SHIN_DEFAULT_BOT_LOGIN
+ ).lower()
+ latest: dt.datetime | None = None
+ for comment in comments:
+ author = ((comment.get("user") or {}).get("login") or "").lower()
+ if author != expected_login:
+ continue
+ body = comment.get("body") or ""
+ if marker not in body:
+ continue
+ created = comment.get("created_at")
+ if not created:
+ continue
+ try:
+ ts = parse_iso8601(created)
+ except ValueError:
+ continue
+ if latest is None or ts > latest:
+ latest = ts
+ if latest is None:
+ return None
+ reference = now if now is not None else dt.datetime.now(dt.timezone.utc)
+ return (reference - latest).total_seconds()
diff --git a/.github/scripts/close_low_quality_prs.py b/.github/scripts/close_low_quality_prs.py
new file mode 100644
index 00000000000..7b9bbb579e3
--- /dev/null
+++ b/.github/scripts/close_low_quality_prs.py
@@ -0,0 +1,573 @@
+#!/usr/bin/env python3
+"""
+Auto-close low-quality pull requests.
+
+Closes open PRs (including drafts, regardless of age) that satisfy ALL of:
+ 1. Have a Greptile (`greptile-apps`) review comment whose latest
+ "Confidence Score: X/5" is below the configured threshold (default: 4).
+ 2. Are authored by an external OSS contributor (internal BerriAI
+ contributors are exempt).
+ 3. Do not carry an opt-out label (default: "do not close").
+
+`--min-age-days` is retained as an opt-in safety net for one-off backfill
+runs (default: 0). The team's intent is that the count of open PRs equals
+the count of PRs internal collaborators need to action on, so neither age
+nor draft status acts as a free pass.
+
+For each match, the script posts an explanatory comment and closes the PR.
+Because OSS contributors *cannot* reopen a PR closed by the bot/maintainer
+(GitHub limitation), the close-comment instructs them to push their fixes
+and **open a fresh PR**, or to comment `@agent-shin reconsider` on the
+closed PR to have the LLM judge re-evaluate (and reopen on pass).
+
+Requires the `gh` CLI to be authenticated.
+
+Usage examples:
+ # Dry run (default) - prints what would be closed
+ python3 close_low_quality_prs.py
+
+ # Actually close matching PRs
+ python3 close_low_quality_prs.py --close
+
+ # Restrict to PRs at least N days old (one-off backfill safety net)
+ python3 close_low_quality_prs.py --min-age-days 7 --min-score 4 --close
+"""
+
+from __future__ import annotations
+
+import argparse
+import datetime as dt
+import json
+import os
+import subprocess
+import sys
+from typing import Iterable
+
+# Add this script's directory to `sys.path` so the sibling
+# `agent_shin_shared` module is importable when the script is invoked
+# directly (e.g. `python3 .github/scripts/close_low_quality_prs.py ...`).
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+from agent_shin_shared import ( # noqa: E402 -- sys.path adjusted above
+ AGENT_SHIN_CLOSE_MARKER,
+ ALLOWLIST_LOGINS,
+ GRACE_COMMENT_MARKER,
+ GRACE_PERIOD_SECONDS,
+ GREPTILE_BOT_LOGINS,
+ SCORE_PATTERN,
+ extract_greptile_score,
+ gh,
+ list_open_items,
+ parse_iso8601,
+ seconds_since_latest_marker_comment,
+)
+
+# `GREPTILE_BOT_LOGINS` and `SCORE_PATTERN` (Greptile's GitHub App login
+# variants and the "Confidence Score: X/5" regex) are imported from
+# `agent_shin_shared` so the LLM judge in `triage_with_llm.py` and this
+# daily Greptile sweep read the score through the same set of logins
+# and the same regex.
+
+# `author_association` values for internal BerriAI contributors who should be
+# exempt from auto-triage.
+INTERNAL_AUTHOR_ASSOCIATIONS = frozenset({"OWNER", "MEMBER", "COLLABORATOR"})
+
+# Default labels that exempt a PR from auto-close. Defined at module scope (not
+# as a mutable argparse default) so that `--optout-label foo` REPLACES the
+# defaults instead of appending to them — the argparse `action="append"` +
+# `default=[...]` combination silently mutates the shared default list.
+DEFAULT_OPTOUT_LABELS = ("do not close", "keep open", "wip")
+
+# `GRACE_COMMENT_MARKER` (HTML marker appended to grace-period warning
+# comments — used by either script to recognize that a warning was
+# already posted) and `GRACE_PERIOD_SECONDS` (length of the grace
+# period between the warning and the actual auto-close, 2 hours) are
+# imported from `agent_shin_shared` so the Agent Shin LLM judge and
+# this daily Greptile sweep agree on the same marker and duration.
+
+
+def fetch_open_prs(repo: str | None) -> list[dict]:
+ """Fetch all open PRs (number, createdAt, isDraft, labels, author).
+
+ Includes drafts: `gh pr list --state open` returns both ready-for-review
+ and draft PRs by default. This is the desired behavior — drafts are not
+ a free pass; the internal-collaborator open-PR queue should reflect every
+ PR that needs human attention regardless of draft status.
+ """
+ fields = "number,title,createdAt,isDraft,labels,author,url"
+ return list_open_items("pr", repo=repo, fields=fields)
+
+
+def fetch_pr_author_association(pr_number: int, repo: str | None) -> str:
+ """Return the GitHub `author_association` for a PR, uppercase.
+
+ Values: OWNER, MEMBER, COLLABORATOR, CONTRIBUTOR, FIRST_TIME_CONTRIBUTOR,
+ FIRST_TIMER, MANNEQUIN, NONE. Returns "" on lookup failure.
+ """
+ endpoint = (
+ f"repos/{repo}/pulls/{pr_number}"
+ if repo
+ else f"repos/{{owner}}/{{repo}}/pulls/{pr_number}"
+ )
+ try:
+ data = json.loads(gh("api", endpoint))
+ except subprocess.CalledProcessError:
+ return ""
+ return (data.get("author_association") or "").upper()
+
+
+def is_external_pr_author(pr: dict, repo: str | None) -> bool:
+ """Return True if the PR author is an external OSS contributor.
+
+ Internal = `OWNER` / `MEMBER` / `COLLABORATOR` association, or a bot login.
+ """
+ login = ((pr.get("author") or {}).get("login") or "").lower()
+ if login.endswith("[bot]") or login in {"dependabot", "github-actions"}:
+ return False
+ association = fetch_pr_author_association(pr["number"], repo)
+ # Fail-safe: if the API lookup failed (empty string), treat the author as
+ # internal so we don't auto-close their PR. Auto-close is destructive, so
+ # an unknown association should never make a PR eligible for closing.
+ if not association or association in INTERNAL_AUTHOR_ASSOCIATIONS:
+ return False
+ return True
+
+
+def fetch_pr_comments(pr_number: int, repo: str | None) -> list[dict]:
+ """Fetch issue-level comments on a PR (where Greptile posts its summary)."""
+ endpoint = (
+ f"repos/{repo}/issues/{pr_number}/comments?per_page=100"
+ if repo
+ else f"repos/{{owner}}/{{repo}}/issues/{pr_number}/comments?per_page=100"
+ )
+ raw = gh("api", "--paginate", endpoint)
+ comments: list[dict] = []
+ for line in raw.strip().splitlines():
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ parsed = json.loads(line)
+ except json.JSONDecodeError:
+ # A malformed line should not blow up the whole sweep. Skip and
+ # carry on so the remaining PRs in this run still get evaluated.
+ continue
+ if isinstance(parsed, list):
+ comments.extend(parsed)
+ else:
+ comments.append(parsed)
+ return comments
+
+
+def has_optout_label(pr: dict, optout_labels: set[str]) -> bool:
+ labels = {label.get("name", "").lower() for label in pr.get("labels", [])}
+ return bool(labels & {lbl.lower() for lbl in optout_labels})
+
+
+def seconds_since_last_grace_warning(
+ comments: Iterable[dict],
+ *,
+ bot_login: str | None = None,
+ now: dt.datetime | None = None,
+) -> float | None:
+ """Return seconds since the bot's most recent grace-period warning, or
+ None if no such warning has ever been posted on this PR.
+
+ Thin wrapper over
+ `agent_shin_shared.seconds_since_latest_marker_comment` — the
+ centralized helper handles the bot-author filter, marker match,
+ timestamp parsing, and `now` injection. Keeping this wrapper
+ preserves the closer's "already-fetched comments + injectable now"
+ interface so callers (and tests) don't need to change.
+ """
+ return seconds_since_latest_marker_comment(
+ comments,
+ marker=GRACE_COMMENT_MARKER,
+ bot_login=bot_login,
+ now=now,
+ )
+
+
+def format_grace_warning_comment(score: int, threshold: int) -> str:
+ """Comment posted on the FIRST low-Greptile-score detection — gives
+ the contributor a 2-hour grace window before the auto-close fires on
+ the next daily cron run.
+
+ Mirrors `format_grace_warning_pr_comment` in
+ `triage_with_llm.py` in spirit (2-hour grace + escape hatches), but
+ framed around Greptile's confidence score instead of the LLM judge's
+ rubric since the close trigger here is the Greptile signal.
+ """
+ return (
+ "🚅 Hi, thanks for the PR! I'm **Agent Shin**, the automated triage bot for this "
+ "repository.\n"
+ "\n"
+ "Heads up: Greptile's most recent review scored this PR "
+ f"**{score}/5**, below our merge bar of **{threshold}/5**.\n"
+ "\n"
+ "If the score isn't lifted in the next **2 hours**, I'll auto-close this PR. That's "
+ "**not** us saying the change isn't worthwhile. We want the open-PR list to mirror "
+ "what a maintainer can act on *right now*, so contributors like you don't get lost in "
+ "a backlog. Take your time; everything below still works after the close.\n"
+ "\n"
+ "**During the grace period:** push fixes that address Greptile's feedback, then comment "
+ "`@greptileai` to request a fresh review. If "
+ f"the new score is **{threshold}/5 or higher**, the PR stays open and no further "
+ "action is needed on your side.\n"
+ "\n"
+ "**If the PR does get auto-closed in 2 hours, you still have an easy recovery path:**\n"
+ "\n"
+ "- Comment `@greptileai` to request a fresh review. **This still works even after "
+ f"the PR is closed**, and a score of {threshold}/5 or higher is one of the signals "
+ "that lifts the PR back into the review queue. A low Greptile score isn't a blocker.\n"
+ "- Comment `@agent-shin reconsider` after pushing fixes; I'll re-run the rubric and "
+ "reopen the PR if both gates (description rubric + Greptile score) now pass.\n"
+ "\n"
+ f"{GRACE_COMMENT_MARKER}"
+ )
+
+
+def post_grace_warning(
+ pr: dict,
+ score: int,
+ threshold: int,
+ repo: str | None,
+ dry_run: bool,
+) -> None:
+ """Post the 2-hour grace-period warning comment on `pr`.
+
+ The warning carries `GRACE_COMMENT_MARKER` so subsequent runs can
+ detect that the contributor has already been told about the
+ pending close. Does NOT close the PR — the close happens on the
+ next eligible run after `GRACE_PERIOD_SECONDS` elapses (handled
+ by `close_pr`).
+ """
+ pr_number = pr["number"]
+ repo_args = ["--repo", repo] if repo else []
+
+ if dry_run:
+ print(
+ f" [DRY RUN] Would post grace warning to PR #{pr_number} "
+ f"(greptile={score}/5): {pr['title']}"
+ )
+ return
+
+ comment_body = format_grace_warning_comment(score, threshold)
+ gh("pr", "comment", str(pr_number), "--body", comment_body, *repo_args)
+ print(f" Posted grace warning on PR #{pr_number} (greptile={score}/5)")
+
+
+def format_close_comment(score: int, threshold: int) -> str:
+ """Comment posted when a low-Greptile-score PR is auto-closed.
+
+ Carries `AGENT_SHIN_CLOSE_MARKER` so the `@agent-shin reconsider` path
+ (guarded by `was_closed_by_agent_shin`) recognizes this as an Agent Shin
+ close and is allowed to reopen the PR once it passes again; without the
+ marker that recovery path the comment advertises silently rejects the
+ contributor.
+ """
+ score_sentence = (
+ f"Greptile's most recent review scored this PR **{score}/5**, below "
+ f"our merge bar of **{threshold}/5**, and the 2-hour grace period since "
+ "the warning has elapsed.\n\n"
+ )
+ return (
+ f"Closing as part of automated PR triage.\n\n"
+ f"{score_sentence}"
+ "We close low-confidence PRs aggressively to keep the review queue "
+ "manageable for maintainers and contributors alike. **This is not a "
+ "rejection of the idea.** To bring this back:\n\n"
+ "1. Push the fixes that address Greptile's feedback (continue using "
+ "your existing branch is fine).\n"
+ "2. **Open a new PR** with the updated branch. Greptile will review "
+ "it again, and if it scores "
+ f"**{threshold}/5 or higher** a maintainer will take another look.\n\n"
+ "_Why open a new PR instead of reopening this one?_ GitHub does not "
+ "let external contributors reopen a PR that was closed by a bot or "
+ "maintainer, so a fresh PR is the most reliable path forward. If you "
+ "would prefer this exact PR re-evaluated, comment "
+ "`@agent-shin reconsider` once you've pushed the fixes; Agent Shin "
+ "will re-run triage and reopen this PR if it now meets the bar. "
+ "You can also comment `@greptileai` to request a fresh Greptile "
+ "review; that works **even after the PR is closed**.\n\n"
+ "Thanks for contributing to LiteLLM. We know auto-closures can sting; "
+ "the goal is to keep the project healthy, not to dismiss your work."
+ f"\n\n{AGENT_SHIN_CLOSE_MARKER}"
+ )
+
+
+def close_pr(
+ pr: dict,
+ score: int,
+ threshold: int,
+ age_days: int,
+ repo: str | None,
+ dry_run: bool,
+ label: str | None,
+) -> None:
+ """Post the explanatory comment and close the PR."""
+ pr_number = pr["number"]
+ repo_args = ["--repo", repo] if repo else []
+
+ if dry_run:
+ print(
+ f" [DRY RUN] Would close PR #{pr_number} "
+ f"(age={age_days}d, greptile={score}/5): {pr['title']}"
+ )
+ return
+
+ comment_body = format_close_comment(score, threshold)
+ gh("pr", "comment", str(pr_number), "--body", comment_body, *repo_args)
+
+ if label:
+ try:
+ gh("pr", "edit", str(pr_number), "--add-label", label, *repo_args)
+ except subprocess.CalledProcessError as exc:
+ stderr = (exc.stderr or "").strip()
+ print(f" warn: failed to add label '{label}' to #{pr_number}: {stderr}")
+
+ gh("pr", "close", str(pr_number), *repo_args)
+ print(f" Closed PR #{pr_number} (greptile={score}/5, age={age_days}d)")
+
+
+def evaluate_pr(
+ pr: dict,
+ now: dt.datetime,
+ min_age_days: int,
+ min_score: int,
+ repo: str | None,
+ optout_labels: set[str],
+ allowlist: frozenset[str] = ALLOWLIST_LOGINS,
+) -> tuple[str, int | None, int | None]:
+ """Decide what to do with `pr` on this triage run.
+
+ Returns (action, score_or_none, age_days_or_none) where action is one of:
+ "skip-too-young", "skip-optout-label", "skip-not-allowlisted",
+ "skip-internal", "skip-no-greptile-score", "skip-score-ok",
+ "warn-grace", "skip-in-grace-period", or "close".
+
+ Drafts are NOT skipped — the goal is "open PR count == PRs internal
+ collaborators need to action on", and a draft that Greptile scored <4/5
+ is still in that queue. Authors can opt out via the `wip` label (see
+ `DEFAULT_OPTOUT_LABELS`) if they need to keep a long-lived draft open.
+
+ Grace-period semantics: the first time a PR fails the rubric, the
+ action is `warn-grace` — the caller should post a warning comment but
+ NOT close the PR. On a subsequent run, if the warning is still less
+ than `GRACE_PERIOD_SECONDS` old AND the PR still fails, the action is
+ `skip-in-grace-period`. Once the warning ages out and the rubric is
+ still failing, the action is `close`.
+ """
+ if has_optout_label(pr, optout_labels):
+ return ("skip-optout-label", None, None)
+
+ created = parse_iso8601(pr["createdAt"])
+ age_days = (now - created).days
+ # `min_age_days` defaults to 0 (close as soon as Greptile scores low).
+ # Set a positive value via --min-age-days for one-off backfill runs that
+ # want to skip very-young PRs.
+ if min_age_days > 0 and age_days < min_age_days:
+ return ("skip-too-young", None, age_days)
+
+ # While the allowlist is active it is the sole author gate: only those
+ # logins are acted on and the external-only restriction is bypassed for
+ # them. Otherwise auto-close only external OSS contributors — internal
+ # contributors (BerriAI org members) handle their own backlog.
+ login = ((pr.get("author") or {}).get("login") or "").lower()
+ if allowlist:
+ if login not in allowlist:
+ return ("skip-not-allowlisted", None, age_days)
+ elif not is_external_pr_author(pr, repo):
+ return ("skip-internal", None, age_days)
+
+ comments = fetch_pr_comments(pr["number"], repo)
+ extraction = extract_greptile_score(comments)
+ if extraction is None:
+ return ("skip-no-greptile-score", None, age_days)
+
+ score, _ = extraction
+ if score >= min_score:
+ return ("skip-score-ok", score, age_days)
+
+ grace_age = seconds_since_last_grace_warning(comments, now=now)
+ if grace_age is None:
+ return ("warn-grace", score, age_days)
+ if grace_age < GRACE_PERIOD_SECONDS:
+ return ("skip-in-grace-period", score, age_days)
+
+ return ("close", score, age_days)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--repo",
+ type=str,
+ default=None,
+ help="Repository (owner/repo). Auto-detected if omitted.",
+ )
+ parser.add_argument(
+ "--min-age-days",
+ type=int,
+ default=0,
+ help=(
+ "Minimum age (in days) before a PR is eligible. Default 0 = "
+ "close as soon as Greptile flags it. Set a positive value for "
+ "one-off backfill runs that want to spare very-young PRs."
+ ),
+ )
+ parser.add_argument(
+ "--min-score",
+ type=int,
+ default=4,
+ choices=range(1, 6),
+ help="Greptile score below which a PR is closed (default: 4 -> closes <4/5).",
+ )
+ parser.add_argument(
+ "--optout-label",
+ action="append",
+ default=None,
+ help=(
+ "Label(s) that exempt a PR from auto-close. Repeat to add more. "
+ "Case-insensitive. When omitted, defaults to "
+ f"{list(DEFAULT_OPTOUT_LABELS)!r}; passing this flag REPLACES the "
+ "defaults (argparse `append` with a mutable default would append "
+ "instead, which we explicitly avoid)."
+ ),
+ )
+ parser.add_argument(
+ "--close-label",
+ type=str,
+ default=None,
+ help=(
+ "Optional label to add to PRs that get auto-closed "
+ "(e.g. 'auto-closed-low-quality'). Must already exist on the repo."
+ ),
+ )
+ parser.add_argument(
+ "--close",
+ action="store_true",
+ help="Actually close matching PRs (default is dry-run).",
+ )
+ parser.add_argument(
+ "--limit",
+ type=int,
+ default=None,
+ help="Maximum number of PRs to close in one run (safety net).",
+ )
+ args = parser.parse_args()
+
+ dry_run = not args.close
+ if dry_run:
+ print("=== DRY RUN MODE (pass --close to actually close PRs) ===\n")
+
+ print("Fetching open PRs...")
+ prs = fetch_open_prs(args.repo)
+ print(f"Found {len(prs)} open PRs.\n")
+
+ now = dt.datetime.now(dt.timezone.utc)
+ optout_labels = set(args.optout_label or DEFAULT_OPTOUT_LABELS)
+
+ closed = 0
+ summary = {
+ "close": 0,
+ "warn-grace": 0,
+ "skip-in-grace-period": 0,
+ "skip-too-young": 0,
+ "skip-optout-label": 0,
+ "skip-not-allowlisted": 0,
+ "skip-internal": 0,
+ "skip-no-greptile-score": 0,
+ "skip-score-ok": 0,
+ }
+
+ # `warned` tracks grace-warning comments posted in this run so the
+ # `--limit` safety net bounds *all* destructive write actions, not
+ # just closures. Without this cap, a backlog of PRs failing the
+ # threshold simultaneously could flood contributors with comments.
+ warned = 0
+ for pr in sorted(prs, key=lambda p: p["createdAt"]):
+ try:
+ action, score, age_days = evaluate_pr(
+ pr,
+ now,
+ args.min_age_days,
+ args.min_score,
+ args.repo,
+ optout_labels,
+ )
+ summary[action] = summary.get(action, 0) + 1
+
+ if action == "warn-grace":
+ assert score is not None
+ print(
+ f"#{pr['number']}: \"{pr['title']}\" "
+ f"(age={age_days}d, greptile={score}/5) -> warn-grace"
+ )
+ post_grace_warning(
+ pr,
+ score=score,
+ threshold=args.min_score,
+ repo=args.repo,
+ dry_run=dry_run,
+ )
+ if not dry_run:
+ warned += 1
+ if args.limit is not None and (warned + closed) >= args.limit:
+ print(
+ f"\nReached --limit={args.limit} "
+ f"(closed={closed}, warned={warned}); stopping."
+ )
+ break
+ continue
+
+ if action != "close":
+ continue
+
+ assert score is not None and age_days is not None
+ print(
+ f"#{pr['number']}: \"{pr['title']}\" "
+ f"(age={age_days}d, greptile={score}/5) -> close"
+ )
+ close_pr(
+ pr,
+ score=score,
+ threshold=args.min_score,
+ age_days=age_days,
+ repo=args.repo,
+ dry_run=dry_run,
+ label=args.close_label,
+ )
+
+ if not dry_run:
+ closed += 1
+ if args.limit is not None and (warned + closed) >= args.limit:
+ print(
+ f"\nReached --limit={args.limit} "
+ f"(closed={closed}, warned={warned}); stopping."
+ )
+ break
+ except Exception as exc: # noqa: BLE001 - per-PR errors don't abort the sweep
+ summary["error"] = summary.get("error", 0) + 1
+ print(
+ f"!! PR #{pr.get('number')}: {exc}",
+ file=sys.stderr,
+ )
+ continue
+
+ print("\n=== Summary ===")
+ for key, value in summary.items():
+ print(f" {key:28s} {value}")
+ if dry_run:
+ print(f"\nTotal would close: {summary['close']}")
+ else:
+ print(f"\nTotal closed: {closed}")
+ print(
+ f"Total {'would warn (grace)' if dry_run else 'warned (grace)'}: "
+ f"{summary['warn-grace']}"
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.github/scripts/triage-requirements.txt b/.github/scripts/triage-requirements.txt
new file mode 100644
index 00000000000..a18f05fbb95
--- /dev/null
+++ b/.github/scripts/triage-requirements.txt
@@ -0,0 +1,282 @@
+# Hash-pinned dependency set for the Agent Shin triage scripts.
+# Installed in privileged triage workflows, so every package is pinned to an
+# exact version with SHA-256 hashes and installed with pip --require-hashes.
+#
+# Regenerate after bumping openai:
+# echo 'openai==' \
+# | uv pip compile - --generate-hashes --python-version 3.12 \
+# --no-annotate --no-header -o .github/scripts/triage-requirements.txt
+
+annotated-types==0.7.0 \
+ --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \
+ --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89
+anyio==4.14.0 \
+ --hash=sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89 \
+ --hash=sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9
+certifi==2026.6.17 \
+ --hash=sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432 \
+ --hash=sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db
+distro==1.9.0 \
+ --hash=sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed \
+ --hash=sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2
+h11==0.16.0 \
+ --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
+ --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
+httpcore==1.0.9 \
+ --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \
+ --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8
+httpx==0.28.1 \
+ --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \
+ --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
+idna==3.18 \
+ --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \
+ --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848
+jiter==0.15.0 \
+ --hash=sha256:01a8222cf05ab1128e239421156c207949808acaaea2bdfd33130ae666786e86 \
+ --hash=sha256:032396229564bca02440396bd327710719f724f5e7b7e9f7a8eb3faa4a2c2281 \
+ --hash=sha256:04b400bbf8c9efb03d9bdd976475c919c1d85593b04b9fff7ae234065daf87ae \
+ --hash=sha256:05906b93d72f03339e6bb7cf8dc10ebda64a0266126eed6beba79e20abcf5fd4 \
+ --hash=sha256:066f8f33f18b2419cd8213b2436fa7fbc9c499f315971cfa3ce1f9820c001b1b \
+ --hash=sha256:0ab068bce62a45aa3e7367eceaffb5dde60b7eb853be8dece45132e3d0ff4879 \
+ --hash=sha256:0be6f5ad41a809f303f416d17cec92a7a725902fb9b4f3de3d19362ac0ef8554 \
+ --hash=sha256:0e90a1c315a0226ec822d973817967f9223b7701546c8c2a7913e7ab0926294d \
+ --hash=sha256:0f862193b8696249d22ec433e85fd2ab0ad9596bc3e45e6c0bc55e8aeba97be2 \
+ --hash=sha256:1303d4d68a9b051ea90502402063ecf3807da00ad2affa19ca1ae3b90b3c5f67 \
+ --hash=sha256:144f8e72cb53dab146347b91cceac01f5481237f2b93b4a339a1ee8f8878b67c \
+ --hash=sha256:182226cbc930c9fab81bc2e41a4da672f89539906dadb05e75670ac07b94f71f \
+ --hash=sha256:1c11465f97e2abf45a014b83b730222f8f1c5335e802c7055a67d50de6f1f4e3 \
+ --hash=sha256:1c15024a3d892223b18f597c86d59387249dc396590844ce6b9f6131d1093bae \
+ --hash=sha256:1d54fb5b31dea401a41af3f8a7d2512e9b6a6a005491e6166c7e4ffab9639a9c \
+ --hash=sha256:25ffbe229aa8cd98c28879d8aa1a6e34ae77992ab984a65fba800859dab16269 \
+ --hash=sha256:2a77aadd57cac1682e4401a72724d2796d89a4ba129b1a5812aa94ee480826eb \
+ --hash=sha256:2ae901f3a55bfafdde31d289590fa25e3245735a2b1e8c7cc15871710a002871 \
+ --hash=sha256:2b0074e2f56eb2dacca1689760fd2852a068f85a0547a157b82cb4cafeb6768b \
+ --hash=sha256:2c8aea7781d2a372227871de4e1a1332aa96f5a89fd76c5e835dafdbad102887 \
+ --hash=sha256:2c9cb907439d20bd0c7d7565ca01ee52234203208433749bae5b516907526928 \
+ --hash=sha256:2fb6a5d26af81fc0f00f9360a891e05cf755e149bba391c4d563adc54812973d \
+ --hash=sha256:2fd73e3da91a0a722d67165e849ce2cdc10de0e0d48738c142be8c6c5f310f4c \
+ --hash=sha256:30ce1a5d16b5641dc935d50ef775af6a0871e3d14ab05d6fc54dff371b78e558 \
+ --hash=sha256:30ce785d2adb8e32c3f7741442370a74834ec4c01f3c48f0750227a0b4ef27d6 \
+ --hash=sha256:30f2218e6a9e5c18bc10fe6d41ac189c442c88eacf11bad9f28ef95a9bef00e6 \
+ --hash=sha256:351a341c2105aa430b7047e30f1bf7975f6313b00165d3fc07be2edaf741f279 \
+ --hash=sha256:37a10c377ce3a4a85f4a67f28b7afe093154cde77eaf248a72e856aa08b4d865 \
+ --hash=sha256:392b8ab019e5502d08aff85c6272209c24bc2cbe706ea82a56368f524236614a \
+ --hash=sha256:3e4540b8e74e4268811ac05db226a6a128ff572e7e0ce3f1163b693cadb184cd \
+ --hash=sha256:40b2c7e92c44a84d748d21706c68dc6ff8161d80b59c99d774721a0d2317d7c7 \
+ --hash=sha256:411fa4dfa5a7ae3d11491027ffb9beadec3996010a986862db70d91abba1c750 \
+ --hash=sha256:4251acc80e2b7c9b7b8823456ea0fceeb0734dac2df7636d3c711b38476b5a76 \
+ --hash=sha256:42bfb257930800cf43e7c62c832402c704ab60797c992faf88d20e903eac8f32 \
+ --hash=sha256:4363818355dbc70ae1a8e9eaba9de350d93ede4ff6992b8f8eb8cbb6e5122d42 \
+ --hash=sha256:4ab395feec8d249ec4044e228e98a7033f043426a265df439dc3698823f0a4e4 \
+ --hash=sha256:50164d7610c00e7cd913a873fce30b6beeebf4b37e53983e33f22de4c900f6b8 \
+ --hash=sha256:50e51156192722a9c58db112837d3f8ef96fb3c5ecc14e95f409134b08b158ec \
+ --hash=sha256:510c8b3c17a0ed9ac69850c0438dada3c9b82d9c4d589fcb62002a5a9cf3a866 \
+ --hash=sha256:5157de9f76eb4bc5ea74a1219366a25f945ad305641d74e04f59c54087091aa9 \
+ --hash=sha256:54d5d6090cdc1b7c9e780dfb04949a990adb1e301a2fc0bbcee7de4638d33f9a \
+ --hash=sha256:553fcac2ef2cb990877f9fc0833b8b629a3e6a5670b6b5fd58219b41a653ddc4 \
+ --hash=sha256:5607e6013ed7e6b0ec9661e467b7ffde0aa7ab36833a04850f26fcf88ed4845b \
+ --hash=sha256:5d6a60072b44c3c2b797a7ddcbcbbf2b34ea3cfd4721580fbfd2a09d9d9b84ba \
+ --hash=sha256:5f30bae8bc1c2d613e28e5af3e8cceb09b742f1c8a8a5f839fb67afaffc03b61 \
+ --hash=sha256:62ebd14e47e9aed9df4472afcb2663668ce4d74891cd54f86bf6e44029d6dc89 \
+ --hash=sha256:631f13a3d04e97d4e083993b10f4b99530e3a10d953e2eb5e196b7dc7f812ce0 \
+ --hash=sha256:6550fa135c7deb8ead6af49ed7ff648532ea8334a1447fe34a36315ef79c5c29 \
+ --hash=sha256:66b1880df2d01e206e8339769d1c7c1753bcb653efd6289e203f6f24ebada0c0 \
+ --hash=sha256:6eac374c5c975709b69c10f09afd199df74150172156ad10c8d4fd785b7da995 \
+ --hash=sha256:71683c38c825452999b5717fcae07ea708e8c93003e808be4319c1b02e3d176e \
+ --hash=sha256:7553333dd0930c104a5a0db8df72bf7219fe663d731383b576bb6ed6351c984d \
+ --hash=sha256:75e8a04e91432dde9f1838373cf93d23726c79d3e908d319acf0e796f85592e7 \
+ --hash=sha256:773b6eb282ce11ee19f05f6b2d4404fa308e5bbd353b0b80a0262caad6db2cd7 \
+ --hash=sha256:774f93f65031856bf14ad9f59bdcab8b8cad501e5ceabd51ba3525f76937a25b \
+ --hash=sha256:7c468136b8bd6bb18c8786e4236a1fa27362f24cb23450ba0cb204ab379b8e6f \
+ --hash=sha256:7ce8902f939970048b233087082e7bb829db29375811c7ad50687b8624c6fd08 \
+ --hash=sha256:7d3d6683288c11cbab50e865f2e2f13950179aa45410e30b2cfbd3fb7b0177bf \
+ --hash=sha256:7f6163c0f10b055245f814dcc59f4818da60dfe72f3e72ab89fc24b6bd5e9c52 \
+ --hash=sha256:8020c99ec13a7db2b6f96cbe82ef4721c88b426a4892f27478044af0284615ef \
+ --hash=sha256:813dfbb17d65328bf86e5f0905dd277ba2265d3ca20556e86c0c7035b7182e5a \
+ --hash=sha256:860a74063284a2ae9bfedd694f299cc2c68e2696c5f3d440cc9d18bb81b9dd04 \
+ --hash=sha256:8c9004af7c8d67cce7f1aae1026fb55607f4aa600710d08ede3a3ce4aeefe7e0 \
+ --hash=sha256:8d2c0c44d569ce0f2850f5c926f8caeb5f245fbc84475aeb36efccc2103e6dbd \
+ --hash=sha256:8f7e9bc0f1135039b22ee6eab588d42df1ce55842b30740a352885eb267bd941 \
+ --hash=sha256:90c5db5527c221249a876160663ab891ace358c17f7b9c93ec1478b7f0550e5c \
+ --hash=sha256:9100ddbec09741cc66feb0fc6773f8bdbd0e3c345689368f260082ff85dcc0cd \
+ --hash=sha256:913d02d29c9606643418d9ccfc3b72492ab25a6bf7889934e09a3490f8d3438b \
+ --hash=sha256:980c256edb05b78a111b99c4de3b1d32e31634b867fd1fc2cf726e7b7bba9854 \
+ --hash=sha256:9f924585cdacf631cd382b657966847bb537bf9ed0a6f9b991da5f05a631480f \
+ --hash=sha256:a254e10b593624d230c365b6d616b22ca0ad65e63a16e6631c2b3466022e6ba8 \
+ --hash=sha256:a2a438005b6f22d0273413484d6094d7c2c5d10ec1b3a3bf128e0d1d3ba53258 \
+ --hash=sha256:a97261f1fccb8e50ecd2890a96e46efdc3f57c80a197324c6777827231eca712 \
+ --hash=sha256:ab596fa3837e91e7e6a31b5f639988bfc6a35d1f915ac3932d946062219d588f \
+ --hash=sha256:abbf258599526ad0326fe51e252e24f2bd6f24f1852681b4b78feda3808f1d18 \
+ --hash=sha256:ac0d9ddea4350974be7a221fc25895f251a8fee748c889bdced2141c0fec1a49 \
+ --hash=sha256:acf4ee4d1fc55917239fe72972fb292dd773055d05eb040d36f4326e02cc2c0e \
+ --hash=sha256:ae1b0d82ac2d987f9ea512b1c9adfcc71a28de3dea3a6039b54d76cffda9901e \
+ --hash=sha256:b15741f501469009ae0ae90b7147958a664a7dede40aa7ff174a8a4645f546d0 \
+ --hash=sha256:b15d3ec9b0449c40e85319bdb4caa8b77ab526e74f5532ed94bec15e2f66822c \
+ --hash=sha256:b3b3b775e33d3bfaec9899edc526ae97b0da0bf9d071a46124ba419149a414f8 \
+ --hash=sha256:b6c0ffae686c39bf3737be60793783267628783ea42545632c10b291105aee45 \
+ --hash=sha256:c210f8b35dc6f30aafd4b4365ca89b9d1189f21ab49b8e68fa6322a847aef138 \
+ --hash=sha256:c2f6bb8b5216ab9e7873bc08b5d7bef2b8abbb578a3069bf1cd14a45d71d771d \
+ --hash=sha256:c60e71b6d10cfc284c9bf36bd885e8d44c46f688ce50aa91b5edd90181dea687 \
+ --hash=sha256:c6694a173ecabc12eb60efbc0b474464ead1951ff65cd8b1e72100715c64512b \
+ --hash=sha256:c77496cb10bd7549690fbbab3e5ec05857b83e49276f4a9423a766ddd2afcd4c \
+ --hash=sha256:c84c1b7be454b0c16f8499b4ebfbfd82ea5cca6527cceefcbbc06a7557b5ed2e \
+ --hash=sha256:cc0bc345cf2df9d1c00ac443f50d543c1ccfa8b0422cb85b1ab70d681c0b255b \
+ --hash=sha256:ceb8fc27d38793f9c97149be8302720c5b22e5c195a37bf2c45dc36c4600a512 \
+ --hash=sha256:cf4bd113a69c0a740e27cb962ce10630c36d2b8f59d759a651b955ee9d18a823 \
+ --hash=sha256:d1aa62e277fc1cbd80e6deacae6f4d983b41b3d7728e0645c5d741a6149bba45 \
+ --hash=sha256:d1e7b1776f0797956c509e123d0952d10d293a9492dea9f288ab9570ec01d1a5 \
+ --hash=sha256:d636d5095155afd364247f65070fab7beda13498d7ff4de331046e704ab9657f \
+ --hash=sha256:d726e3ceeb337191324b49de298142f27c3ad10886341555d1d5315b5f252c6a \
+ --hash=sha256:d72d8af5c1013656a8870c866660627d1a75bc185814ee022c8533caa1de88ae \
+ --hash=sha256:d8d2955167274e15d79a7a020afdd9b39c990eb80b2d89fca695d92dcfdd38ec \
+ --hash=sha256:d92a5cd21fdb083931d546c207aa29633787c5dc5b02daab2d32b843f88a2c53 \
+ --hash=sha256:e58585a58209d72691ce2d62a9147445f5a87beb0bde97fde284c96ae392a3d1 \
+ --hash=sha256:e7196e56f1cd69af1dbb07dff02dcfb260a50b45a82d409d92a06fedb32473b5 \
+ --hash=sha256:eda3071db3346334beae1360b46da4606da57bf3528c167b3c38533afaf9f2c5 \
+ --hash=sha256:edebcf7d1f601199084bb6e844d7dc67e03e04f6ac786b0332d616635c4ff7a4 \
+ --hash=sha256:ef1fd24d9413f6209e00d3d5a453e67acfe004a25cc6c8e8484faed4311ab9e8 \
+ --hash=sha256:f0b271b462769543716f92d3a4f90527df6ef5ed05ee95ec4137f513e21e1b77 \
+ --hash=sha256:f18f85e4218d1b40f000f42a92239a7a61a902cd42c65e6c360dbd17dcb20894 \
+ --hash=sha256:f1e1754960f38ec40613a07e5e372df67acb3b890fb383b6fb3de3e49ddbf3c7 \
+ --hash=sha256:f2143ab06181d2b029eedcb6af3cebe95f11bbac62441781860f98ee9330a6a6 \
+ --hash=sha256:f3d37768fce7f88dd2a8c6091f2325dea27d30d30d5c6e7a1c0f0af77723b708 \
+ --hash=sha256:fa248c9eb220197d363f688818dac2fd4b2f0cd7d843ca7105d652034823427d
+openai==2.33.0 \
+ --hash=sha256:03ac37d70e8c9e3a8124214e3afa785e2cbc12e627fbd98177a086ef2fd87ad5 \
+ --hash=sha256:f850c435e2a4685bba3295bd54912dd26315d9c1b7733068186134d6e0599f9a
+pydantic==2.13.4 \
+ --hash=sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba \
+ --hash=sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6
+pydantic-core==2.46.4 \
+ --hash=sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0 \
+ --hash=sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262 \
+ --hash=sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda \
+ --hash=sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0 \
+ --hash=sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e \
+ --hash=sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b \
+ --hash=sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594 \
+ --hash=sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29 \
+ --hash=sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2 \
+ --hash=sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c \
+ --hash=sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d \
+ --hash=sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398 \
+ --hash=sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d \
+ --hash=sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3 \
+ --hash=sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f \
+ --hash=sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb \
+ --hash=sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7 \
+ --hash=sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5 \
+ --hash=sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9 \
+ --hash=sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462 \
+ --hash=sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4 \
+ --hash=sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b \
+ --hash=sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d \
+ --hash=sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df \
+ --hash=sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2 \
+ --hash=sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0 \
+ --hash=sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519 \
+ --hash=sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd \
+ --hash=sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7 \
+ --hash=sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac \
+ --hash=sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6 \
+ --hash=sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565 \
+ --hash=sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898 \
+ --hash=sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb \
+ --hash=sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928 \
+ --hash=sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6 \
+ --hash=sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3 \
+ --hash=sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a \
+ --hash=sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596 \
+ --hash=sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987 \
+ --hash=sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e \
+ --hash=sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d \
+ --hash=sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712 \
+ --hash=sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008 \
+ --hash=sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd \
+ --hash=sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1 \
+ --hash=sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be \
+ --hash=sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea \
+ --hash=sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292 \
+ --hash=sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33 \
+ --hash=sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3 \
+ --hash=sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4 \
+ --hash=sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b \
+ --hash=sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826 \
+ --hash=sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac \
+ --hash=sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7 \
+ --hash=sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d \
+ --hash=sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf \
+ --hash=sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4 \
+ --hash=sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc \
+ --hash=sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15 \
+ --hash=sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3 \
+ --hash=sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b \
+ --hash=sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914 \
+ --hash=sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04 \
+ --hash=sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c \
+ --hash=sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b \
+ --hash=sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9 \
+ --hash=sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce \
+ --hash=sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4 \
+ --hash=sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a \
+ --hash=sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f \
+ --hash=sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424 \
+ --hash=sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894 \
+ --hash=sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9 \
+ --hash=sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76 \
+ --hash=sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201 \
+ --hash=sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb \
+ --hash=sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109 \
+ --hash=sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4 \
+ --hash=sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848 \
+ --hash=sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526 \
+ --hash=sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0 \
+ --hash=sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01 \
+ --hash=sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458 \
+ --hash=sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e \
+ --hash=sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba \
+ --hash=sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a \
+ --hash=sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39 \
+ --hash=sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c \
+ --hash=sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000 \
+ --hash=sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b \
+ --hash=sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf \
+ --hash=sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4 \
+ --hash=sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd \
+ --hash=sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28 \
+ --hash=sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9 \
+ --hash=sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30 \
+ --hash=sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983 \
+ --hash=sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1 \
+ --hash=sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76 \
+ --hash=sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5 \
+ --hash=sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4 \
+ --hash=sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7 \
+ --hash=sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c \
+ --hash=sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066 \
+ --hash=sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3 \
+ --hash=sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02 \
+ --hash=sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89 \
+ --hash=sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50 \
+ --hash=sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76 \
+ --hash=sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49 \
+ --hash=sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b \
+ --hash=sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d \
+ --hash=sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7 \
+ --hash=sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4 \
+ --hash=sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c \
+ --hash=sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e \
+ --hash=sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff \
+ --hash=sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae
+sniffio==1.3.1 \
+ --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \
+ --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc
+tqdm==4.68.3 \
+ --hash=sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482 \
+ --hash=sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03
+typing-extensions==4.15.0 \
+ --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \
+ --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
+typing-inspection==0.4.2 \
+ --hash=sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 \
+ --hash=sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464
diff --git a/.github/scripts/triage_rollout_heads_up.py b/.github/scripts/triage_rollout_heads_up.py
new file mode 100644
index 00000000000..a5dedb1c9e7
--- /dev/null
+++ b/.github/scripts/triage_rollout_heads_up.py
@@ -0,0 +1,557 @@
+#!/usr/bin/env python3
+"""One-shot 7-day heads-up sweep for the Agent Shin rollout.
+
+Posts a friendly "the OSS triage bot kicks in next Monday" comment on every
+open external PR/issue that currently *would* fail the new rubric — i.e.,
+every PR/issue Agent Shin would close once the rollout completes. The point
+is to give contributors a full week to fix their description before the bot
+ever takes a destructive action, so nobody is surprised by an auto-close.
+
+The script is designed to run **exactly once** at rollout, fired by a manual
+``workflow_dispatch`` (``dry_run=false``) on the heads-up workflow. Re-runs
+are safe: every comment is stamped with the hidden ``HEADS_UP_MARKER`` and
+PRs/issues that already carry the marker are skipped.
+
+Dry-run vs. real run
+--------------------
+Defaults to dry-run. Passing ``--close`` flips into real mode. Every GitHub
+mutation goes through ``_agent_shin_actions``, which has a one-line
+``if dry_run: log else: do_it`` per call, so the only difference between a
+dry-run preview and the real run is the call site that actually hits the
+GitHub API.
+
+Local preview::
+
+ python3 .github/scripts/triage_rollout_heads_up.py --repo BerriAI/litellm
+
+Real run (the manual rollout dispatch uses this)::
+
+ python3 .github/scripts/triage_rollout_heads_up.py --repo BerriAI/litellm --close
+"""
+
+from __future__ import annotations
+
+import argparse
+import datetime as dt
+import json
+import os
+import sys
+from pathlib import Path
+from typing import Any
+
+# Make the sibling triage_with_llm + _agent_shin_actions importable when this
+# script is invoked directly (the GitHub workflow does `python3 .github/scripts/...`).
+_SCRIPTS_DIR = Path(__file__).resolve().parent
+if str(_SCRIPTS_DIR) not in sys.path:
+ sys.path.insert(0, str(_SCRIPTS_DIR))
+
+from _agent_shin_actions import maybe_post_comment # noqa: E402
+from agent_shin_shared import ( # noqa: E402
+ AGENT_SHIN_DEFAULT_BOT_LOGIN,
+ ALLOWLIST_LOGINS,
+ list_open_items,
+)
+from triage_with_llm import ( # noqa: E402
+ DEFAULT_MODEL,
+ call_llm_judge,
+ fetch_issue,
+ fetch_pr,
+ gh,
+ is_internal_contributor,
+ review_gate,
+ triage,
+)
+
+# Hidden marker so re-runs skip PRs/issues we've already notified. Distinct from
+# the within-grace / ready / regressed markers so it can't be confused with the
+# steady-state lifecycle comments.
+HEADS_UP_MARKER = ""
+
+# Placeholder until the litellm-docs PR ships. The rollout blog post explains
+# the new rubric, the 7-day grace, and how to recover after an auto-close.
+# TODO(docs): replace with the canonical URL once the litellm-docs PR merges.
+ROLLOUT_BLOG_URL = "https://docs.litellm.ai/docs/agent_shin_triage_rollout"
+
+# Default cutoff is one week from "now". Computed at runtime so the wording
+# stays correct even if the rollout is merged later than planned. The user can
+# override with --close-on YYYY-MM-DD when running the script manually.
+DEFAULT_GRACE_DAYS = 7
+
+# The daily auto-close sweeps (close_low_quality_prs.yml at 09:00 UTC and
+# review_gate.yml at 09:30 UTC) are what actually close a still-failing item,
+# so the deadline we promise contributors has to name that wall-clock moment.
+ACTIVATION_TIME_UTC = "09:00 UTC"
+
+
+def _format_cutoff(cutoff: dt.date) -> str:
+ """Human-readable, timezone-explicit cutoff, e.g. ``Monday, June 1, 2026
+ (09:00 UTC)`` — the moment a still-failing PR/issue gets closed."""
+ return (
+ f"{cutoff.strftime('%A, %B')} {cutoff.day}, {cutoff.year} "
+ f"({ACTIVATION_TIME_UTC})"
+ )
+
+
+def _rubric_section_pr() -> str:
+ return (
+ "**Going forward, every external PR needs ONE of:**\n"
+ "\n"
+ "- A linked GitHub issue using a closing keyword: "
+ "`Fixes #1234`, `Closes #1234`, or `Resolves #1234`, OR\n"
+ "- All three of: a clear **problem description**, **expected vs. "
+ "actual behavior**, and **end-to-end QA proof** (at least one of a "
+ "short screen recording / video, before/after screenshots, or the "
+ "exact commands you ran with their real output; mocked or stubbed "
+ "runs don't count).\n"
+ "\n"
+ "PRs also need a **Greptile confidence score of 4/5 or higher** before "
+ "the bot will tag them `ready for review`. You can `@greptileai` to "
+ "request a fresh review at any time, including after the PR is closed."
+ )
+
+
+def _rubric_section_issue() -> str:
+ return (
+ "**Going forward, every external issue needs:**\n"
+ "\n"
+ "- For **bug reports**: end-to-end evidence of the bug (at least one "
+ "of a screen recording / video, a screenshot, or the exact commands "
+ "you ran with their real output / traceback) plus expected vs. actual "
+ "behavior. Written steps with no run output don't count, and mocked "
+ "or stubbed runs don't count.\n"
+ "- For **feature requests**: a clear description of the proposed "
+ "feature plus a use case + concrete example (config, API call, UI "
+ "flow, or scenario showing what's blocked today)."
+ )
+
+
+def _description_only_note(kind: str) -> str:
+ noun = "PR" if kind == "pr" else "issue"
+ return (
+ f"⚠️ **The requirements must live in the {noun} *description*, not in "
+ "comments.** Some PRs/issues collect 100+ comments from humans and "
+ "bots; reading the entire thread on every triage run would balloon "
+ "GitHub API usage (we'd start getting 429'd) and blow out the LLM "
+ "judge's context. The bot only reads the description, so anything "
+ "you add as a comment will be invisible to it."
+ )
+
+
+def _missing_section(verdict: dict, greptile_score: int | None) -> str:
+ """Bullet list of what's currently missing on this PR/issue.
+
+ Combines the LLM judge's `missing` list (rubric items) with a Greptile
+ shortfall (for PRs) so the contributor sees one list of things to fix.
+ """
+ missing = list(verdict.get("missing") or [])
+ if greptile_score is not None and greptile_score < 4:
+ missing.insert(
+ 0,
+ f"Greptile's most recent review scored this PR {greptile_score}/5 "
+ "(below the 4/5 bar Agent Shin will require).",
+ )
+ if not missing:
+ return (
+ "_The bot couldn't articulate a specific missing piece; see the "
+ "rubric link above and double-check the description includes all "
+ "of it before the rollout._"
+ )
+ bullets = "\n".join(f"- {m}" for m in missing)
+ return f"**What this one is currently missing:**\n\n{bullets}"
+
+
+def _recovery_section(kind: str) -> str:
+ if kind == "pr":
+ return (
+ "**If the bot closes this PR after the rollout:** update the "
+ "description with the missing pieces, then either open a fresh "
+ "PR or comment `@agent-shin reconsider` on the closed PR. If "
+ "Greptile re-scores you at 4/5 or higher I'll reopen and tag "
+ "the PR `ready for review`. (`@greptileai` works on closed PRs "
+ "too; a fresh review is one of the signals that lifts you back "
+ "into the queue.) This is **not** us losing interest in your "
+ "change; far from it. We just need open PRs to be a list of "
+ "things a maintainer can act on, so we can get to yours faster."
+ )
+ return (
+ "**If the bot closes this issue after the rollout:** edit the issue "
+ "description to add the missing pieces, then comment `@agent-shin "
+ "reconsider` on the closed issue. I'll re-evaluate and, if the rubric "
+ "is met, reopen it. (GitHub doesn't let external authors reopen an "
+ "issue a maintainer or bot closed, so the comment is the reliable "
+ "path.) This is **not** us saying the bug isn't real or the request "
+ "isn't useful; it's so the remaining open issues are a list of things "
+ "a maintainer can act on."
+ )
+
+
+def format_heads_up_comment(
+ *, kind: str, verdict: dict, greptile_score: int | None, cutoff: dt.date
+) -> str:
+ """Compose the friendly 7-day heads-up comment posted on a failing PR/issue."""
+ noun = "PR" if kind == "pr" else "issue"
+ rubric = _rubric_section_pr() if kind == "pr" else _rubric_section_issue()
+ cutoff_str = _format_cutoff(cutoff)
+ explanation = (verdict.get("explanation") or "").strip()
+ explanation_block = (
+ f"> _(The judge's note for this one: {explanation})_\n\n" if explanation else ""
+ )
+
+ return (
+ "🚅 **Heads-up: we're turning on the OSS triage bot in "
+ f"{DEFAULT_GRACE_DAYS} days, on {cutoff_str}.**\n"
+ "\n"
+ "We're rolling out **Agent Shin**, an LLM-as-judge triage bot for "
+ f"external {noun}s. Once it's live, the bot reads each open "
+ f"{noun}'s description, scores it against a small rubric, and "
+ f"auto-closes any {noun} that's missing the basics, with a single "
+ f"comment explaining what's missing and how to recover. Full "
+ f"context: [Agent Shin rollout blog post]({ROLLOUT_BLOG_URL}).\n"
+ "\n"
+ f"{rubric}\n"
+ "\n"
+ f"{_description_only_note(kind)}\n"
+ "\n"
+ f"{_missing_section(verdict, greptile_score)}\n"
+ "\n"
+ f"{explanation_block}"
+ "**Timeline (you have a week):**\n"
+ "\n"
+ f"- We turn the bot on in {DEFAULT_GRACE_DAYS} days, on "
+ f"**{cutoff_str}**. You have until then to update this {noun}'s "
+ "description with the missing pieces above.\n"
+ f"- If this {noun} still fails the rubric at **{cutoff_str}**, "
+ "we'll close it.\n"
+ f"- From then on the bot runs daily, and every {noun} that fails "
+ "the rubric gets a **2-hour lifetime**: one warning comment, then "
+ "auto-close 2 hours later.\n"
+ "\n"
+ f"{_recovery_section(kind)}\n"
+ "\n"
+ f"{HEADS_UP_MARKER}"
+ )
+
+
+def _list_open_numbers(repo: str, kind: str) -> list[int]:
+ """Return every open PR or issue number in ``repo``.
+
+ Delegates to ``list_open_items`` so the full backlog is fetched (no cap)
+ and the `gh {pr,issue} list` invocation stays in one shared place. ``gh
+ issue list`` would include PRs, but ``list_open_items`` uses the dedicated
+ command per kind, so the two never mix.
+ """
+ return [
+ item["number"] for item in list_open_items(kind, repo=repo, fields="number")
+ ]
+
+
+def _has_heads_up_marker(item: dict) -> bool:
+ """Cheap fast-path: check the PR/issue body itself for the marker.
+
+ The marker is appended to the *comment* we post, not the body, so this
+ will only fire if the body literally contains the marker text. We still
+ do the comment-marker check separately below; this body check just lets
+ us short-circuit for PRs/issues that quote the marker for any reason.
+ """
+ body = item.get("body") or ""
+ return HEADS_UP_MARKER in body
+
+
+def _comments_have_marker(repo: str, number: int) -> bool:
+ """True if the bot already posted a comment carrying the marker.
+
+ Used for idempotency: a re-run skips items the previous run notified.
+ Filters by author (matching the sibling marker-checks in
+ ``triage_with_llm._has_marker`` and
+ ``agent_shin_shared.seconds_since_latest_marker_comment``) so a
+ contributor who quotes the heads-up via GitHub's "Quote reply" — which
+ preserves HTML comments in the raw markdown — can't trick the
+ idempotency check into silently skipping a real heads-up.
+
+ Comments live on the unified issues endpoint regardless of whether the
+ item is a PR or an issue, so no ``kind`` argument is required here.
+ """
+ expected_login = (
+ os.environ.get("AGENT_SHIN_BOT_LOGIN") or AGENT_SHIN_DEFAULT_BOT_LOGIN
+ ).lower()
+ raw = gh(
+ "api",
+ "--paginate",
+ f"repos/{repo}/issues/{number}/comments?per_page=100",
+ )
+ for line in raw.splitlines():
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ payload = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ comments = payload if isinstance(payload, list) else [payload]
+ for comment in comments:
+ author = ((comment.get("user") or {}).get("login") or "").lower()
+ if author != expected_login:
+ continue
+ if HEADS_UP_MARKER in (comment.get("body") or ""):
+ return True
+ return False
+
+
+def _evaluate_pr(*, repo: str, number: int, model: str, judge: Any = None) -> dict:
+ """Run the future PR rubric (review_gate) in dry-run and return the result."""
+ return review_gate(
+ repo=repo,
+ number=number,
+ close=False, # we only want the verdict, never act here
+ model=model,
+ judge=judge,
+ )
+
+
+def _evaluate_issue(*, repo: str, number: int, model: str, judge: Any = None) -> dict:
+ """Run the future issue rubric (triage kind='issue') in dry-run."""
+ return triage(
+ repo=repo,
+ kind="issue",
+ number=number,
+ close=False,
+ model=model,
+ judge=judge,
+ )
+
+
+def _would_be_closed(kind: str, result: dict) -> bool:
+ """True if the future triage would auto-close this PR/issue based on the
+ rubric (regardless of grace-period gating).
+
+ For PRs we trust ``review_gate``'s ``passing`` field — it combines the LLM
+ verdict and the Greptile score. For issues we read the LLM verdict
+ directly. Both fields are ``None``/missing on skip paths
+ (skip-internal-author, skip-llm-error, etc.) where the future bot would
+ NOT close the item — those return False.
+ """
+ if kind == "pr":
+ passing = result.get("passing")
+ if passing is None:
+ return False # skipped — nothing for the heads-up to warn about
+ return passing is False
+ verdict = result.get("verdict") or {}
+ return (verdict.get("verdict") or "").lower() == "fail"
+
+
+def _process_one(
+ *,
+ repo: str,
+ kind: str,
+ number: int,
+ model: str,
+ cutoff: dt.date,
+ dry_run: bool,
+ judge: Any = None,
+ skip_marker_check: bool = False,
+ allowlist: frozenset[str] = ALLOWLIST_LOGINS,
+) -> dict:
+ """Evaluate one PR/issue and post a heads-up if it would be auto-closed.
+
+ Returns a per-item dict for the summary table.
+ """
+ base = {"kind": kind, "number": number}
+ fetcher = fetch_pr if kind == "pr" else fetch_issue
+ item = fetcher(repo, number)
+
+ if (item.get("state") or "") != "open":
+ return {**base, "action": "skip-not-open"}
+ if allowlist:
+ login = (item.get("user") or {}).get("login") or ""
+ if login.lower() not in allowlist:
+ return {**base, "action": "skip-not-allowlisted"}
+ elif is_internal_contributor(item):
+ return {**base, "action": "skip-internal-author"}
+ if not skip_marker_check and _has_heads_up_marker(item):
+ return {**base, "action": "skip-already-marked-in-body"}
+ if not skip_marker_check and _comments_have_marker(repo, number):
+ return {**base, "action": "skip-already-notified"}
+
+ if kind == "pr":
+ result = _evaluate_pr(repo=repo, number=number, model=model, judge=judge)
+ else:
+ result = _evaluate_issue(repo=repo, number=number, model=model, judge=judge)
+
+ if not _would_be_closed(kind, result):
+ return {**base, "action": "skip-passing", "evaluator": result.get("action")}
+
+ verdict = result.get("verdict") or {}
+ greptile_score = result.get("greptile_score") if kind == "pr" else None
+ comment = format_heads_up_comment(
+ kind=kind, verdict=verdict, greptile_score=greptile_score, cutoff=cutoff
+ )
+ maybe_post_comment(repo, number, comment, dry_run=dry_run)
+ return {
+ **base,
+ "action": "heads-up-posted" if not dry_run else "would-post-heads-up",
+ "verdict": (verdict.get("verdict") or "").lower(),
+ "greptile_score": greptile_score,
+ }
+
+
+def _print_summary(results: list[dict]) -> None:
+ """Tally per-action counts so a dry-run preview tells you at a glance how
+ many comments the real run would post."""
+ counts: dict[str, int] = {}
+ for r in results:
+ counts[r["action"]] = counts.get(r["action"], 0) + 1
+ print("\n=== rollout heads-up summary ===")
+ for action in sorted(counts):
+ print(f" {action:35s} {counts[action]}")
+ print(f" total {len(results)}")
+
+
+def run(
+ *,
+ repo: str,
+ close: bool,
+ cutoff: dt.date,
+ model: str,
+ kinds: tuple[str, ...] = ("pr", "issue"),
+ judge: Any = None,
+ only_numbers: dict[str, list[int]] | None = None,
+ skip_marker_check: bool = False,
+) -> list[dict]:
+ """Sweep ``repo`` and post heads-up comments. Returns the per-item results."""
+ dry_run = not close
+ if dry_run:
+ print(
+ f"[DRY RUN] sweeping {repo}; --close not passed, no comments will be posted."
+ )
+ else:
+ print(f"[REAL RUN] sweeping {repo}; comments WILL be posted.")
+ print(f"Cutoff date in comment body: {cutoff.isoformat()}")
+
+ results: list[dict] = []
+ for kind in kinds:
+ if only_numbers and kind in only_numbers:
+ numbers = list(only_numbers[kind])
+ else:
+ numbers = _list_open_numbers(repo, kind)
+ print(f"\n--- {kind}s: {len(numbers)} open ---")
+ for n in numbers:
+ try:
+ result = _process_one(
+ repo=repo,
+ kind=kind,
+ number=n,
+ model=model,
+ cutoff=cutoff,
+ dry_run=dry_run,
+ judge=judge,
+ skip_marker_check=skip_marker_check,
+ )
+ except (
+ Exception
+ ) as exc: # noqa: BLE001 - per-item errors don't abort the sweep
+ result = {
+ "kind": kind,
+ "number": n,
+ "action": "error",
+ "error": str(exc),
+ }
+ print(f"!! {kind}#{n}: {exc}", file=sys.stderr)
+ print(f" {kind}#{n}: {result['action']}")
+ results.append(result)
+ _print_summary(results)
+ return results
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--repo", required=True, help="owner/repo")
+ parser.add_argument(
+ "--close",
+ action="store_true",
+ help=(
+ "Actually post comments. Without this flag the script is in "
+ "dry-run mode and only logs what it would do."
+ ),
+ )
+ parser.add_argument(
+ "--close-on",
+ type=dt.date.fromisoformat,
+ default=None,
+ help=(
+ "Cutoff date shown in the heads-up comment as the rollout date "
+ f"(default: today + {DEFAULT_GRACE_DAYS} days)."
+ ),
+ )
+ parser.add_argument(
+ "--model",
+ default=os.environ.get("TRIAGE_MODEL") or DEFAULT_MODEL,
+ help=f"Model for the rubric LLM judge (default: {DEFAULT_MODEL}).",
+ )
+ parser.add_argument(
+ "--kind",
+ choices=("pr", "issue", "both"),
+ default="both",
+ help="Restrict the sweep to PRs or issues only (default: both).",
+ )
+ parser.add_argument(
+ "--only-pr",
+ type=int,
+ action="append",
+ default=[],
+ help="Limit the PR sweep to these PR numbers (repeat for several).",
+ )
+ parser.add_argument(
+ "--only-issue",
+ type=int,
+ action="append",
+ default=[],
+ help="Limit the issue sweep to these issue numbers (repeat for several).",
+ )
+ parser.add_argument(
+ "--ignore-existing-marker",
+ action="store_true",
+ help=(
+ "Re-post on PRs/issues that already carry the heads-up marker. "
+ "Useful for testing the comment wording on a known PR."
+ ),
+ )
+ args = parser.parse_args()
+
+ cutoff = args.close_on or (
+ dt.datetime.now(dt.timezone.utc).date() + dt.timedelta(days=DEFAULT_GRACE_DAYS)
+ )
+
+ kinds: tuple[str, ...]
+ if args.kind == "pr":
+ kinds = ("pr",)
+ elif args.kind == "issue":
+ kinds = ("issue",)
+ else:
+ kinds = ("pr", "issue")
+
+ only: dict[str, list[int]] = {}
+ if args.only_pr:
+ only["pr"] = args.only_pr
+ if args.only_issue:
+ only["issue"] = args.only_issue
+
+ # The script must NOT hit the LLM in dry-run if no key is set — we still
+ # want a useful preview that says "skip-no-llm-key" for items that would
+ # have been judged. Production runs require OPENAI_API_KEY.
+ if args.close and not os.environ.get("OPENAI_API_KEY"):
+ parser.error("OPENAI_API_KEY must be set for --close (real-run) mode.")
+
+ run(
+ repo=args.repo,
+ close=args.close,
+ cutoff=cutoff,
+ model=args.model,
+ kinds=kinds,
+ only_numbers=only or None,
+ skip_marker_check=args.ignore_existing_marker,
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.github/scripts/triage_with_llm.py b/.github/scripts/triage_with_llm.py
new file mode 100644
index 00000000000..d2536058e01
--- /dev/null
+++ b/.github/scripts/triage_with_llm.py
@@ -0,0 +1,1778 @@
+#!/usr/bin/env python3
+"""
+Agent Shin — LLM-as-judge triage for external OSS pull requests and issues.
+
+Evaluates a single PR or issue against the contribution rubric and, when the
+LLM judge marks it as failing, posts an explanatory comment + closes the
+PR/issue. Re-triggers on `reopened` so contributors can iterate back in by
+filling in the missing pieces and reopening.
+
+Internal BerriAI contributors (`author_association` in {OWNER, MEMBER,
+COLLABORATOR}) and bot accounts are skipped entirely.
+
+Usage:
+ triage_with_llm.py --repo owner/repo --pr 1234
+ triage_with_llm.py --repo owner/repo --issue 5678
+ triage_with_llm.py --repo owner/repo --pr 1234 --close # actually close
+ triage_with_llm.py --repo owner/repo --pr 1234 --print-prompt # show prompt
+
+Defaults are SAFE: without `--close` the script writes a verdict to stdout (and,
+when running in GitHub Actions, to $GITHUB_STEP_SUMMARY) but takes no GitHub
+write actions.
+
+Environment:
+ GH_TOKEN / GITHUB_TOKEN - for `gh` CLI auth (auto-set in Actions)
+ OPENAI_API_KEY - required when --close is passed
+ OPENAI_BASE_URL - optional (route to any OpenAI-compatible API)
+ TRIAGE_MODEL - optional model override (default: gpt-5.4-mini)
+"""
+
+from __future__ import annotations
+
+import argparse
+import datetime as dt
+import json
+import os
+import re
+import subprocess
+import sys
+import textwrap
+import urllib.parse
+from typing import Any, Iterable
+
+# Add this script's directory to `sys.path` so the sibling
+# `agent_shin_shared` module is importable when the script is invoked
+# directly (e.g. `python3 .github/scripts/triage_with_llm.py ...`) and
+# also when the tests load this script via
+# `importlib.util.spec_from_file_location`.
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+from agent_shin_shared import ( # noqa: E402 -- sys.path adjusted above
+ AGENT_SHIN_CLOSE_MARKER,
+ AGENT_SHIN_DEFAULT_BOT_LOGIN,
+ ALLOWLIST_LOGINS,
+ GRACE_COMMENT_MARKER,
+ GRACE_PERIOD_SECONDS,
+ GREPTILE_BOT_LOGINS,
+ SCORE_PATTERN,
+ extract_greptile_score,
+ gh,
+ parse_iso8601,
+ seconds_since_latest_marker_comment,
+)
+
+DEFAULT_MODEL = "gpt-5.4-mini"
+
+INTERNAL_ASSOCIATIONS = frozenset({"OWNER", "MEMBER", "COLLABORATOR"})
+
+# `AGENT_SHIN_DEFAULT_BOT_LOGIN` is imported from `agent_shin_shared`.
+# When the workflow uses the default `secrets.GITHUB_TOKEN`, the
+# closure / reopen event's `actor.login` is `github-actions[bot]`. The
+# env override `AGENT_SHIN_BOT_LOGIN` exists for local debugging and for
+# repos that wire Agent Shin to a PAT.
+
+# HTML marker appended to every reconsider verdict comment. We grep for this
+# on subsequent reconsider triggers to enforce a short cooldown so that
+# repeated `@agent-shin reconsider` comments don't burn CI/LLM budget.
+# Using a unique HTML comment keeps the marker invisible to humans while
+# being trivially greppable from a comments-list API response.
+RECONSIDER_COMMENT_MARKER = ""
+
+# Minimum gap between two reconsider verdicts on the same PR/issue. Set to
+# 10 minutes — long enough that a contributor can't trivially spam the
+# trigger, short enough that a genuine "I just pushed a fix and reupdated
+# the body" iteration loop isn't punished.
+RECONSIDER_RATE_LIMIT_SECONDS = 600
+
+# `GRACE_COMMENT_MARKER` (HTML marker on the grace-period warning comment
+# posted on the first low-quality detection — used on subsequent triage
+# runs to detect that a warning was already posted and measure how long
+# ago it was posted) and `GRACE_PERIOD_SECONDS` (length of the grace
+# period between the warning and the actual auto-close, 2 hours) are
+# imported from `agent_shin_shared` so the daily Greptile sweep and the
+# LLM judge agree on the same marker and duration.
+
+# --- Review-gate ("ready for review" label lifecycle) configuration ----------
+# The review gate keeps a single label in sync with whether a PR currently
+# clears BOTH quality bars: the LLM rubric (clear problem + expected/actual +
+# QA proof, or a linked issue) AND Greptile's most recent confidence score.
+READY_FOR_REVIEW_LABEL = "ready for review"
+DEFAULT_GRACE_DAYS = 1 # 24h before an un-passing, un-tagged PR is auto-closed
+DEFAULT_MIN_GREPTILE_SCORE = 4 # Greptile < 4/5 counts as "not passing"
+
+# Hidden HTML-comment markers stamped into review-gate comments. They never
+# render in the GitHub UI but let the gate detect its own prior actions so it
+# (a) posts the within-grace "what's missing" notice at most once and (b) can
+# tell a first-time pass ("ready for review") from a recovery after a
+# regression ("all clear again").
+READY_MARKER = ""
+REGRESSED_MARKER = ""
+WITHIN_GRACE_MARKER = ""
+
+# `GREPTILE_BOT_LOGINS` (Greptile's GitHub App login variants —
+# `greptile-apps[bot]` in REST API comments, `greptile-apps` in
+# `gh pr view --json` output) and `SCORE_PATTERN` (regex matching lines
+# like `Confidence Score: 3/5`) are imported from `agent_shin_shared`
+# so the daily sweep and the review gate read the score through the
+# same set of logins / patterns.
+
+# `AGENT_SHIN_CLOSE_MARKER` is imported from `agent_shin_shared` so this LLM
+# judge and the daily Greptile sweep stamp the same marker on their close
+# comments — `was_closed_by_agent_shin` keys the reconsider reopen path off it.
+
+# Model families that require `reasoning_effort` to be set, and that reject
+# `temperature != 1` unless `reasoning_effort` is "none". For these models we
+# pass `reasoning_effort="none"` so a `temperature=0` deterministic judgment
+# is still accepted. See litellm/llms/openai/chat/gpt_5_transformation.py for
+# the full set of constraints LiteLLM applies to these models.
+GPT5_FAMILY_PREFIX = "gpt-5"
+
+# Regexes for picking off "obvious passes" without burning LLM tokens.
+#
+# Keep this list to GitHub's documented PR-closing keywords only
+# (https://docs.github.com/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
+# Casual mentions like "see #1234" or "ref #1234" are intentionally NOT
+# auto-passed — they should fall through to the LLM judge, which has the
+# stricter rubric "a bare issue number without a closing keyword counts only
+# if it's clearly the related issue (not a passing mention)".
+LINKED_ISSUE_PATTERN = re.compile(
+ r"\b(?:fixes|fix|fixed|closes|close|closed|resolves|resolve|resolved)\s+"
+ r"(?:#\d+|https?://github\.com/[\w.-]+/[\w.-]+/issues/\d+)",
+ re.IGNORECASE,
+)
+HTML_COMMENT_PATTERN = re.compile(r"", re.DOTALL)
+
+
+# ---------------------------------------------------------------------------
+# gh helpers
+#
+# `gh` is imported from `agent_shin_shared` so a future change (timeout,
+# logging, retry) only needs to be made once.
+
+
+def fetch_pr(repo: str, number: int) -> dict:
+ """Return the full GitHub REST representation of a PR."""
+ return json.loads(gh("api", f"repos/{repo}/pulls/{number}"))
+
+
+def fetch_issue(repo: str, number: int) -> dict:
+ """Return the full GitHub REST representation of an issue."""
+ return json.loads(gh("api", f"repos/{repo}/issues/{number}"))
+
+
+def post_comment(repo: str, number: int, body: str) -> None:
+ """Post an issue-style comment (works for both issues and PRs)."""
+ gh(
+ "api",
+ f"repos/{repo}/issues/{number}/comments",
+ "-X",
+ "POST",
+ "-f",
+ f"body={body}",
+ )
+
+
+def close_pr(repo: str, number: int) -> None:
+ """Close a pull request (state=closed)."""
+ gh(
+ "api",
+ f"repos/{repo}/pulls/{number}",
+ "-X",
+ "PATCH",
+ "-f",
+ "state=closed",
+ )
+
+
+def reopen_pr(repo: str, number: int) -> None:
+ """Reopen a previously-closed pull request (state=open).
+
+ Used by the `@agent-shin reconsider` comment-trigger flow: the bot has
+ write access via GH_TOKEN, so it can reopen on the contributor's behalf
+ even though GitHub doesn't let the OSS author do it themselves.
+ """
+ gh(
+ "api",
+ f"repos/{repo}/pulls/{number}",
+ "-X",
+ "PATCH",
+ "-f",
+ "state=open",
+ )
+
+
+def close_issue(repo: str, number: int, *, not_planned: bool = True) -> None:
+ """Close an issue, marking state_reason=not_planned by default."""
+ args = [
+ "api",
+ f"repos/{repo}/issues/{number}",
+ "-X",
+ "PATCH",
+ "-f",
+ "state=closed",
+ ]
+ if not_planned:
+ args.extend(["-f", "state_reason=not_planned"])
+ gh(*args)
+
+
+def reopen_issue(repo: str, number: int) -> None:
+ """Reopen a previously-closed issue (state=open, state_reason=reopened)."""
+ gh(
+ "api",
+ f"repos/{repo}/issues/{number}",
+ "-X",
+ "PATCH",
+ "-f",
+ "state=open",
+ "-f",
+ "state_reason=reopened",
+ )
+
+
+def add_label(repo: str, number: int, label: str) -> None:
+ """Add a label to a PR/issue (GitHub creates the label if it's missing)."""
+ gh(
+ "api",
+ f"repos/{repo}/issues/{number}/labels",
+ "-X",
+ "POST",
+ "-f",
+ f"labels[]={label}",
+ )
+
+
+def remove_label(repo: str, number: int, label: str) -> None:
+ """Remove a label from a PR/issue. A missing label (404) is not an error."""
+ encoded = urllib.parse.quote(label, safe="")
+ try:
+ gh(
+ "api",
+ f"repos/{repo}/issues/{number}/labels/{encoded}",
+ "-X",
+ "DELETE",
+ )
+ except subprocess.CalledProcessError as exc:
+ stderr = (exc.stderr or "").lower()
+ if "404" in stderr or "not found" in stderr:
+ return
+ raise
+
+
+def _iter_paginated_json(*api_args: str) -> Any:
+ """Yield JSON objects from `gh api --paginate ... -q '.[]'`.
+
+ `gh api --paginate` on a JSON-array endpoint concatenates pages into
+ one stream; `-q '.[]'` flattens that stream into newline-delimited
+ objects (jq-style). This keeps memory bounded for chatty endpoints
+ like issue events/comments on long-lived PRs.
+ """
+ raw = gh("api", "--paginate", *api_args, "-q", ".[]")
+ for line in raw.splitlines():
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ yield json.loads(line)
+ except json.JSONDecodeError:
+ # A malformed line should not blow up the whole guard. Skip and
+ # carry on — at worst the guard fail-closes (returns False /
+ # None) and the caller treats it as "unknown".
+ continue
+
+
+def fetch_last_close_event(
+ repo: str, number: int
+) -> tuple[str | None, dt.datetime | None]:
+ """Return the actor login and timestamp of the most recent `closed` event.
+
+ Either field may be None: actor when the events API returns nothing
+ (unusual for a closed item, but possible on transient errors), and
+ timestamp when the event lacks `created_at` or the value can't be
+ parsed. `was_closed_by_agent_shin` fail-closes on either.
+ """
+ actor: str | None = None
+ closed_at: dt.datetime | None = None
+ for event in _iter_paginated_json(f"repos/{repo}/issues/{number}/events"):
+ if event.get("event") != "closed":
+ continue
+ actor = (event.get("actor") or {}).get("login")
+ created = event.get("created_at")
+ if not created:
+ closed_at = None
+ continue
+ try:
+ closed_at = parse_iso8601(created)
+ except ValueError:
+ closed_at = None
+ return actor, closed_at
+
+
+# How much older than the latest `closed` event the Agent Shin marker
+# comment is allowed to be while still counting as "this close was Agent
+# Shin's". Agent Shin posts the close comment immediately before closing,
+# so the marker timestamp is normally at most a few seconds before the
+# close event; the buffer just absorbs clock skew between the comments
+# API and the events API.
+AGENT_SHIN_CLOSE_MARKER_SKEW_SECONDS = 300
+
+
+def was_closed_by_agent_shin(
+ repo: str, number: int, *, bot_login: str | None = None
+) -> bool:
+ """Return True iff Agent Shin itself most-recently closed this PR/issue.
+
+ This is the guard that stops `@agent-shin reconsider` from reopening an
+ item Agent Shin did not close — a maintainer closing for non-rubric
+ reasons (security, duplicate, design rejection), or a different workflow
+ (stale/duplicate sweeps) closing under the shared `github-actions[bot]`
+ identity. Three independent signals must all hold, because that identity
+ is not unique to Agent Shin and a marker comment from a prior
+ closed/reopened cycle would otherwise vouch for an unrelated close:
+
+ 1. The most recent `closed` event's actor is the bot identity.
+ 2. Agent Shin left one of its auto-close comments, detected via
+ `AGENT_SHIN_CLOSE_MARKER`. The actor check alone can't tell an
+ Agent Shin close from any other `github-actions[bot]` close.
+ 3. That marker comment was posted at (or just before) the latest
+ close event, not on a previous close in an
+ Agent-Shin-close -> reconsider-reopen -> other-bot-reclose cycle.
+
+ The check is intentionally fail-closed: any uncertainty about who closed
+ the item is treated as "not Agent Shin" so the destructive reopen path
+ stays gated.
+ """
+ expected = (
+ bot_login
+ or os.environ.get("AGENT_SHIN_BOT_LOGIN")
+ or AGENT_SHIN_DEFAULT_BOT_LOGIN
+ ).lower()
+ actor, closed_at = fetch_last_close_event(repo, number)
+ if not actor or actor.lower() != expected or closed_at is None:
+ return False
+ marker_seconds = seconds_since_last_agent_shin_close(
+ repo, number, bot_login=bot_login
+ )
+ if marker_seconds is None:
+ return False
+ close_age_seconds = (dt.datetime.now(dt.timezone.utc) - closed_at).total_seconds()
+ return marker_seconds <= close_age_seconds + AGENT_SHIN_CLOSE_MARKER_SKEW_SECONDS
+
+
+def _seconds_since_latest_marker_comment(
+ repo: str,
+ number: int,
+ *,
+ marker: str,
+ bot_login: str | None = None,
+) -> float | None:
+ """Return seconds since the bot's most recent comment with ``marker``.
+
+ Fetches comments via `_iter_paginated_json` and delegates the
+ iteration / author-filter / timestamp logic to
+ `agent_shin_shared.seconds_since_latest_marker_comment` so the daily
+ Greptile sweep and the LLM judge use one source of truth for the
+ "bot already posted X" detection. The wall-clock `now` is resolved
+ against this module's `dt` so tests that freeze time via
+ `monkeypatch.setattr(triage_module, "dt", ...)` still apply.
+ """
+ return seconds_since_latest_marker_comment(
+ _iter_paginated_json(f"repos/{repo}/issues/{number}/comments"),
+ marker=marker,
+ bot_login=bot_login,
+ now=dt.datetime.now(dt.timezone.utc),
+ )
+
+
+def seconds_since_last_reconsider_verdict(
+ repo: str, number: int, *, bot_login: str | None = None
+) -> float | None:
+ """Return seconds since the bot's most recent reconsider verdict comment.
+
+ Detects comments by matching the HTML marker `RECONSIDER_COMMENT_MARKER`
+ appended by `format_reopen_comment` and
+ `format_reconsider_still_failing_comment`. Returns None when the bot
+ has never posted a reconsider verdict on this PR/issue (or when the
+ only matching comments are missing a `created_at` timestamp, which
+ shouldn't happen on a real GitHub response).
+ """
+ return _seconds_since_latest_marker_comment(
+ repo, number, marker=RECONSIDER_COMMENT_MARKER, bot_login=bot_login
+ )
+
+
+def seconds_since_last_grace_warning(
+ repo: str, number: int, *, bot_login: str | None = None
+) -> float | None:
+ """Return seconds since the bot's most recent grace-period warning.
+
+ Detects warning comments by matching the HTML marker
+ `GRACE_COMMENT_MARKER` appended by `format_grace_warning_pr_comment`
+ and `format_grace_warning_issue_comment`. Returns None when no
+ grace warning has ever been posted on this PR/issue — that's the
+ "first low-quality detection" signal that drives the warning path.
+ """
+ return _seconds_since_latest_marker_comment(
+ repo, number, marker=GRACE_COMMENT_MARKER, bot_login=bot_login
+ )
+
+
+def seconds_since_last_agent_shin_close(
+ repo: str, number: int, *, bot_login: str | None = None
+) -> float | None:
+ """Return seconds since Agent Shin's most recent auto-close comment.
+
+ Detects close comments by matching `AGENT_SHIN_CLOSE_MARKER` (stamped by
+ `format_pr_close_comment` / `format_issue_close_comment`). Returns None
+ when Agent Shin has never closed this PR/issue — the signal
+ `was_closed_by_agent_shin` uses to keep the reconsider reopen path gated
+ against closures performed by other workflows sharing the bot identity.
+ """
+ return _seconds_since_latest_marker_comment(
+ repo, number, marker=AGENT_SHIN_CLOSE_MARKER, bot_login=bot_login
+ )
+
+
+# ---------------------------------------------------------------------------
+# Author classification
+
+
+def is_internal_contributor(item: dict) -> bool:
+ """Return True if the PR/issue author should be exempted from triage.
+
+ Fail-safe: if `author_association` is missing or empty (which should never
+ happen on a successful GitHub REST response but is possible on schema
+ changes or partial responses), treat the author as INTERNAL so the
+ destructive close path never fires on an unknown contributor. This matches
+ the sibling `is_external_pr_author` in `close_low_quality_prs.py`.
+ """
+ login = ((item.get("user") or {}).get("login") or "").lower()
+ if login.endswith("[bot]") or login in {"dependabot", "github-actions"}:
+ return True
+ association = (item.get("author_association") or "").upper()
+ if not association or association in INTERNAL_ASSOCIATIONS:
+ return True
+ return False
+
+
+# ---------------------------------------------------------------------------
+# Greptile score + age helpers (`extract_greptile_score`, `parse_iso8601`)
+# live in `agent_shin_shared` — they're imported at the top of this module
+# so both `triage_with_llm.py` and `close_low_quality_prs.py` share a
+# single source of truth for the Confidence-Score regex and ISO-8601
+# parsing.
+
+
+# ---------------------------------------------------------------------------
+# Prompt construction
+
+
+def strip_html_comments(text: str) -> str:
+ """Remove HTML comments — template placeholder text shouldn't fool the judge."""
+ return HTML_COMMENT_PATTERN.sub("", text or "")
+
+
+def has_linked_issue(text: str) -> bool:
+ """Heuristic: does this body link to an open issue (Fixes #123 etc.)?"""
+ return bool(LINKED_ISSUE_PATTERN.search(strip_html_comments(text or "")))
+
+
+def build_pr_prompt(*, title: str, body: str) -> str:
+ cleaned_body = strip_html_comments(body or "").strip() or "(empty)"
+ # Dedent the static template *before* interpolating dynamic fields so that
+ # multi-line bodies (whose 2nd+ lines start at column 0) don't defeat the
+ # common-indent computation in textwrap.dedent.
+ template = textwrap.dedent("""
+ You are "Agent Shin", the OSS triage bot for the LiteLLM open-source
+ repository (BerriAI/litellm). Decide whether this external pull request
+ meets the project's contribution standards.
+
+ A PR PASSES triage only if BOTH (1) AND (2) are satisfied. A linked
+ issue alone is NOT enough — it covers context, not proof.
+
+ (1) CONTEXT — the PR provides AT LEAST ONE of:
+ (a) A link to a related GitHub issue. Acceptable forms:
+ "Fixes #1234", "Closes #1234", "Resolves #1234",
+ "Refs https://github.com/BerriAI/litellm/issues/1234". A
+ bare "#1234" without a closing keyword counts only if it
+ is clearly the related issue (not a passing mention).
+ (b) A clear problem description in the body (what bug or
+ missing feature this addresses, beyond the title) AND
+ expected vs. actual behavior (or, for features, "what's
+ possible now vs. with this PR").
+
+ (2) END-TO-END QA PROOF: the PR body contains AT LEAST ONE of:
+ (a) A screen recording / video showing the behavior before
+ and after the change (the bug reproducing, then the fix
+ working). For a brand-new feature with no meaningful
+ "before", a recording of it working end-to-end is fine.
+ (b) A screenshot (or before/after screenshots) showing the
+ fix or feature working.
+ (c) Specific commands that were actually run (curl, python,
+ a CLI invocation, etc.) PAIRED WITH their real
+ output, demonstrating the change works end-to-end against
+ the real system. Commands whose external dependencies
+ (LLM provider, DB, network) are mocked or stubbed do NOT
+ satisfy (2c); they are not end-to-end.
+
+ `has_qa_proof` must be set to `true` only when (2a), (2b),
+ or a non-mocked (2c) is actually present in the body. If the
+ only "proof" is mocked tests, `has_qa_proof` is `false` and
+ the verdict is "fail".
+
+ The following do NOT count as QA proof:
+ - Generic claims like "I tested it", "works locally", "all
+ tests pass", or a checked "I added tests" checkbox with no
+ output shown.
+ - A description of what tests exist or were added, without
+ their actual output in the PR body.
+ - `pytest` (or any test runner) executed against the
+ repository's own unit tests. Those mock the LLM provider,
+ DB, and network, so they are NOT end-to-end and never
+ satisfy (2), no matter how much passing output is pasted.
+ - A linked issue. The linked issue is context (1a), never
+ proof (2).
+
+ FAIL the PR if EITHER (1) or (2) is missing. Do not bias toward PASS:
+ if QA proof is absent, the verdict is "fail" even when the rest of
+ the PR is well-written.
+
+ Respond with a single JSON object, no prose:
+
+ {{
+ "verdict": "pass" | "fail",
+ "linked_issue": boolean,
+ "has_problem_description": boolean,
+ "has_expected_vs_actual": boolean,
+ "has_qa_proof": boolean,
+ "qa_proof_type": "video" | "screenshot" | "commands_with_output" | "none",
+ "missing": ["plain-english strings naming what is missing"],
+ "explanation": "1-2 sentence reasoning for the team to skim"
+ }}
+
+ ---
+ PR title: {title}
+
+ PR body:
+ ---
+ {cleaned_body}
+ ---
+ """).strip()
+ return template.format(title=title, cleaned_body=cleaned_body)
+
+
+def build_issue_prompt(*, title: str, body: str) -> str:
+ cleaned_body = strip_html_comments(body or "").strip() or "(empty)"
+ # Dedent the static template *before* interpolating dynamic fields so that
+ # multi-line bodies (whose 2nd+ lines start at column 0) don't defeat the
+ # common-indent computation in textwrap.dedent.
+ template = textwrap.dedent("""
+ You are "Agent Shin", the OSS triage bot for the LiteLLM open-source
+ repository (BerriAI/litellm). Decide whether this GitHub issue meets
+ the project's reporting standards.
+
+ For a BUG REPORT the issue PASSES triage only when it contains BOTH:
+ (1) END-TO-END EVIDENCE OF THE BUG (the "before"; set
+ `has_repro=true` only when this is present): AT LEAST ONE of:
+ (a) A screen recording / video of the bug happening.
+ (b) A screenshot of the bug.
+ (c) The exact command(s) actually run (curl, python, a CLI
+ invocation, etc.) PAIRED WITH their real output, traceback,
+ or logs showing the failure against the real system.
+ Commands whose external dependencies (LLM provider, DB,
+ network) are mocked or stubbed do NOT count.
+ Prose-only "steps to reproduce" with no run output, video, or
+ screenshot do NOT satisfy (1).
+ (2) Expected vs. actual behavior (`has_expected_vs_actual`).
+
+ FAIL the bug report if either (1) or (2) is missing. Do not bias
+ toward PASS: if the bug isn't demonstrated end-to-end, the verdict is
+ "fail" even when the report is well-written.
+
+ For a FEATURE REQUEST the issue PASSES triage only when it contains
+ ALL of:
+ - A clear description of the proposed feature (what should LiteLLM do
+ that it does not today).
+ - Motivation / use case with a concrete example (config, API call,
+ UI flow, or scenario showing what's blocked today).
+
+ For an issue that is neither a bug report nor a feature request (a
+ question, support request, or discussion), PASS as long as it has a
+ clear, specific ask and is not empty or template placeholder text.
+
+ Respond with a single JSON object, no prose:
+
+ {{
+ "verdict": "pass" | "fail",
+ "kind": "bug" | "feature" | "other",
+ "has_repro": boolean,
+ "has_expected_vs_actual": boolean,
+ "has_motivation_example": boolean,
+ "missing": ["plain-english strings naming what is missing"],
+ "explanation": "1-2 sentence reasoning for the team to skim"
+ }}
+
+ ---
+ Issue title: {title}
+
+ Issue body:
+ ---
+ {cleaned_body}
+ ---
+ """).strip()
+ return template.format(title=title, cleaned_body=cleaned_body)
+
+
+# ---------------------------------------------------------------------------
+# LLM call + verdict parsing
+
+
+def call_llm_judge(
+ prompt: str, *, model: str, api_key: str, base_url: str | None
+) -> str:
+ """Call an OpenAI-compatible chat completions endpoint. Returns raw text."""
+ # Import inside the function so unit tests that monkey-patch this never
+ # need the openai package installed.
+ from openai import OpenAI
+
+ client = (
+ OpenAI(api_key=api_key, base_url=base_url)
+ if base_url
+ else OpenAI(api_key=api_key)
+ )
+ kwargs: dict[str, Any] = {
+ "model": model,
+ "messages": [{"role": "user", "content": prompt}],
+ "temperature": 0,
+ "response_format": {"type": "json_object"},
+ }
+ # gpt-5.x reasoning models reject `temperature != 1` unless
+ # `reasoning_effort` is explicitly "none". Set it via `extra_body` so this
+ # works across openai SDK versions regardless of whether the SDK natively
+ # types `reasoning_effort` as a top-level chat-completions param yet.
+ if model.lower().startswith(GPT5_FAMILY_PREFIX):
+ kwargs["extra_body"] = {"reasoning_effort": "none"}
+ response = client.chat.completions.create(**kwargs)
+ return response.choices[0].message.content or ""
+
+
+def parse_verdict(raw: str) -> dict:
+ """Parse the LLM's JSON response. Tolerates ```json fences and stray text."""
+ if not raw:
+ raise ValueError("empty LLM response")
+ text = raw.strip()
+ if text.startswith("```"):
+ text = re.sub(r"^```(?:json)?\s*", "", text)
+ text = re.sub(r"\s*```$", "", text)
+ try:
+ return json.loads(text)
+ except json.JSONDecodeError:
+ match = re.search(r"\{.*\}", text, re.DOTALL)
+ if not match:
+ raise ValueError(f"could not extract JSON from LLM response: {raw[:200]}")
+ return json.loads(match.group(0))
+
+
+# ---------------------------------------------------------------------------
+# Comment composition
+
+
+def _format_missing(missing: list[str]) -> str:
+ if not missing:
+ return "- (see explanation below)"
+ return "\n".join(f"- {m}" for m in missing)
+
+
+# Rubric items the judge can mark present. The first element of each tuple is
+# the verdict-JSON boolean field, the second is the human-readable label we
+# render in the "what you got right" section of close / grace-warning comments.
+_PR_PRESENT_LABELS: tuple[tuple[str, str], ...] = (
+ ("linked_issue", "Linked a related GitHub issue"),
+ ("has_problem_description", "Clear problem description"),
+ ("has_expected_vs_actual", "Expected vs. actual behavior"),
+ ("has_qa_proof", "End-to-end QA proof"),
+)
+
+# Issue rubric labels grouped by `kind`. The judge sets `kind` to one of
+# {"bug", "feature", "other"}; when "other" we render both groups so we don't
+# silently drop a present-flag the judge actually set to True.
+_ISSUE_BUG_LABELS: tuple[tuple[str, str], ...] = (
+ (
+ "has_repro",
+ "End-to-end evidence of the bug (video, screenshot, or command + real output)",
+ ),
+ ("has_expected_vs_actual", "Expected vs. actual behavior"),
+)
+_ISSUE_FEATURE_LABELS: tuple[tuple[str, str], ...] = (
+ ("has_motivation_example", "Motivation and concrete example"),
+)
+
+
+def _format_present_for_pr(verdict: dict) -> list[str]:
+ """Human-readable rubric items the judge confirmed are present on a PR.
+
+ Drives the "what you got right" section in close / grace-warning comments.
+ The user gave explicit feedback: contributors should see what they nailed
+ *before* the list of gaps, so the comment doesn't read as pure rejection.
+ """
+ return [label for field, label in _PR_PRESENT_LABELS if verdict.get(field)]
+
+
+def _format_present_for_issue(verdict: dict) -> list[str]:
+ """Human-readable rubric items the judge confirmed are present on an issue.
+
+ Branches on the judge's `kind` field. For `"other"` (or missing kind) we
+ render the union so a present-flag isn't dropped just because the judge
+ couldn't classify the issue cleanly.
+ """
+ kind = (verdict.get("kind") or "").lower()
+ groups: list[tuple[tuple[str, str], ...]] = []
+ if kind in ("bug", "other", ""):
+ groups.append(_ISSUE_BUG_LABELS)
+ if kind in ("feature", "other", ""):
+ groups.append(_ISSUE_FEATURE_LABELS)
+ out: list[str] = []
+ for group in groups:
+ for field, label in group:
+ if verdict.get(field) and label not in out:
+ out.append(label)
+ return out
+
+
+def _format_present_block(items: list[str]) -> str:
+ """Render the optional "what you got right" block. Empty string when the
+ judge didn't confirm anything as present — better to omit the section
+ entirely than to show "What you got right: (nothing)".
+ """
+ if not items:
+ return ""
+ bullets = "\n".join(f"- ✅ {item}" for item in items)
+ return f"**What you got right:**\n\n{bullets}\n\n"
+
+
+def format_pr_close_comment(verdict: dict) -> str:
+ missing_lines = _format_missing(verdict.get("missing") or [])
+ present_block = _format_present_block(_format_present_for_pr(verdict))
+ explanation = verdict.get("explanation") or ""
+ return (
+ "🚅 Hi, thanks for the PR! I'm **Agent Shin**, the automated triage bot for this "
+ "repository. "
+ "[What's this and why am I getting it?](https://docs.litellm.ai/blog/agent-shin-triage)\n"
+ "\n"
+ "I read the description against our "
+ "[contribution rubric](https://github.com/BerriAI/litellm/blob/main/.github/pull_request_template.md). "
+ "Here's how it lined up:\n"
+ "\n"
+ f"{present_block}"
+ "**What's still missing:**\n"
+ "\n"
+ f"{missing_lines}\n"
+ "\n"
+ f"> {explanation}\n"
+ "\n"
+ "**Closing this PR isn't a rejection of the change.** We want the open-PR list to "
+ "mirror what a maintainer can act on *right now*, so contributors don't get lost in a "
+ 'backlog. A closed PR is a soft "park this for later"; your work is still here, '
+ "the diff is still here, and getting it reopened is one comment away. Take your time.\n"
+ "\n"
+ "**To bring this PR back:**\n"
+ "\n"
+ "- Update the description with the missing pieces, then comment `@agent-shin reconsider` "
+ "on this PR. I'll re-evaluate and reopen if it now passes.\n"
+ "- Or **Open a new PR** with the same fix and the updated description. GitHub doesn't "
+ "always let external contributors reopen a bot-closed PR, so a fresh PR is the most "
+ "reliable path back into the review queue.\n"
+ "- If Greptile's most recent score on this PR was below 4/5, comment `@greptileai` to "
+ "request a fresh review; that **still works even after the PR is closed**, and a "
+ "stronger score is one of the signals that lifts the PR back into the queue. A low "
+ "Greptile score isn't a blocker.\n"
+ "\n"
+ '**What "end-to-end QA proof" means**, since it\'s the most common gap: at least one '
+ "of a short before/after screen recording / video (the bug reproducing, then the fix "
+ "working; for a brand-new feature, a recording of it working end-to-end), a screenshot "
+ "(or before/after screenshots) of it working, or the exact commands you ran paired "
+ "with their **real output** against the real system. Running `pytest` on the repo's "
+ "unit tests doesn't count; those mock the LLM provider, DB, and network, so they "
+ "aren't end-to-end. Output from a real, no-mocks integration run is what we look "
+ "for. A linked issue alone isn't enough either: it covers context, not proof. See "
+ "[the full rubric](https://docs.litellm.ai/blog/agent-shin-triage#the-rubric-for-pull-requests).\n"
+ "\n"
+ "Internal BerriAI contributors: this rubric doesn't apply to you; ping a maintainer.\n"
+ "\n"
+ "_(I'm an LLM, so I'm not infallible. If you think I got this wrong, comment "
+ "`@agent-shin reconsider` or ping a maintainer; they'll override me.)_"
+ f"\n\n{AGENT_SHIN_CLOSE_MARKER}"
+ )
+
+
+def format_issue_close_comment(verdict: dict) -> str:
+ missing_lines = _format_missing(verdict.get("missing") or [])
+ present_block = _format_present_block(_format_present_for_issue(verdict))
+ explanation = verdict.get("explanation") or ""
+ return (
+ "🚅 Hi, thanks for filing this! I'm **Agent Shin**, the automated triage bot for this "
+ "repository. "
+ "[What's this and why am I getting it?](https://docs.litellm.ai/blog/agent-shin-triage)\n"
+ "\n"
+ "I read the issue against our reporting checklist. Here's how it lined up:\n"
+ "\n"
+ f"{present_block}"
+ "**What's still missing:**\n"
+ "\n"
+ f"{missing_lines}\n"
+ "\n"
+ f"> {explanation}\n"
+ "\n"
+ "**Closing this isn't us saying the bug isn't real or the request isn't useful.** We "
+ "want the open-issue list to mirror what a maintainer can act on *right now*, so "
+ "reports like yours don't get buried in a backlog. A closed issue is a soft \"park "
+ 'this for later"; your report is still here, and getting it reopened is one comment '
+ "away. Take your time.\n"
+ "\n"
+ "**To bring this issue back:**\n"
+ "\n"
+ "1. Edit the issue description to add the missing pieces:\n"
+ " - For **bug reports**: end-to-end evidence of the bug (a screen recording / "
+ "video, a screenshot, or the exact commands you ran with their real output / "
+ "traceback) plus expected vs. actual behavior. Written steps with no run output, "
+ "video, or screenshot don't count, and mocked or stubbed runs don't count.\n"
+ " - For **feature requests**: a concrete description of what should change, plus a "
+ "use case and example (config / API call / UI flow).\n"
+ "2. Comment `@agent-shin reconsider`. I'll re-run triage and reopen the issue if it "
+ "now meets the bar. (GitHub doesn't let external authors reopen an issue a maintainer "
+ "or bot closed, so the comment-based reconsider is the reliable path.)\n"
+ "\n"
+ "Internal BerriAI contributors: this rubric doesn't apply to you; ping a maintainer.\n"
+ "\n"
+ "_(I'm an LLM, so I'm not infallible. If you think I got this wrong, comment "
+ "`@agent-shin reconsider` or ping a maintainer; they'll override me.)_"
+ f"\n\n{AGENT_SHIN_CLOSE_MARKER}"
+ )
+
+
+def format_grace_warning_pr_comment(verdict: dict) -> str:
+ """Comment posted on the FIRST low-quality detection — gives the
+ contributor a 2-hour grace window to fix the PR before the next
+ triage run actually closes it.
+
+ This is the "before-close" warning. On the second triage run, if the
+ grace marker is older than `GRACE_PERIOD_SECONDS` AND the PR still
+ fails the rubric, the close path runs (which posts
+ `format_pr_close_comment` and closes the PR).
+ """
+ missing_lines = _format_missing(verdict.get("missing") or [])
+ present_block = _format_present_block(_format_present_for_pr(verdict))
+ explanation = verdict.get("explanation") or ""
+ return (
+ "🚅 Hi, thanks for the PR! I'm **Agent Shin**, the automated triage bot for this "
+ "repository. "
+ "[What's this and why am I getting it?](https://docs.litellm.ai/blog/agent-shin-triage)\n"
+ "\n"
+ "I read the description against our "
+ "[contribution rubric](https://github.com/BerriAI/litellm/blob/main/.github/pull_request_template.md). "
+ "Here's how it lined up:\n"
+ "\n"
+ f"{present_block}"
+ "**What's still missing:**\n"
+ "\n"
+ f"{missing_lines}\n"
+ "\n"
+ f"> {explanation}\n"
+ "\n"
+ "If the description isn't updated in the next **2 hours**, I'll auto-close this PR. "
+ "That's **not** us saying we don't care about the change; we want the open-PR list to "
+ "mirror what a maintainer can act on *right now*, so contributors don't get lost in a "
+ 'backlog. A closed PR is a soft "park this for later," not a rejection. Take your '
+ "time; everything below still works after the close.\n"
+ "\n"
+ "**During the grace period:** just update the PR description with the missing pieces. "
+ "No need to ping me; I'll re-check on the next sweep and skip the auto-close if it "
+ "now passes. See "
+ "[what counts as QA proof](https://docs.litellm.ai/blog/agent-shin-triage#the-rubric-for-pull-requests) "
+ "for the full rubric (a linked issue alone isn't enough; it covers context, not proof).\n"
+ "\n"
+ "**If the PR does get auto-closed in 2 hours, you still have easy recovery paths:**\n"
+ "\n"
+ "- Comment `@agent-shin reconsider` after updating the description. I'll re-evaluate "
+ "and reopen the PR if it now passes.\n"
+ "- Comment `@greptileai` to request a fresh Greptile review; that **still works even "
+ "after the PR is closed**, and a stronger score is one of the signals that lifts the "
+ "PR back into the queue. So a low Greptile score isn't a blocker either.\n"
+ "\n"
+ "Internal BerriAI contributors: this rubric doesn't apply to you; ping a maintainer.\n"
+ "\n"
+ "_(I'm an LLM, so I'm not infallible. If you think I got this wrong, ping a "
+ "maintainer; they'll override me.)_\n"
+ "\n"
+ f"{GRACE_COMMENT_MARKER}"
+ )
+
+
+def format_grace_warning_issue_comment(verdict: dict) -> str:
+ """Issue analogue of `format_grace_warning_pr_comment`."""
+ missing_lines = _format_missing(verdict.get("missing") or [])
+ present_block = _format_present_block(_format_present_for_issue(verdict))
+ explanation = verdict.get("explanation") or ""
+ return (
+ "🚅 Hi, thanks for filing this! I'm **Agent Shin**, the automated triage bot for this "
+ "repository. "
+ "[What's this and why am I getting it?](https://docs.litellm.ai/blog/agent-shin-triage)\n"
+ "\n"
+ "I read the issue against our reporting checklist. Here's how it lined up:\n"
+ "\n"
+ f"{present_block}"
+ "**What's still missing:**\n"
+ "\n"
+ f"{missing_lines}\n"
+ "\n"
+ f"> {explanation}\n"
+ "\n"
+ "If the issue isn't updated in the next **2 hours**, I'll auto-close it. That's **not** us "
+ "saying the bug isn't real or the request isn't useful; we want the open-issue list "
+ "to mirror what a maintainer can act on *right now*, so reports like yours don't get "
+ 'buried in a backlog. A closed issue is a soft "park this for later," not a '
+ "rejection. Take your time; reopening is one comment away.\n"
+ "\n"
+ "**During the grace period:** just edit the issue description with the missing "
+ "pieces. No need to ping me; I'll re-check on the next sweep and skip the auto-close "
+ "if it now passes.\n"
+ "\n"
+ "Missing pieces, depending on what this is:\n"
+ "\n"
+ "- For **bug reports**: end-to-end evidence of the bug (a screen recording / video, a "
+ "screenshot, or the exact commands you ran with their real output / traceback) plus "
+ "expected vs. actual behavior. Written steps with no run output don't count, and "
+ "mocked or stubbed runs don't count.\n"
+ "- For **feature requests**: a concrete description of what should change, plus a use "
+ "case and example (config / API call / UI flow).\n"
+ "\n"
+ "**If the issue does get auto-closed in 2 hours**, comment `@agent-shin reconsider` "
+ "and I'll re-evaluate. If it now meets the bar, I'll reopen the issue.\n"
+ "\n"
+ "Internal BerriAI contributors: this rubric doesn't apply to you; ping a maintainer.\n"
+ "\n"
+ "_(I'm an LLM, so I'm not infallible. If you think I got this wrong, ping a "
+ "maintainer; they'll override me.)_\n"
+ "\n"
+ f"{GRACE_COMMENT_MARKER}"
+ )
+
+
+# ---------------------------------------------------------------------------
+# Step-summary helpers
+
+
+def write_step_summary(content: str) -> None:
+ """When running inside GitHub Actions, append to the step summary file."""
+ path = os.environ.get("GITHUB_STEP_SUMMARY")
+ if not path:
+ return
+ try:
+ with open(path, "a", encoding="utf-8") as handle:
+ handle.write(content)
+ if not content.endswith("\n"):
+ handle.write("\n")
+ except OSError as exc:
+ print(f"warn: failed to write step summary: {exc}", file=sys.stderr)
+
+
+# ---------------------------------------------------------------------------
+# Core orchestration
+
+
+def format_reopen_comment(kind: str) -> str:
+ """Comment posted when Agent Shin reopens after a successful reconsider."""
+ noun = "PR" if kind == "pr" else "issue"
+ # The trailing HTML marker is used by `seconds_since_last_reconsider_verdict`
+ # to enforce a cooldown between repeated `@agent-shin reconsider` triggers.
+ # Keep the marker on its own line so it doesn't disturb the rendered text.
+ return (
+ f"♻️ **Re-evaluated and reopened.** Thanks for updating the {noun}!\n"
+ "\n"
+ "Agent Shin re-ran triage on the latest description and it now meets "
+ "the bar. A maintainer will take another look soon; please don't "
+ f"close this {noun} again unless asked to.\n"
+ "\n"
+ "_(If a maintainer ends up closing this for non-rubric reasons, that "
+ "decision stands; comment `@agent-shin reconsider` again only if you "
+ "have substantively new information.)_\n"
+ "\n"
+ f"{RECONSIDER_COMMENT_MARKER}"
+ )
+
+
+def format_reconsider_still_failing_comment(kind: str, verdict: dict) -> str:
+ """Comment posted when reconsider re-runs triage but the verdict is still fail."""
+ missing_lines = _format_missing(verdict.get("missing") or [])
+ explanation = verdict.get("explanation") or ""
+ noun = "PR" if kind == "pr" else "issue"
+ # The trailing HTML marker is used by `seconds_since_last_reconsider_verdict`
+ # to enforce a cooldown between repeated `@agent-shin reconsider` triggers.
+ return (
+ f"⏸️ **Re-evaluated; this {noun} still doesn't meet the rubric.**\n"
+ "\n"
+ "Agent Shin re-ran triage on the current description but is still "
+ "missing:\n"
+ "\n"
+ f"{missing_lines}\n"
+ "\n"
+ f"> {explanation}\n"
+ "\n"
+ "Update the description with the missing pieces and comment "
+ "`@agent-shin reconsider` again, or ping a maintainer if you think "
+ "I got this wrong.\n"
+ "\n"
+ "_(I'm an LLM and I'm not infallible.)_\n"
+ "\n"
+ f"{RECONSIDER_COMMENT_MARKER}"
+ )
+
+
+# ---------------------------------------------------------------------------
+# Review gate — "ready for review" label lifecycle
+
+_UNSET = object()
+
+
+def _combine_missing(
+ verdict: dict, greptile_score: int | None, min_score: int
+) -> list[str]:
+ """Merge the LLM rubric's `missing` list with a Greptile-score shortfall."""
+ missing = list(verdict.get("missing") or [])
+ if greptile_score is not None and greptile_score < min_score:
+ missing.insert(
+ 0,
+ f"Greptile's most recent review scored this PR {greptile_score}/5 "
+ f"(below the {min_score}/5 bar)",
+ )
+ return missing or ["(see explanation below)"]
+
+
+def _has_marker(
+ comments: Iterable[dict], marker: str, *, bot_login: str | None = None
+) -> bool:
+ """Return True iff the bot itself posted a comment containing ``marker``.
+
+ Filters by author so a contributor who quotes the marker (e.g. via
+ GitHub's "Quote reply" feature, which preserves HTML comments in
+ raw markdown) is not mistaken for a bot action — that would
+ silently suppress notifications or change which "recovered" wording
+ is selected. Matches the author-filter pattern used by the sibling
+ `_seconds_since_latest_marker_comment` helper.
+ """
+ expected_login = (
+ bot_login
+ or os.environ.get("AGENT_SHIN_BOT_LOGIN")
+ or AGENT_SHIN_DEFAULT_BOT_LOGIN
+ ).lower()
+ for comment in comments:
+ author = ((comment.get("user") or {}).get("login") or "").lower()
+ if author != expected_login:
+ continue
+ if marker in (comment.get("body") or ""):
+ return True
+ return False
+
+
+def format_ready_for_review_comment(
+ verdict: dict,
+ greptile_score: int | None,
+ min_greptile_score: int = DEFAULT_MIN_GREPTILE_SCORE,
+) -> str:
+ """Posted the first time a PR clears the bar (label added)."""
+ score_line = (
+ f" Greptile scored it **{greptile_score}/5**."
+ if greptile_score is not None
+ else ""
+ )
+ explanation = verdict.get("explanation") or ""
+ return (
+ "✅ **Triage passed, tagging `ready for review`.**\n"
+ "\n"
+ "Agent Shin checked this PR against the "
+ "[contribution rubric](https://github.com/BerriAI/litellm/blob/main/.github/pull_request_template.md) "
+ "and it clears the bar (a linked issue, or a clear problem description "
+ f"+ expected vs. actual + QA proof).{score_line}\n"
+ "\n"
+ f"> {explanation}\n"
+ "\n"
+ "A maintainer will take it from here. If a later re-check finds the PR "
+ f"has regressed (Greptile drops below {min_greptile_score}/5, "
+ "the QA proof is removed, etc.) I'll pull the tag and comment with "
+ "what's missing; fix it and the tag comes back automatically.\n"
+ f"{READY_MARKER}"
+ )
+
+
+def format_all_clear_comment(verdict: dict, greptile_score: int | None) -> str:
+ """Posted when a PR recovers after a regression (label re-added)."""
+ score_line = (
+ f" Greptile is back to **{greptile_score}/5**."
+ if greptile_score is not None
+ else ""
+ )
+ explanation = verdict.get("explanation") or ""
+ return (
+ "✅ **All clear again, re-adding `ready for review`.**\n"
+ "\n"
+ "Thanks for addressing the earlier feedback. On re-check this PR meets "
+ f"the contribution bar once more.{score_line}\n"
+ "\n"
+ f"> {explanation}\n"
+ "\n"
+ "A maintainer will take another look.\n"
+ f"{READY_MARKER}"
+ )
+
+
+def format_regression_comment(
+ missing: list[str], explanation: str, grace_days: int
+) -> str:
+ """Posted when a previously-tagged PR regresses (label removed, PR stays open).
+
+ Discloses the same ``grace_days`` deadline the state machine enforces:
+ once that window elapses with the PR still failing, the close path fires.
+ Hiding the deadline behind a bare "stays open" would surprise contributors
+ with an auto-close they were never warned about.
+ """
+ window = "24 hours" if grace_days == 1 else f"{grace_days} days"
+ return (
+ "⚠️ **Removing the `ready for review` tag.**\n"
+ "\n"
+ "On a re-check this PR no longer meets the contribution bar. What's "
+ "missing now:\n"
+ "\n"
+ f"{_format_missing(missing)}\n"
+ "\n"
+ f"> {explanation}\n"
+ "\n"
+ f"The PR stays open for ~{window}; address the points above and Agent "
+ 'Shin will post an "all clear" comment and re-add the tag '
+ "automatically. If the points still aren't addressed after that "
+ "window, the PR is auto-closed; that's not a rejection, and you can "
+ "comment `@agent-shin reconsider` to have it re-evaluated and reopened "
+ "once it passes.\n"
+ f"{REGRESSED_MARKER}"
+ )
+
+
+def format_within_grace_comment(
+ missing: list[str], explanation: str, grace_days: int
+) -> str:
+ """Posted once while a failing PR is still inside its grace window."""
+ window = "24 hours" if grace_days == 1 else f"{grace_days} days"
+ return (
+ "🚅 Hi, thanks for the PR! This is **Agent Shin**, the automated triage "
+ "bot. This PR doesn't quite meet the contribution bar yet:\n"
+ "\n"
+ f"{_format_missing(missing)}\n"
+ "\n"
+ f"> {explanation}\n"
+ "\n"
+ f"You have ~{window} from when this PR was opened to add the missing "
+ "pieces; just update the description and I'll re-check on the next "
+ "sweep. Once it passes I'll tag it `ready for review`. If it does get "
+ "auto-closed, that's not a rejection; comment `@agent-shin reconsider` "
+ "and I'll re-evaluate and reopen if it now passes.\n"
+ f"{WITHIN_GRACE_MARKER}"
+ )
+
+
+def review_gate(
+ *,
+ repo: str,
+ number: int,
+ close: bool,
+ model: str,
+ judge: Any = None,
+ greptile_score: Any = _UNSET,
+ comments: Any = _UNSET,
+ now: dt.datetime | None = None,
+ grace_days: int = DEFAULT_GRACE_DAYS,
+ min_greptile_score: int = DEFAULT_MIN_GREPTILE_SCORE,
+ label: str = READY_FOR_REVIEW_LABEL,
+ allowlist: frozenset[str] = ALLOWLIST_LOGINS,
+) -> dict:
+ """Reconcile the `ready for review` label with a PR's current quality.
+
+ A PR is *passing* when it clears BOTH gates: the LLM rubric (linked issue,
+ or problem description + expected/actual + QA proof) AND Greptile's most
+ recent confidence score (>= ``min_greptile_score``; absence of a score is
+ not held against the PR). The gate then drives a small state machine, using
+ the label itself as the persisted state so comments fire only on
+ transitions (never on every scheduled run):
+
+ passing, untagged -> add label + "ready for review" / "all clear"
+ passing, tagged -> noop-passing
+ not passing, tagged -> remove label + regression comment (stays open)
+ not passing, untagged, old -> close + comment (past the grace window)
+ not passing, untagged, new -> one-time "what's missing" notice (within grace)
+
+ ``close`` gates every destructive side effect: with ``close=False`` the
+ function returns a ``would-*`` preview and touches nothing, mirroring the
+ dry-run contract of :func:`triage`. ``judge``/``greptile_score``/
+ ``comments``/``now`` are injectable for tests; in production they are
+ resolved from the OpenAI judge, the PR's Greptile comment, the live comment
+ list, and the wall clock respectively.
+ """
+ item = fetch_pr(repo, number)
+
+ title = item.get("title") or ""
+ body = item.get("body") or ""
+ login = (item.get("user") or {}).get("login") or ""
+ association = item.get("author_association") or ""
+ state = item.get("state") or ""
+ # GitHub label names are case-insensitive; compare lowercased so a repo
+ # that already has e.g. "Ready for Review" is recognized as the same
+ # label as our READY_FOR_REVIEW_LABEL constant ("ready for review").
+ labels_now = {(lbl.get("name") or "").lower() for lbl in (item.get("labels") or [])}
+ label_key = label.lower()
+ created_raw = item.get("created_at") or ""
+
+ base_result = {
+ "kind": "pr",
+ "number": number,
+ "title": title,
+ "author": login,
+ "author_association": association,
+ "state": state,
+ "labeled": label_key in labels_now,
+ "review_gate": True,
+ }
+
+ if state != "open":
+ return {**base_result, "action": "skip-not-open"}
+
+ if allowlist:
+ if login.lower() not in allowlist:
+ return {**base_result, "action": "skip-not-allowlisted"}
+ elif is_internal_contributor(item):
+ return {**base_result, "action": "skip-internal-author"}
+
+ # Resolve the comment list once — used for both the Greptile score and the
+ # marker-based dedup below.
+ if comments is _UNSET:
+ comments = list(_iter_paginated_json(f"repos/{repo}/issues/{number}/comments"))
+
+ # --- rubric verdict: linked-issue short-circuit, else the LLM judge -------
+ if has_linked_issue(body):
+ verdict = {
+ "verdict": "pass",
+ "linked_issue": True,
+ "missing": [],
+ "explanation": "Linked-issue regex matched; LLM was not called.",
+ }
+ rubric_pass = True
+ else:
+ prompt = build_pr_prompt(title=title, body=body)
+ if judge is None:
+ api_key = os.environ.get("OPENAI_API_KEY")
+ if not api_key:
+ return {**base_result, "action": "skip-no-llm-key"}
+ base_url = os.environ.get("OPENAI_BASE_URL") or None
+
+ def judge(p: str) -> str:
+ return call_llm_judge(
+ p, model=model, api_key=api_key, base_url=base_url
+ )
+
+ try:
+ verdict = parse_verdict(judge(prompt))
+ except Exception as exc: # noqa: BLE001 - judge errors must never act
+ return {**base_result, "action": "skip-llm-error", "error": str(exc)}
+ rubric_pass = (verdict.get("verdict") or "").lower() == "pass"
+
+ # --- Greptile score -------------------------------------------------------
+ if greptile_score is _UNSET:
+ extraction = extract_greptile_score(comments)
+ greptile_score = extraction[0] if extraction else None
+ greptile_ok = greptile_score is None or greptile_score >= min_greptile_score
+ passing = rubric_pass and greptile_ok
+
+ # --- age ------------------------------------------------------------------
+ age_days = None
+ if created_raw:
+ reference = now or dt.datetime.now(dt.timezone.utc)
+ age_days = (reference - parse_iso8601(created_raw)).days
+
+ label_present = label_key in labels_now
+ explanation = verdict.get("explanation") or ""
+ # When the rubric short-circuited to pass (linked-issue regex) but
+ # Greptile dragged the PR below the bar, the synthetic verdict's
+ # explanation ("LLM was not called") would mislead a contributor reading
+ # the regression / close comment. Surface the real reason instead.
+ if rubric_pass and not greptile_ok:
+ explanation = (
+ f"Greptile's most recent review scored this PR "
+ f"{greptile_score}/5 (below the {min_greptile_score}/5 bar)."
+ )
+ verdict = {**verdict, "explanation": explanation}
+ base_result = {
+ **base_result,
+ "verdict": verdict,
+ "greptile_score": greptile_score,
+ "passing": passing,
+ "age_days": age_days,
+ }
+
+ if passing:
+ if label_present:
+ return {**base_result, "action": "noop-passing"}
+ recovered = _has_marker(comments, REGRESSED_MARKER)
+ comment = (
+ format_all_clear_comment(verdict, greptile_score)
+ if recovered
+ else format_ready_for_review_comment(
+ verdict, greptile_score, min_greptile_score
+ )
+ )
+ if not close:
+ return {**base_result, "action": "would-label-ready", "comment": comment}
+ post_comment(repo, number, comment)
+ add_label(repo, number, label)
+ return {**base_result, "action": "labeled-ready", "comment": comment}
+
+ missing = _combine_missing(verdict, greptile_score, min_greptile_score)
+
+ if label_present:
+ comment = format_regression_comment(missing, explanation, grace_days)
+ if not close:
+ return {**base_result, "action": "would-remove-label", "comment": comment}
+ remove_label(repo, number, label)
+ post_comment(repo, number, comment)
+ return {**base_result, "action": "label-removed-regressed", "comment": comment}
+
+ # Not passing and not tagged. If the PR was previously tagged and then
+ # regressed (we removed the label and posted REGRESSED_MARKER), honor the
+ # "PR stays open — fix it and the tag comes back" promise from
+ # `format_regression_comment` and skip the close path. Without this guard,
+ # any PR older than `grace_days` would be closed on the next evaluation,
+ # giving the contributor no realistic window to address the regression.
+ #
+ # The promise has a deliberate expiration: once `grace_days` have elapsed
+ # since the regression notice, fall through to the close path so a PR that
+ # was abandoned post-regression doesn't sit open forever.
+ if _has_marker(comments, REGRESSED_MARKER):
+ reference = now or dt.datetime.now(dt.timezone.utc)
+ seconds_since_regression = seconds_since_latest_marker_comment(
+ comments, marker=REGRESSED_MARKER, now=reference
+ )
+ grace_seconds = grace_days * 86400
+ if seconds_since_regression is None or seconds_since_regression < grace_seconds:
+ return {**base_result, "action": "regressed-already-notified"}
+
+ # Not passing and not tagged: close if past the grace window, else notify once.
+ if age_days is not None and age_days >= grace_days:
+ comment = format_pr_close_comment({**verdict, "missing": missing})
+ if not close:
+ return {**base_result, "action": "would-close", "comment": comment}
+ post_comment(repo, number, comment)
+ close_pr(repo, number)
+ return {**base_result, "action": "closed", "comment": comment}
+
+ if _has_marker(comments, WITHIN_GRACE_MARKER):
+ return {**base_result, "action": "within-grace-already-notified"}
+ comment = format_within_grace_comment(missing, explanation, grace_days)
+ if not close:
+ return {
+ **base_result,
+ "action": "would-notify-within-grace",
+ "comment": comment,
+ }
+ post_comment(repo, number, comment)
+ return {**base_result, "action": "within-grace-notified", "comment": comment}
+
+
+def triage(
+ *,
+ repo: str,
+ kind: str,
+ number: int,
+ close: bool,
+ model: str,
+ judge: Any = None,
+ print_prompt: bool = False,
+ reconsider: bool = False,
+ allowlist: frozenset[str] = ALLOWLIST_LOGINS,
+) -> dict:
+ """Triage a single PR or issue. Returns a result dict for logging/tests.
+
+ `judge` is an optional callable `(prompt) -> str` for tests / dry-run with
+ a stub. In production, leave it None and the script uses `call_llm_judge`.
+
+ When `reconsider=True`, the closed-state guard is skipped and a
+ fail-but-no-comment is replaced with a "still failing" comment + leave
+ closed; a pass triggers `reopen_pr`/`reopen_issue` plus a reopen comment.
+ Reconsider mode is intended for the `@agent-shin reconsider` comment
+ trigger. Like regular triage, `close=False` keeps reconsider in dry-run
+ (returns `would-reopen` / `would-reconsider-still-failing` so a local
+ operator can preview without write side effects); the workflow only
+ passes `--close` when `AGENT_SHIN_ENABLED=true`.
+
+ Reconsider mode adds two extra safety guards on top of the regular
+ triage skip-internal-author check:
+
+ 1. **Bot-closed guard.** Only reopens if the most recent close was
+ performed by the bot identity (default `github-actions[bot]`).
+ This stops a contributor from using `@agent-shin reconsider` to
+ override a maintainer's close for non-rubric reasons.
+ 2. **Rate-limit guard.** If the bot has already posted a reconsider
+ verdict on this PR/issue within `RECONSIDER_RATE_LIMIT_SECONDS`,
+ skip — repeated triggers from the same contributor shouldn't burn
+ CI minutes or LLM budget.
+ """
+ fetcher = {"pr": fetch_pr, "issue": fetch_issue}[kind]
+ item = fetcher(repo, number)
+
+ title = item.get("title") or ""
+ body = item.get("body") or ""
+ login = (item.get("user") or {}).get("login") or ""
+ association = item.get("author_association") or ""
+ state = item.get("state") or ""
+
+ base_result = {
+ "kind": kind,
+ "number": number,
+ "title": title,
+ "author": login,
+ "author_association": association,
+ "state": state,
+ "reconsider": reconsider,
+ }
+
+ # Reconsider only makes sense on a closed PR/issue. A "reconsider on an
+ # open PR" is a no-op (the regular triage flow already evaluates open
+ # PRs); return a clear skip so the workflow can short-circuit.
+ if reconsider:
+ if state != "closed":
+ return {**base_result, "action": "skip-not-closed"}
+ else:
+ if state != "open":
+ return {**base_result, "action": "skip-not-open"}
+
+ if allowlist:
+ if login.lower() not in allowlist:
+ return {**base_result, "action": "skip-not-allowlisted"}
+ elif is_internal_contributor(item):
+ return {**base_result, "action": "skip-internal-author"}
+
+ # Reconsider-only guards — these run BEFORE the LLM call so a
+ # maintainer-closed PR / rate-limited trigger never spends LLM budget.
+ if reconsider:
+ if not was_closed_by_agent_shin(repo, number):
+ return {**base_result, "action": "skip-not-bot-closed"}
+ age = seconds_since_last_reconsider_verdict(repo, number)
+ if age is not None and age < RECONSIDER_RATE_LIMIT_SECONDS:
+ return {
+ **base_result,
+ "action": "skip-rate-limited",
+ "rate_limit_age_seconds": age,
+ "rate_limit_window_seconds": RECONSIDER_RATE_LIMIT_SECONDS,
+ }
+
+ if kind == "pr":
+ # Short-circuit: if body very clearly links a related issue, just pass.
+ if has_linked_issue(body):
+ base = {
+ **base_result,
+ "action": "pass-linked-issue",
+ "verdict": {
+ "verdict": "pass",
+ "linked_issue": True,
+ "explanation": "Linked-issue regex matched; LLM was not called.",
+ },
+ }
+ if reconsider:
+ # Pass-on-reconsider -> reopen the PR with a friendly comment.
+ reopen_body = format_reopen_comment(kind)
+ if not close:
+ return {
+ **base,
+ "action": "would-reopen",
+ "comment": reopen_body,
+ }
+ post_comment(repo, number, reopen_body)
+ reopen_pr(repo, number)
+ return {
+ **base,
+ "action": "reopened",
+ "comment": reopen_body,
+ }
+ return base
+ prompt = build_pr_prompt(title=title, body=body)
+ else:
+ prompt = build_issue_prompt(title=title, body=body)
+
+ if print_prompt:
+ return {**base_result, "action": "print-prompt", "prompt": prompt}
+
+ if judge is None:
+ api_key = os.environ.get("OPENAI_API_KEY")
+ if not api_key:
+ # No key configured — never take a destructive action. Report skip.
+ return {
+ **base_result,
+ "action": "skip-no-llm-key",
+ "prompt_preview": prompt[:200],
+ }
+ base_url = os.environ.get("OPENAI_BASE_URL") or None
+
+ def judge(p: str) -> str:
+ return call_llm_judge(p, model=model, api_key=api_key, base_url=base_url)
+
+ try:
+ raw = judge(prompt)
+ verdict = parse_verdict(raw)
+ except Exception as exc: # noqa: BLE001 - judge errors must never close PRs
+ return {**base_result, "action": "skip-llm-error", "error": str(exc)}
+
+ decision = (verdict.get("verdict") or "").lower()
+
+ if reconsider:
+ # Reconsider: an explicit `pass` -> reopen + post reopen comment;
+ # anything else (fail, missing/malformed verdict, typo) -> leave
+ # closed + post a "still failing" comment so the contributor can
+ # iterate again. Reopen is destructive, so a flaky/empty verdict
+ # must not satisfy the gate.
+ # In dry-run (`close=False`) we return `would-*` actions instead
+ # of touching GitHub state, mirroring the regular triage flow's
+ # `would-close`. This lets a local operator preview the outcome
+ # of `python triage_with_llm.py --reconsider --pr N` without
+ # risking accidental comments or reopens.
+ if decision == "pass":
+ reopen_body = format_reopen_comment(kind)
+ if not close:
+ return {
+ **base_result,
+ "action": "would-reopen",
+ "verdict": verdict,
+ "comment": reopen_body,
+ }
+ post_comment(repo, number, reopen_body)
+ if kind == "pr":
+ reopen_pr(repo, number)
+ else:
+ reopen_issue(repo, number)
+ return {
+ **base_result,
+ "action": "reopened",
+ "verdict": verdict,
+ "comment": reopen_body,
+ }
+ still_failing = format_reconsider_still_failing_comment(kind, verdict)
+ if not close:
+ return {
+ **base_result,
+ "action": "would-reconsider-still-failing",
+ "verdict": verdict,
+ "comment": still_failing,
+ }
+ post_comment(repo, number, still_failing)
+ return {
+ **base_result,
+ "action": "reconsider-still-failing",
+ "verdict": verdict,
+ "comment": still_failing,
+ }
+
+ if decision != "fail":
+ return {**base_result, "action": "pass-llm", "verdict": verdict}
+
+ # Grace-period flow: on the first low-quality detection, post a warning
+ # comment instead of closing immediately. On a subsequent triage run
+ # (manual re-trigger, or the daily `close_low_quality_prs.py` cron
+ # finding the same PR in its own pass), if `GRACE_PERIOD_SECONDS` has
+ # elapsed since the warning AND the PR still fails the rubric, close.
+ grace_age = seconds_since_last_grace_warning(repo, number)
+ if grace_age is None:
+ warning_body = (
+ format_grace_warning_pr_comment(verdict)
+ if kind == "pr"
+ else format_grace_warning_issue_comment(verdict)
+ )
+ if not close:
+ return {
+ **base_result,
+ "action": "would-warn-grace",
+ "verdict": verdict,
+ "comment": warning_body,
+ }
+ post_comment(repo, number, warning_body)
+ return {
+ **base_result,
+ "action": "warned-grace",
+ "verdict": verdict,
+ "comment": warning_body,
+ }
+ if grace_age < GRACE_PERIOD_SECONDS:
+ return {
+ **base_result,
+ "action": "skip-in-grace-period",
+ "verdict": verdict,
+ "grace_age_seconds": grace_age,
+ "grace_period_seconds": GRACE_PERIOD_SECONDS,
+ }
+
+ # The grace window has elapsed. `--close` still gates the destructive
+ # write so a dry-run preview never posts or closes — the workflow only
+ # passes `--close` when `AGENT_SHIN_ENABLED=true`, which keeps the bot
+ # inert by default.
+ if not close:
+ return {**base_result, "action": "would-close", "verdict": verdict}
+
+ comment_body = (
+ format_pr_close_comment(verdict)
+ if kind == "pr"
+ else format_issue_close_comment(verdict)
+ )
+ post_comment(repo, number, comment_body)
+ if kind == "pr":
+ close_pr(repo, number)
+ else:
+ close_issue(repo, number)
+
+ return {
+ **base_result,
+ "action": "closed",
+ "verdict": verdict,
+ "comment": comment_body,
+ }
+
+
+# ---------------------------------------------------------------------------
+# CLI
+
+
+def render_summary(result: dict) -> str:
+ """Render a human-readable summary block (used for stdout + step summary)."""
+ lines = ["## Agent Shin verdict", ""]
+ lines.append(
+ f"- **{result['kind'].upper()} #{result['number']}**: {result.get('title', '')}"
+ )
+ lines.append(
+ f"- **Author**: `{result.get('author', '')}` ({result.get('author_association', '')})"
+ )
+ lines.append(f"- **State**: {result.get('state', '')}")
+ lines.append(f"- **Action**: `{result['action']}`")
+ verdict = result.get("verdict")
+ if verdict:
+ lines.append("")
+ lines.append("```json")
+ lines.append(json.dumps(verdict, indent=2))
+ lines.append("```")
+ error = result.get("error")
+ if error:
+ lines.append("")
+ lines.append(f"_LLM error: {error}_")
+ comment = result.get("comment")
+ if comment:
+ lines.append("")
+ lines.append("### Posted comment:")
+ lines.append("")
+ lines.append("> " + comment.replace("\n", "\n> "))
+ return "\n".join(lines)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--repo", required=True, help="Repository (owner/repo).")
+ target = parser.add_mutually_exclusive_group(required=True)
+ target.add_argument("--pr", type=int, help="Pull request number to triage.")
+ target.add_argument("--issue", type=int, help="Issue number to triage.")
+ parser.add_argument(
+ "--close",
+ action="store_true",
+ help="Actually post comment + close on fail (default: dry run).",
+ )
+ parser.add_argument(
+ "--model",
+ # `os.environ.get("TRIAGE_MODEL", DEFAULT_MODEL)` would return "" when
+ # GitHub Actions exposes an unset repo variable as an empty-string env
+ # var, silently bypassing DEFAULT_MODEL and causing every call to fail
+ # as `skip-llm-error`. The `or` guard collapses empty -> default.
+ default=os.environ.get("TRIAGE_MODEL") or DEFAULT_MODEL,
+ help=f"OpenAI-compatible model name (default: {DEFAULT_MODEL}).",
+ )
+ parser.add_argument(
+ "--print-prompt",
+ action="store_true",
+ help="Print the prompt that would be sent to the judge and exit.",
+ )
+ parser.add_argument(
+ "--reconsider",
+ action="store_true",
+ help=(
+ "Re-run triage on a CLOSED PR/issue and reopen it on pass. "
+ "Used by the `@agent-shin reconsider` comment-trigger workflow. "
+ "Only invoke this from a workflow that has already gated on "
+ "AGENT_SHIN_ENABLED=true and verified the commenter is the "
+ "PR/issue author or an internal collaborator."
+ ),
+ )
+ parser.add_argument(
+ "--review-gate",
+ action="store_true",
+ help=(
+ "Reconcile the `ready for review` label for an OPEN PR: tag on "
+ "pass, remove the tag + comment on regression, close after the "
+ "grace window if it never passed. PR-only."
+ ),
+ )
+ parser.add_argument(
+ "--grace-days",
+ type=int,
+ default=DEFAULT_GRACE_DAYS,
+ help=(
+ "Review-gate only: hours/24 a failing, un-tagged PR may stay open "
+ f"before auto-close (default: {DEFAULT_GRACE_DAYS} = 24h)."
+ ),
+ )
+ parser.add_argument(
+ "--min-greptile-score",
+ type=int,
+ default=DEFAULT_MIN_GREPTILE_SCORE,
+ choices=range(1, 6),
+ help=(
+ "Review-gate only: Greptile score below which a PR counts as not "
+ f"passing (default: {DEFAULT_MIN_GREPTILE_SCORE} -> <4/5 regresses)."
+ ),
+ )
+ args = parser.parse_args()
+
+ kind = "pr" if args.pr is not None else "issue"
+ number = args.pr if args.pr is not None else args.issue
+
+ if args.review_gate:
+ if kind != "pr":
+ parser.error("--review-gate applies to pull requests only (use --pr).")
+ result = review_gate(
+ repo=args.repo,
+ number=number,
+ close=args.close,
+ model=args.model,
+ grace_days=args.grace_days,
+ min_greptile_score=args.min_greptile_score,
+ )
+ else:
+ result = triage(
+ repo=args.repo,
+ kind=kind,
+ number=number,
+ close=args.close,
+ model=args.model,
+ print_prompt=args.print_prompt,
+ reconsider=args.reconsider,
+ )
+
+ if result.get("action") == "print-prompt":
+ print(result["prompt"])
+ return 0
+
+ summary = render_summary(result)
+ print(summary)
+ write_step_summary(summary + "\n")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.github/workflows/check-ui-api-types.yml b/.github/workflows/check-ui-api-types.yml
index eeb5545b15e..d8053c15683 100644
--- a/.github/workflows/check-ui-api-types.yml
+++ b/.github/workflows/check-ui-api-types.yml
@@ -54,7 +54,7 @@ jobs:
run: uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma
- name: Set up Node.js
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0
+ uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: "20"
cache: "npm"
diff --git a/.github/workflows/close_low_quality_prs.yml b/.github/workflows/close_low_quality_prs.yml
new file mode 100644
index 00000000000..2401be84000
--- /dev/null
+++ b/.github/workflows/close_low_quality_prs.yml
@@ -0,0 +1,92 @@
+name: Close Low-Quality PRs
+
+# Auto-close any open PR (including drafts, regardless of age) authored by an
+# external OSS contributor that Greptile reviewed with a confidence score
+# below 4/5. Closures are explained in a comment that tells the contributor
+# to push fixes and open a fresh PR (since OSS authors cannot reopen a PR
+# closed by a bot/maintainer) or comment `@agent-shin reconsider` to have
+# Agent Shin re-evaluate.
+#
+# Manual one-off run:
+# gh workflow run "Close Low-Quality PRs" -f close=true
+#
+# Dry-run preview (no PRs are touched):
+# gh workflow run "Close Low-Quality PRs" -f close=false
+
+on:
+ schedule:
+ # Daily at 09:00 UTC. Pairs well with the stale-issue workflow at midnight.
+ - cron: "0 9 * * *"
+ workflow_dispatch:
+ inputs:
+ close:
+ description: "Actually close matching PRs (false = dry run)."
+ required: false
+ default: "false"
+ type: choice
+ options:
+ - "true"
+ - "false"
+ min_age_days:
+ description: "Minimum PR age in days (default 0 = no age filter)."
+ required: false
+ default: "0"
+ min_score:
+ description: "Greptile score below which a PR is closed (1-5)."
+ required: false
+ default: "4"
+ limit:
+ description: "Maximum number of PRs to close in a single run."
+ required: false
+ default: "25"
+
+permissions:
+ contents: read
+ pull-requests: write
+ issues: write
+
+jobs:
+ close-low-quality-prs:
+ if: github.repository == 'BerriAI/litellm'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout triage script
+ uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ with:
+ sparse-checkout: .github/scripts
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ with:
+ python-version: "3.12"
+
+ - name: Run low-quality PR closer
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ # Scheduled runs are ALWAYS dry-run, even when AGENT_SHIN_ENABLED is
+ # "true", so the team can QA the closer's verdicts in step summaries
+ # before any contributor sees a PR closed. Real closures only happen
+ # on manual workflow_dispatch with close=true (and the variable set).
+ CLOSE_FLAG: ${{ github.event.inputs.close || 'false' }}
+ AGENT_SHIN_ENABLED: ${{ vars.AGENT_SHIN_ENABLED }}
+ MIN_AGE_DAYS: ${{ github.event.inputs.min_age_days || '0' }}
+ MIN_SCORE: ${{ github.event.inputs.min_score || '4' }}
+ LIMIT: ${{ github.event.inputs.limit || '25' }}
+ run: |
+ set -euo pipefail
+ ARGS=(
+ --repo "${{ github.repository }}"
+ --min-age-days "${MIN_AGE_DAYS}"
+ --min-score "${MIN_SCORE}"
+ --limit "${LIMIT}"
+ )
+ if [ "${AGENT_SHIN_ENABLED:-false}" != "true" ]; then
+ echo "::notice::AGENT_SHIN_ENABLED is not 'true' -> forcing dry-run regardless of close input."
+ elif [ "${GITHUB_EVENT_NAME:-}" = "workflow_dispatch" ] && [ "${CLOSE_FLAG}" = "true" ]; then
+ ARGS+=(--close)
+ echo "::notice::Running in close-on-fail mode."
+ else
+ echo "::notice::AGENT_SHIN_ENABLED is true but this trigger is dry-run (scheduled event or close=false)."
+ fi
+ python3 .github/scripts/close_low_quality_prs.py "${ARGS[@]}"
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index babe3b62933..d3a165a11da 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -43,14 +43,14 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
- uses: github/codeql-action/init@ebcb5b36ded6beda4ceefea6a8bc4cc885255bb3 # v3
+ uses: github/codeql-action/init@ebcb5b36ded6beda4ceefea6a8bc4cc885255bb3 # v3.34.1
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
config-file: ./.github/codeql/codeql-config.yml
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@ebcb5b36ded6beda4ceefea6a8bc4cc885255bb3 # v3
+ uses: github/codeql-action/analyze@ebcb5b36ded6beda4ceefea6a8bc4cc885255bb3 # v3.34.1
with:
category: "/language:${{ matrix.language }}"
output: sarif-results
@@ -77,7 +77,7 @@ jobs:
output: sarif-results/python.sarif
- name: Upload SARIF
- uses: github/codeql-action/upload-sarif@ebcb5b36ded6beda4ceefea6a8bc4cc885255bb3 # v3
+ uses: github/codeql-action/upload-sarif@ebcb5b36ded6beda4ceefea6a8bc4cc885255bb3 # v3.34.1
with:
sarif_file: sarif-results
category: "/language:${{ matrix.language }}"
diff --git a/.github/workflows/conventional-commits.yml b/.github/workflows/conventional-commits.yml
new file mode 100644
index 00000000000..69ade24d028
--- /dev/null
+++ b/.github/workflows/conventional-commits.yml
@@ -0,0 +1,46 @@
+name: Conventional PR Title
+
+# Squash-merge replaces the merge commit subject with the PR title, so
+# enforcing Conventional Commits at the PR-title level is what actually gates
+# the commits that land on the default branch. The local commit-msg hook
+# (.githooks/commit-msg) is a best-effort assist; this workflow is the gate.
+#
+# See https://www.conventionalcommits.org/en/v1.0.0/
+
+on:
+ pull_request:
+ types: [opened, edited, reopened, synchronize, labeled, unlabeled]
+
+permissions:
+ pull-requests: read
+
+jobs:
+ lint-pr-title:
+ name: Validate PR title
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check title against Conventional Commits
+ uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ # Must mirror the type list in .githooks/commit-msg.
+ types: |
+ feat
+ fix
+ docs
+ style
+ refactor
+ perf
+ test
+ build
+ ci
+ chore
+ revert
+ requireScope: false
+ subjectPattern: ^(?![A-Z]).+$
+ subjectPatternError: |
+ The subject "{subject}" must start with a lowercase character.
+ # Allow merges/reverts that GitHub generates automatically.
+ ignoreLabels: |
+ ignore-semantic-pull-request
diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml
index a726a921a2b..4834775e329 100644
--- a/.github/workflows/create-release.yml
+++ b/.github/workflows/create-release.yml
@@ -52,6 +52,22 @@ jobs:
// are stable maintenance releases, not pre-releases.
const isPrerelease = /(?:rc|nightly|alpha|beta|[-.]dev)/i.test(tag);
+ // A stable release should only claim the repo "latest" badge when its
+ // version is >= the current latest. Otherwise a backport (e.g. 1.84.6)
+ // would steal "latest" from a newer line (e.g. 1.88.1).
+ const versionKey = (rawTag) => {
+ const m = String(rawTag).match(/^v?(\d+)\.(\d+)\.(\d+)/);
+ if (!m) return null;
+ const maintenance = String(rawTag).match(/(?:\.post|\.patch\.)(\d+)/i);
+ return [Number(m[1]), Number(m[2]), Number(m[3]), maintenance ? Number(maintenance[1]) : 0];
+ };
+ const isAtLeast = (a, b) => {
+ for (let i = 0; i < a.length; i++) {
+ if (a[i] !== b[i]) return a[i] > b[i];
+ }
+ return true;
+ };
+
const cosignSection = [
`## Verify Docker Image Signature`,
``,
@@ -90,6 +106,22 @@ jobs:
].join('\n');
try {
+ let makeLatest = "false";
+ const newVersion = versionKey(tag);
+ if (!isPrerelease && newVersion) {
+ let latestVersion = null;
+ try {
+ const latest = await github.rest.repos.getLatestRelease({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ });
+ latestVersion = versionKey(latest.data.tag_name);
+ } catch (error) {
+ if (error.status !== 404) throw error;
+ }
+ makeLatest = (!latestVersion || isAtLeast(newVersion, latestVersion)) ? "true" : "false";
+ }
+
const response = await github.rest.repos.createRelease({
draft: true,
generate_release_notes: true,
@@ -108,6 +140,7 @@ jobs:
release_id: response.data.id,
body: updatedBody,
draft: false,
+ make_latest: makeLatest,
});
} catch (error) {
diff --git a/.github/workflows/osv-scan.yml b/.github/workflows/osv-scan.yml
new file mode 100644
index 00000000000..0cd94fdd9e2
--- /dev/null
+++ b/.github/workflows/osv-scan.yml
@@ -0,0 +1,44 @@
+name: OSV Scan
+
+on:
+ pull_request:
+ branches:
+ - main
+ - litellm_internal_staging
+ - litellm_oss_branch
+ - "litellm_**"
+ schedule:
+ - cron: "23 6 * * *"
+ workflow_dispatch:
+
+permissions: {}
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ osv-scan:
+ name: osv-scan
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ with:
+ persist-credentials: false
+
+ - name: Download osv-scanner v2.3.8
+ run: |
+ curl -fsSL --retry 3 -o "$RUNNER_TEMP/osv-scanner" \
+ https://github.com/google/osv-scanner/releases/download/v2.3.8/osv-scanner_linux_amd64
+ echo "bc98e15319ed0d515e3f9235287ba53cdc5535d576d24fd573978ecfe9ab92dc $RUNNER_TEMP/osv-scanner" | sha256sum -c -
+ chmod +x "$RUNNER_TEMP/osv-scanner"
+
+ - name: Scan lockfiles
+ run: |
+ "$RUNNER_TEMP/osv-scanner" scan source \
+ --config osv-scanner.toml \
+ -L uv.lock \
+ -L ui/litellm-dashboard/package-lock.json
diff --git a/.github/workflows/test-linting.yml b/.github/workflows/test-linting.yml
index b5e45a38cf9..950d6ca31a6 100644
--- a/.github/workflows/test-linting.yml
+++ b/.github/workflows/test-linting.yml
@@ -14,11 +14,15 @@ permissions:
jobs:
lint:
runs-on: ubuntu-latest
- timeout-minutes: 5
+ timeout-minutes: 15
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ # Check out the PR head, not the default refs/pull/N/merge: the merge ref
+ # folds in newer base commits, which the diff-based gates (ruff delta,
+ # Any-discipline) would otherwise blame on this branch.
with:
+ ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
clean: true
persist-credentials: false
@@ -67,15 +71,27 @@ jobs:
uv run --no-sync ruff check .
cd ..
+ - name: Check strict-rule budget (delta vs base)
+ env:
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ run: |
+ uv run --no-sync python scripts/ruff_strict_gate.py --base "$BASE_SHA"
+
+ - name: Check type-discipline budget (mutable collections / casts / type guards / kwargs / unexplained suppressions, delta vs base)
+ env:
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ run: |
+ uv run --no-sync python scripts/type_discipline_gate.py --base "$BASE_SHA"
+
- name: Print OpenAI version
run: |
uv run --no-sync python -c "import openai; print(f'OpenAI version: {openai.__version__}')"
- - name: Run MyPy type checking
+ - name: Check basedpyright budget (delta vs base)
+ env:
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
- cd litellm
- uv run --no-sync mypy .
- cd ..
+ (uv run --no-sync basedpyright --outputjson || true) | uv run --no-sync python scripts/type_check_gate.py --base "$BASE_SHA"
- name: Check for circular imports
run: |
@@ -87,6 +103,33 @@ jobs:
run: |
uv run --no-sync python -c "from litellm import *" || (echo '🚨 import failed, this means you introduced unprotected imports! 🚨'; exit 1)
+ # Intentionally NON-GATING. This job turns red when a *-budget.json ceiling is
+ # raised (or a rule/budget is dropped) so a loosening is obvious in review, but it
+ # must be kept OUT of the branch-protection required-checks list so a justified
+ # bump can still be merged by a human who has seen and accepted the red.
+ budget-ratchet:
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ contents: read
+
+ steps:
+ - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ with:
+ python-version: "3.12"
+
+ - name: Ratchet check (budgets may only decrease; non-gating)
+ env:
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ run: |
+ python scripts/budget_ratchet_check.py --base "$BASE_SHA"
+
secret-scan:
runs-on: ubuntu-latest
timeout-minutes: 5
diff --git a/.github/workflows/test-litellm-ui-build.yml b/.github/workflows/test-litellm-ui-build.yml
index 68497b10dbb..0fd8093949d 100644
--- a/.github/workflows/test-litellm-ui-build.yml
+++ b/.github/workflows/test-litellm-ui-build.yml
@@ -25,7 +25,7 @@ jobs:
persist-credentials: false
- name: Setup Node.js
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0
+ uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: "20"
cache: "npm"
@@ -77,7 +77,7 @@ jobs:
- name: Setup Node.js
if: steps.changed.outputs.has_files == 'true'
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0
+ uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: "20"
cache: "npm"
@@ -111,4 +111,4 @@ jobs:
if: ${{ !cancelled() && steps.changed.outputs.has_files == 'true' }}
run: |
npx eslint . -f json -o "$RUNNER_TEMP/lint-report.json" || true
- node scripts/check-lint-budgets.mjs "$RUNNER_TEMP/lint-report.json" eslint-budgets.json
+ node scripts/check-lint-budgets.mjs "$RUNNER_TEMP/lint-report.json" eslint-budgets.json --check eslint-metrics.json
diff --git a/.github/workflows/test-rust.yml b/.github/workflows/test-rust.yml
new file mode 100644
index 00000000000..3d0a159cdc7
--- /dev/null
+++ b/.github/workflows/test-rust.yml
@@ -0,0 +1,65 @@
+name: LiteLLM Rust
+
+on:
+ push:
+ paths:
+ - "litellm-rust/**"
+ - ".github/workflows/test-rust.yml"
+ pull_request:
+ branches:
+ - main
+ - litellm_internal_staging
+ - litellm_oss_branch
+ - "litellm_**"
+ paths:
+ - "litellm-rust/**"
+ - ".github/workflows/test-rust.yml"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ rust-checks:
+ name: rustfmt, clippy, test
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ defaults:
+ run:
+ working-directory: litellm-rust
+ env:
+ CARGO_TERM_COLOR: always
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ with:
+ persist-credentials: false
+
+ - name: Set up Rust
+ run: |
+ rustup toolchain install stable --profile minimal --component clippy,rustfmt
+ rustup default stable
+
+ - name: Cache Cargo registry and target
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ litellm-rust/target
+ key: ${{ runner.os }}-cargo-${{ hashFiles('litellm-rust/Cargo.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-cargo-
+
+ - name: Check Rust formatting
+ run: cargo fmt --check
+
+ - name: Run Clippy
+ run: cargo clippy --workspace --all-targets --locked -- -D warnings
+
+ - name: Run Rust tests
+ run: cargo test --workspace --locked
diff --git a/.github/workflows/test-unit-misc.yml b/.github/workflows/test-unit-misc.yml
index a7363ac3b43..2226d519331 100644
--- a/.github/workflows/test-unit-misc.yml
+++ b/.github/workflows/test-unit-misc.yml
@@ -32,7 +32,9 @@ jobs:
tests/test_litellm/repositories
tests/test_litellm/images
tests/test_litellm/interactions
+ tests/test_litellm/ocr
tests/test_litellm/passthrough
+ tests/test_litellm/sandbox
tests/test_litellm/vector_stores
tests/test_litellm/test_*.py
workers: 2
diff --git a/.github/workflows/test-unit-proxy-endpoints.yml b/.github/workflows/test-unit-proxy-endpoints.yml
index 0a9513ec024..d9b6a348b60 100644
--- a/.github/workflows/test-unit-proxy-endpoints.yml
+++ b/.github/workflows/test-unit-proxy-endpoints.yml
@@ -11,8 +11,6 @@ on:
permissions:
contents: read
- id-token: write
- pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -20,6 +18,10 @@ concurrency:
jobs:
proxy-endpoints:
+ permissions:
+ contents: read
+ id-token: write
+ pull-requests: write
uses: ./.github/workflows/_test-unit-base.yml
with:
test-path: >-
@@ -52,6 +54,10 @@ jobs:
# is independent and its coverage artifact is uploaded separately.
# See: https://www.notion.so/36c43b8acdab81ee845fd5365128a2fc
proxy-server:
+ permissions:
+ contents: read
+ id-token: write
+ pull-requests: write
uses: ./.github/workflows/_test-unit-base.yml
with:
test-path: tests/test_litellm/proxy/proxy_server
diff --git a/.github/workflows/test_server_root_path.yml b/.github/workflows/test_server_root_path.yml
index 57ff746c9c8..985653796c2 100644
--- a/.github/workflows/test_server_root_path.yml
+++ b/.github/workflows/test_server_root_path.yml
@@ -32,17 +32,16 @@ jobs:
df -h /
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12
+ uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Build Docker image
- uses: docker/build-push-action@0adf9959216b96bec444f325f1e493d4aa344497 #v6.14
+ uses: docker/build-push-action@0adf9959216b96bec444f325f1e493d4aa344497 # v6.14.0
with:
context: .
file: ./docker/Dockerfile.non_root
tags: litellm-test:${{ github.sha }}
load: true
- cache-from: type=gha
- cache-to: type=gha,mode=max
+ push: false
- name: Start LiteLLM container with SERVER_ROOT_PATH
run: |
diff --git a/.github/workflows/triage_issue_with_llm.yml b/.github/workflows/triage_issue_with_llm.yml
new file mode 100644
index 00000000000..765453cf2c6
--- /dev/null
+++ b/.github/workflows/triage_issue_with_llm.yml
@@ -0,0 +1,96 @@
+name: Agent Shin — Issue triage
+
+# LLM-as-judge triage for external GitHub issues.
+#
+# DRY-RUN BY DEFAULT. See .github/workflows/triage_pr_with_llm.yml for the
+# enablement procedure — same repo variable (`AGENT_SHIN_ENABLED=true`)
+# unlocks the PR and issue triage flows together.
+
+on:
+ issues:
+ types: [opened, reopened]
+ workflow_dispatch:
+ inputs:
+ issue_number:
+ description: "Issue number to triage manually."
+ required: true
+ close:
+ description: "If true and AGENT_SHIN_ENABLED=true, actually close on fail."
+ required: false
+ default: "false"
+ type: choice
+ options:
+ - "true"
+ - "false"
+
+permissions:
+ contents: read
+ issues: write
+
+jobs:
+ triage:
+ if: github.repository == 'BerriAI/litellm'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout triage script
+ uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ with:
+ sparse-checkout: .github/scripts
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ with:
+ python-version: "3.12"
+
+ - name: Install LLM client
+ run: pip install --no-cache-dir --require-hashes -r .github/scripts/triage-requirements.txt
+
+ - name: Run Agent Shin
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ # Only expose the LLM key when the bot is enabled or a collaborator
+ # triggers it manually, so an external user can't force paid LLM
+ # calls by churning issues while the bot is still in dry-run.
+ # The Python script calls the LLM whenever this var is set
+ # (regardless of `--close`); stripping `--close` doesn't suppress
+ # the API call, only the destructive side effects.
+ OPENAI_API_KEY: ${{ (vars.AGENT_SHIN_ENABLED == 'true' || github.event_name == 'workflow_dispatch') && secrets.OPENAI_API_KEY || '' }}
+ OPENAI_BASE_URL: ${{ vars.OPENAI_BASE_URL }}
+ TRIAGE_MODEL: ${{ vars.TRIAGE_MODEL }}
+ AGENT_SHIN_ENABLED: ${{ vars.AGENT_SHIN_ENABLED }}
+ DISPATCH_CLOSE: ${{ github.event.inputs.close }}
+ ISSUE_NUMBER: ${{ github.event.issue.number || github.event.inputs.issue_number }}
+ run: |
+ set -euo pipefail
+ ARGS=(--repo "${{ github.repository }}" --issue "${ISSUE_NUMBER}")
+ # Fail-safe gating: only the EXACT string "true" enables the
+ # destructive --close path. The workflow_dispatch input is a
+ # `choice` dropdown of "true"/"false" so the UI is constrained,
+ # but the API (`gh workflow run -f close=...`) accepts any
+ # string, and a `!= "false"` check would treat "True", "yes",
+ # "1", "TRUE", typos, and accidental whitespace as enabling
+ # closure. Mirror the Greptile closer's `= "true"` pattern.
+ if [ "${AGENT_SHIN_ENABLED:-false}" = "true" ] && [ "${DISPATCH_CLOSE:-false}" = "true" ]; then
+ ARGS+=(--close)
+ echo "::notice::Agent Shin is ENABLED and running in close-on-fail mode."
+ elif [ "${AGENT_SHIN_ENABLED:-false}" = "true" ]; then
+ echo "::notice::Agent Shin is ENABLED but this trigger is dry-run (workflow_dispatch close != 'true')."
+ else
+ echo "::notice::Agent Shin is in DRY-RUN mode (AGENT_SHIN_ENABLED is not 'true'). No comments will be posted; no issues will be closed."
+ fi
+ # Automatic `issues` events stay dry-run regardless until the team
+ # explicitly invokes workflow_dispatch with close=true.
+ if [ "${GITHUB_EVENT_NAME:-}" = "issues" ]; then
+ # filter out --close rather than substituting to "" (which would
+ # leave an empty positional arg that argparse rejects)
+ FILTERED=()
+ for arg in "${ARGS[@]}"; do
+ if [ "${arg}" != "--close" ]; then
+ FILTERED+=("${arg}")
+ fi
+ done
+ ARGS=("${FILTERED[@]}")
+ echo "::notice::issues trigger -> forcing dry-run."
+ fi
+ python3 .github/scripts/triage_with_llm.py "${ARGS[@]}"
diff --git a/.github/workflows/triage_reconsider.yml b/.github/workflows/triage_reconsider.yml
new file mode 100644
index 00000000000..f35f681d09a
--- /dev/null
+++ b/.github/workflows/triage_reconsider.yml
@@ -0,0 +1,172 @@
+name: Agent Shin — reconsider
+
+# Comment-trigger workflow: when the PR/issue author (or an internal
+# collaborator) comments `@agent-shin reconsider` on a CLOSED PR/issue,
+# Agent Shin re-runs LLM-judge triage on the current title+body and:
+#
+# - on PASS: posts a "re-evaluated and reopened" comment + reopens.
+# - on FAIL: posts a "still missing X" comment and leaves it closed,
+# so the contributor can iterate again.
+#
+# This exists because GitHub does NOT let an external (non-write-access)
+# OSS contributor reopen a PR/issue closed by a bot or maintainer. Without
+# this comment trigger, a contributor whose PR Agent Shin auto-closed
+# would have no path back into the review queue except opening a fresh PR
+# (which loses the original PR's history). The bot, on the other hand,
+# has write access via GH_TOKEN and can reopen on their behalf.
+#
+# DRY-RUN BY DEFAULT — gated on `vars.AGENT_SHIN_ENABLED == 'true'` just
+# like the other Agent Shin workflows. The workflow also gates on the
+# commenter being either the PR/issue author or an internal collaborator
+# (OWNER/MEMBER/COLLABORATOR) so random commenters cannot DOS the LLM
+# judge or force a reopen.
+
+on:
+ issue_comment:
+ types: [created]
+
+permissions:
+ contents: read
+ issues: write
+ pull-requests: write
+
+jobs:
+ reconsider:
+ if: |
+ github.repository == 'BerriAI/litellm'
+ && contains(github.event.comment.body, '@agent-shin reconsider')
+ runs-on: ubuntu-latest
+ steps:
+ - name: Authorize commenter
+ # Only the PR/issue author OR an internal collaborator may trigger
+ # a reconsider. Outside random commenters could otherwise spam the
+ # phrase to burn LLM budget or, if a fail-open bug were ever
+ # introduced, force a reopen on someone else's behalf.
+ #
+ # We expose the authorization decision as a step output and gate
+ # every subsequent (potentially destructive) step on it. A `run:`
+ # step with `exit 0` would NOT stop the job — only `if:` gating
+ # on a known-true output is safe here.
+ id: auth
+ env:
+ COMMENTER: ${{ github.event.comment.user.login }}
+ AUTHOR: ${{ github.event.issue.user.login }}
+ ASSOCIATION: ${{ github.event.comment.author_association }}
+ run: |
+ set -euo pipefail
+ if [ "${COMMENTER}" = "${AUTHOR}" ]; then
+ echo "::notice::Authorized: commenter is the PR/issue author."
+ echo "authorized=true" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ case "${ASSOCIATION}" in
+ OWNER|MEMBER|COLLABORATOR)
+ echo "::notice::Authorized: commenter is an internal collaborator (${ASSOCIATION})."
+ echo "authorized=true" >> "$GITHUB_OUTPUT"
+ ;;
+ *)
+ echo "::notice::Commenter '${COMMENTER}' (${ASSOCIATION}) is not authorized to trigger reconsider; skipping subsequent steps."
+ echo "authorized=false" >> "$GITHUB_OUTPUT"
+ ;;
+ esac
+
+ - name: React 👀 to acknowledge the reconsider
+ # Add an eyes reaction to the triggering comment the moment we accept
+ # it, so the contributor gets instant feedback that the bot saw their
+ # `@agent-shin reconsider` before the slower triage steps run. Gated on
+ # AGENT_SHIN_ENABLED so dry-run leaves no visible trace. Best-effort:
+ # a reactions API hiccup must never fail the actual reconsider.
+ if: steps.auth.outputs.authorized == 'true' && vars.AGENT_SHIN_ENABLED == 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ COMMENT_ID: ${{ github.event.comment.id }}
+ run: |
+ set -euo pipefail
+ gh api --method POST \
+ -H "Accept: application/vnd.github+json" \
+ "repos/${{ github.repository }}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content=eyes \
+ || echo "::warning::failed to add 👀 reaction (non-fatal)"
+
+ - name: Checkout triage script
+ if: steps.auth.outputs.authorized == 'true'
+ uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ with:
+ sparse-checkout: .github/scripts
+ persist-credentials: false
+
+ - name: Set up Python
+ if: steps.auth.outputs.authorized == 'true'
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ with:
+ python-version: "3.12"
+
+ - name: Install LLM client
+ if: steps.auth.outputs.authorized == 'true'
+ run: pip install --no-cache-dir --require-hashes -r .github/scripts/triage-requirements.txt
+
+ - name: Run Agent Shin reconsider
+ if: steps.auth.outputs.authorized == 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ # Only expose the LLM key when the bot is enabled, so a PR/issue
+ # author can't force paid LLM calls by spamming `@agent-shin
+ # reconsider` while the bot is still in dry-run. The Python script
+ # calls the LLM whenever this var is set (regardless of `--close`);
+ # stripping `--close` doesn't suppress the API call, only the
+ # destructive side effects. Mirror the gating used by every other
+ # Agent Shin workflow (triage_pr_with_llm.yml, review_gate.yml, ...).
+ OPENAI_API_KEY: ${{ vars.AGENT_SHIN_ENABLED == 'true' && secrets.OPENAI_API_KEY || '' }}
+ OPENAI_BASE_URL: ${{ vars.OPENAI_BASE_URL }}
+ TRIAGE_MODEL: ${{ vars.TRIAGE_MODEL }}
+ AGENT_SHIN_ENABLED: ${{ vars.AGENT_SHIN_ENABLED }}
+ # `issue_comment` events fire for both issues and PR comments.
+ # `issue.pull_request` is set iff this is a PR comment, so we use
+ # its presence to decide whether to invoke `--pr N` or `--issue N`.
+ IS_PR: ${{ github.event.issue.pull_request != null }}
+ NUMBER: ${{ github.event.issue.number }}
+ run: |
+ set -euo pipefail
+ if [ "${IS_PR}" = "true" ]; then
+ ARGS=(--repo "${{ github.repository }}" --pr "${NUMBER}" --reconsider)
+ else
+ ARGS=(--repo "${{ github.repository }}" --issue "${NUMBER}" --reconsider)
+ fi
+ # Reconsider's destructive actions (post comment + reopen) are
+ # gated on `--close`, mirroring the regular triage workflows.
+ # When AGENT_SHIN_ENABLED is not the EXACT string "true", we
+ # still run the script so its verdict + would-X action lands in
+ # the step summary for QA — but without `--close`, the script
+ # returns `would-reopen` / `would-reconsider-still-failing`
+ # instead of touching GitHub state.
+ #
+ # Use the positive `= "true"` gate (not `!= "true" -> exit`) so
+ # the workflow guardrails in
+ # tests/test_litellm/test_github_triage_workflows.py see the
+ # canonical fail-safe enable pattern. Unknown values like
+ # "True", "yes", "1", or typos fall through to the dry-run
+ # branch, which is the safe default.
+ if [ "${AGENT_SHIN_ENABLED:-false}" = "true" ]; then
+ ARGS+=(--close)
+ echo "::notice::Agent Shin reconsider ENABLED — running real triage (close=true)."
+ else
+ echo "::notice::AGENT_SHIN_ENABLED is not 'true' -> reconsider stays in dry-run (no comment, no reopen)."
+ fi
+ python3 .github/scripts/triage_with_llm.py "${ARGS[@]}"
+
+ - name: React 👍 when the reconsider finishes
+ # Once the reconsider run has completed successfully, add a thumbs-up so
+ # the contributor sees the bot is done (the 👀 stays, signalling
+ # seen -> handled). `success()` keeps this from firing if the run
+ # errored, and the AGENT_SHIN_ENABLED gate keeps dry-run inert.
+ if: success() && steps.auth.outputs.authorized == 'true' && vars.AGENT_SHIN_ENABLED == 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ COMMENT_ID: ${{ github.event.comment.id }}
+ run: |
+ set -euo pipefail
+ gh api --method POST \
+ -H "Accept: application/vnd.github+json" \
+ "repos/${{ github.repository }}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content=+1 \
+ || echo "::warning::failed to add 👍 reaction (non-fatal)"
diff --git a/.github/workflows/triage_rollout_heads_up.yml b/.github/workflows/triage_rollout_heads_up.yml
new file mode 100644
index 00000000000..903960151e2
--- /dev/null
+++ b/.github/workflows/triage_rollout_heads_up.yml
@@ -0,0 +1,92 @@
+name: Agent Shin — rollout heads-up (one-shot)
+
+# Fires the 7-day heads-up comment on every open external PR/issue that the
+# new triage bot would auto-close. The real sweep is a deliberate one-shot:
+# trigger it at rollout via a manual `workflow_dispatch` with `dry_run=false`.
+# The script is idempotent (skips items that already carry the
+# `` marker), so a re-run is harmless.
+#
+# The automatic push trigger runs DRY-RUN only, so merging the script to
+# `litellm_internal_staging` never posts a comment; it just confirms the
+# workflow is wired up. Posting real comments requires the manual dispatch,
+# which is also the only trigger that exposes `OPENAI_API_KEY`. The heads-up
+# is intentionally NOT gated on `AGENT_SHIN_ENABLED`: it has to warn
+# contributors while that flag is still off, ahead of the flip that turns on
+# auto-closing.
+#
+# The workflow is a thin shell over `.github/scripts/triage_rollout_heads_up.py`.
+# Dry-run vs. real run differ in EXACTLY one CLI flag (`--close`), added only
+# on a manual dispatch with `dry_run=false`.
+
+on:
+ push:
+ branches:
+ - litellm_internal_staging
+ paths:
+ # The presence of this script on staging IS the rollout merge marker.
+ # Editing the file later would re-fire the workflow; that's safe because
+ # the script skips PRs/issues that already have the heads-up marker.
+ - ".github/scripts/triage_rollout_heads_up.py"
+ workflow_dispatch:
+ inputs:
+ dry_run:
+ description: "Dry run (true = preview only, false = actually post comments)."
+ required: false
+ default: "true"
+ type: choice
+ options:
+ - "true"
+ - "false"
+
+permissions:
+ contents: read
+ issues: write
+ pull-requests: write
+
+jobs:
+ heads-up:
+ if: github.repository == 'BerriAI/litellm'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout triage scripts
+ uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
+ with:
+ sparse-checkout: .github/scripts
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ with:
+ python-version: "3.12"
+
+ - name: Install LLM client
+ run: pip install --no-cache-dir --require-hashes -r .github/scripts/triage-requirements.txt
+
+ - name: Run heads-up sweep
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ # Only the manual dispatch (the real-run trigger) needs the LLM key.
+ # The automatic push trigger runs dry-run and never posts, so it gets
+ # no key. Mirrors the sibling triage workflows, which expose the key
+ # only on an enabled/dispatched run rather than unconditionally.
+ OPENAI_API_KEY: ${{ github.event_name == 'workflow_dispatch' && secrets.OPENAI_API_KEY || '' }}
+ OPENAI_BASE_URL: ${{ vars.OPENAI_BASE_URL }}
+ TRIAGE_MODEL: ${{ vars.TRIAGE_MODEL }}
+ # The real run is a deliberate manual dispatch with dry_run=false.
+ # Use the EXACT "false" comparison so any unexpected input value
+ # fail-closes to dry-run (mirrors the AGENT_SHIN_ENABLED pattern in
+ # the sibling workflows). The automatic push trigger always stays
+ # dry-run, so merging the script never posts.
+ DRY_RUN_INPUT: ${{ github.event.inputs.dry_run }}
+ run: |
+ set -euo pipefail
+ ARGS=(--repo "${{ github.repository }}")
+ if [ "${GITHUB_EVENT_NAME:-}" = "workflow_dispatch" ] && [ "${DRY_RUN_INPUT:-true}" = "false" ]; then
+ ARGS+=(--close)
+ echo "::notice::Manual rollout dispatch with dry_run=false -> heads-up comments WILL be posted."
+ elif [ "${GITHUB_EVENT_NAME:-}" = "workflow_dispatch" ]; then
+ echo "::notice::Manual dispatch in dry-run mode -> previewing only, no comments will be posted."
+ else
+ echo "::notice::Automatic push trigger -> dry-run preview only. Fire the real rollout sweep with a manual workflow_dispatch (dry_run=false)."
+ fi
+ python3 .github/scripts/triage_rollout_heads_up.py "${ARGS[@]}"
diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml
index 9a1e899fed5..db79fe43038 100644
--- a/.github/workflows/zizmor.yml
+++ b/.github/workflows/zizmor.yml
@@ -2,9 +2,9 @@ name: GitHub Actions Security Analysis
on:
push:
- branches: [main]
+ branches: [main, litellm_internal_staging]
pull_request:
- branches: [main]
+ branches: [main, litellm_internal_staging]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -18,9 +18,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
- security-events: write
contents: read
- actions: read
steps:
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
@@ -28,4 +26,9 @@ jobs:
persist-credentials: false
- name: Run zizmor
- uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
+ uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
+ with:
+ version: "1.24.1"
+ min-severity: medium
+ advanced-security: false
+ annotations: true
diff --git a/.gitignore b/.gitignore
index 572830d35f6..fda3311fe02 100644
--- a/.gitignore
+++ b/.gitignore
@@ -74,7 +74,6 @@ tests/local_testing/log.txt
.codegpt
litellm/proxy/_new_new_secret_config.yaml
litellm/proxy/custom_guardrail.py
-**/.mypy_cache/
litellm/proxy/application.log
tests/llm_translation/vertex_test_account.json
tests/llm_translation/test_vertex_key.json
diff --git a/CLAUDE.md b/CLAUDE.md
index 02a9630b486..b721064aaa7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -29,13 +29,19 @@ If you ever make public-facing PR descriptions, comments, issues, commit message
- don't use "—". Instead, reach for ";", ".", etc.
- don't use the pattern "It's not X, it's Y", "You're not X, you're Y", etc.
- don't use bulleted or numbered lists unless it would be nonsensical not to. Instead, prefer prose
-- don't add a trailing "." at the end of paragraphs (just like this file)
+- don't add a trailing "." at the end of paragraphs (just like this file). That means every paragraph, not just the last one (of the markdown file, PR description, GitHub comment, etc.). Rule of thumb: unless there's a sentence immediately after, don't add a "."
- don't use →. Instead, prefer not to use arrows, and if need be, use -> instead
Don't hesitate to use values in .env to get needed API keys and other secrets, as long as you never add them to conversation history, commit them, or include them in GitHub issues / PRs
Run tests, format your code, and lint your code before each commit
+When you fix violations gated by `ruff-strict-budget.json` or `basedpyright-code-budget.json`, run `make lint-budget-update` and commit the lowered baselines so the ceilings ratchet down instead of leaving stale headroom
+
+If you're trying to create a new function that relies on untyped stuff, instead of adding more Any's and pushing `reportAny` / `reportExplicitAny` closer to their basedpyright ceilings, just validate it in the caller with Pydantic (a model or `TypeAdapter` that returns the typed thing or raises will do) and then pass the now typed variable in
+
+If you get an LIT001 or LIT002 fail, refactor the code to follow functional programming best practices rather than introducing mutable data structures. For example, build values in one shot with comprehensions or generators wrapped in `tuple()` / `frozenset()` instead of seeding an empty `list`/`dict`/`set` and mutating it over time. Ideally `# mutable-ok` is never used; reach for it only as a genuine last resort when an immutable rewrite is truly impossible, and always pair it with a real reason
+
Ask to commit and push your work when you're done (or if you're confident that your code is good and works, just do it)
When you must use real LLM models to, for example, write e2e tests, write a QA runbook, etc., make sure to use the latest models (doesn't have to be smartest, can also be a modern small, fast one. No strong preference for smart vs fast here, just use something modern) as of the year and month of the current date. Do a web search as necessary to figure that out
@@ -52,6 +58,21 @@ Do not put names of customers or customer company names in code, PRs, and issues
CI supply-chain safety: Never pipe a remote script into a shell (`curl ... | bash`, `wget ... | sh`); download the artifact to a file, verify its SHA-256 checksum, then install. Pin every external tool to a specific version with a full URL (not `latest` or `stable`). Verify checksums for all downloaded binaries, using the provider's official `.sha256` / `.sha256sum` sidecar when available. These rules apply to every download in CI
+Follow these coding conventions for new/updated code (a three-line fix in a legacy file shouldn't trigger huge drive-by refactors):
+
+- Composition over inheritance
+- Never-nester: early returns over deep nesting
+- Don't throw; model failures as values (One function (e.g., raise_public) maps error union to existing public exception contracts via exhaustive match + assert_never)
+- No mutation; don't reassign variables, global or local. Instead of mutable lists and dicts, prefer tuples, frozen dataclasses (with slots=True), etc.
+- Use dependency injection
+- Fully typed; no `Any` or coarse types like `dict[str, Any]` or just `dict`. Every function parameter must be strongly typed
+- Use tagged unions + match
+- No monster files or god objects
+- No file sprawl: deliberate file and folder structure
+- Standard over hand-rolled: use the official SDK or a library where one exists; where none does, follow industry standards instead of inventing local conventions
+
+Follow conventional commits for commit names and PR titles
+
## Think Before Coding
**Don't assume. Don't hide confusion. Surface tradeoffs**
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8ac83341f64..1080579d0fa 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -38,18 +38,25 @@ Before contributing code to LiteLLM, you must sign our [Contributor License Agre
git clone https://github.com/YOUR_USERNAME/litellm.git
cd litellm
-# Create a new branch for your feature
-git checkout -b your-feature-branch
+# Create a new branch for your feature (see "Commit and Branch Conventions" below)
+git checkout -b feature/your-feature
# Install development dependencies
make install-dev
+# Install git hooks that enforce commit + branch conventions (one-time, opt-in)
+make install-hooks
+
# Verify your setup works
make help
```
That's it! Your local development environment is ready.
+## Commit and Branch Conventions
+
+Commits follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) and branches follow [Conventional Branches](https://conventional-branch.github.io/). Run `make install-hooks` once per clone to enable the local git hooks that enforce these — see the [contributor docs](https://docs.litellm.ai/docs/extras/contributing_code#commit-and-branch-conventions) for the full type list, examples, the protected-branch bypass list, and how to opt out.
+
### 2. Development Workflow
Here's the recommended workflow for making changes:
@@ -67,12 +74,12 @@ make lint
# Run unit tests to ensure nothing is broken
make test-unit
-# Commit your changes
+# Commit your changes (must follow Conventional Commits — see above)
git add .
-git commit -m "Your descriptive commit message"
+git commit -m "feat(scope): your descriptive commit message"
-# Push and create a PR
-git push origin your-feature-branch
+# Push and create a PR (branch must follow Conventional Branches — see above)
+git push origin feature/your-feature
```
## Adding Testing
@@ -147,7 +154,7 @@ Individual linting commands:
```bash
make format-check # Check Black formatting
make lint-ruff # Run Ruff linting
-make lint-mypy # Run MyPy type checking
+make lint-basedpyright # Run basedpyright type checking
make check-circular-imports # Check for circular imports
make check-import-safety # Check import safety
```
@@ -209,7 +216,7 @@ LiteLLM follows the [Google Python Style Guide](https://google.github.io/stylegu
Our automated quality checks include:
- **Black** for consistent code formatting
- **Ruff** for linting and code quality
-- **MyPy** for static type checking
+- **basedpyright** for static type checking
- **Circular import detection**
- **Import safety validation**
@@ -223,7 +230,7 @@ If `make lint` fails:
1. **Formatting issues**: Run `make format` to auto-fix
2. **Ruff issues**: Check the output and fix manually
-3. **MyPy issues**: Add proper type hints
+3. **basedpyright issues**: Add proper type hints
4. **Circular imports**: Refactor import dependencies
5. **Import safety**: Fix any unprotected imports
@@ -238,7 +245,7 @@ If `make test-unit` fails:
### 3. Common Development Tips
-- **Use type hints**: MyPy requires proper type annotations
+- **Use type hints**: basedpyright requires proper type annotations
- **Write descriptive commit messages**: Help reviewers understand your changes
- **Keep PRs focused**: One feature/fix per PR
- **Test edge cases**: Don't just test the happy path
diff --git a/Dockerfile b/Dockerfile
index 9ad9ab31b65..af49dc8d8cf 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,8 +1,8 @@
# Base image for building
-ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:31da6565f35af6401031c1d7aa91dc84ac76c5c48edd17fb90f0ed9e3173c7a9
+ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370
# Runtime image
-ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:31da6565f35af6401031c1d7aa91dc84ac76c5c48edd17fb90f0ed9e3173c7a9
+ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:c61ac6919b811ea53c4782d69f1fe05218ba3c25d53f01b6ab7892e621bd4370
ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a
FROM $UV_IMAGE AS uvbin
@@ -68,22 +68,24 @@ FROM $LITELLM_RUNTIME_IMAGE AS runtime
USER root
-RUN apk add --no-cache bash openssl tzdata nodejs npm python3 libsndfile && \
- npm install -g npm@11.14.0 tar@7.5.11 glob@13.0.6 @isaacs/brace-expansion@5.0.1 brace-expansion@5.0.5 minimatch@10.2.4 diff@8.0.3 picomatch@4.0.4 && \
- GLOBAL="$(npm root -g)" && \
- for pkg in tar glob @isaacs/brace-expansion brace-expansion minimatch diff picomatch; do \
- name="${pkg##*/}"; \
- find "$GLOBAL/npm" -type d -name "$name" -path "*/node_modules/$pkg" | while read d; do \
- rm -rf "$d" && cp -rL "$GLOBAL/$pkg" "$d"; \
- done; \
- done && \
- npm cache clean --force && \
- { apk del --no-cache npm 2>/dev/null || true; }
+# node (without npm) is required by the prisma CLI at runtime
+RUN apk add --no-cache bash openssl tzdata nodejs python3 libsndfile
WORKDIR /app
ENV PATH="/app/.venv/bin:${PATH}"
-COPY --from=builder /app /app
+# Copy only what runtime needs. The application is installed inside the venv;
+# the rest of the builder's /app is source and build metadata that must not
+# ship (manifest-scanning tools attribute everything in it to this image).
+# entrypoint.sh invokes litellm/proxy/prisma_migration.py by source path.
+COPY --from=builder /app/.venv /app/.venv
+COPY --from=builder /app/docker /app/docker
+COPY --from=builder /app/schema.prisma /app/schema.prisma
+COPY --from=builder /app/litellm/proxy/prisma_migration.py /app/litellm/proxy/prisma_migration.py
+# enterprise/ is imported by source path at runtime (proxy_cli puts the
+# working directory on sys.path; litellm/proxy/hooks resolves
+# enterprise.enterprise_hooks from it)
+COPY --from=builder /app/enterprise /app/enterprise
# Prisma binaries live in $HOME/.cache (default prisma-python location),
# which is /root/.cache here. Copy only the Prisma subdirs — copying the
# whole /root/.cache drags in the uv build cache (~660 MB, includes a
diff --git a/Makefile b/Makefile
index a00a90da601..076eac0f4a7 100644
--- a/Makefile
+++ b/Makefile
@@ -5,7 +5,9 @@
test-unit-integrations test-unit-core-utils test-unit-other test-unit-root \
test-proxy-unit-a test-proxy-unit-b test-integration test-unit-helm \
info lint lint-dev format \
- install-dev install-proxy-dev install-test-deps \
+ lint-basedpyright lint-basedpyright-budget-update \
+ lint-ruff-budget lint-ruff-budget-update lint-budget-update lint-gate \
+ install-dev install-proxy-dev install-test-deps install-hooks \
install-helm-unittest check-circular-imports check-import-safety
# Default target
@@ -17,12 +19,18 @@ help:
@echo " make install-proxy-dev-ci - Install proxy dev dependencies (CI-compatible)"
@echo " make install-test-deps - Install the full local test environment"
@echo " make install-helm-unittest - Install helm unittest plugin"
+ @echo " make install-hooks - Install git hooks (Conventional Commits + Branches)"
@echo " make format - Apply Black code formatting"
@echo " make format-check - Check Black code formatting (matches CI)"
- @echo " make lint - Run all linting (Ruff, MyPy, Black check, circular imports, import safety)"
+ @echo " make lint - Run all linting (Ruff, basedpyright, Black check, circular imports, import safety)"
@echo " make lint-ruff - Run Ruff linting only"
- @echo " make lint-mypy - Run MyPy type checking only"
+ @echo " make lint-basedpyright - Run basedpyright strict, gated by per-rule error counts"
+ @echo " make lint-basedpyright-budget-update - Re-capture the basedpyright per-rule budget (ratchet)"
@echo " make lint-black - Check Black formatting (matches CI)"
+ @echo " make lint-ruff-budget - Gate the codebase total of each strict ruff rule against its ceiling"
+ @echo " make lint-gate - Strict ruff gate in CI-parity mode (fetches staging, simulates the merge)"
+ @echo " make lint-ruff-budget-update - Re-capture per-rule baselines in ruff-strict-budget.json (ratchet)"
+ @echo " make lint-budget-update - Re-capture all ratchet budgets (ruff + basedpyright)"
@echo " make check-circular-imports - Check for circular imports"
@echo " make check-import-safety - Check import safety"
@echo " make test - Run all tests"
@@ -68,6 +76,11 @@ install-test-deps: install-proxy-dev
install-helm-unittest:
helm plugin install https://github.com/helm-unittest/helm-unittest --version v0.4.4 || echo "ignore error if plugin exists"
+# Install git hooks that enforce Conventional Commits and Conventional Branches.
+# Opt-in: not chained into install-dev.
+install-hooks:
+ ./scripts/install_git_hooks.sh
+
# Formatting
format: install-dev
cd litellm && $(UV_RUN) black . && cd ..
@@ -111,11 +124,30 @@ lint-ruff-FULL-dev: install-dev
if [ -n "$$files" ]; then echo "$$files" | xargs $(UV_RUN) ruff check; \
else echo "No changed .py files to check."; fi
-lint-mypy: install-dev
- cd litellm && $(UV_RUN) mypy . --ignore-missing-imports && cd ..
+lint-basedpyright: install-dev
+ git fetch origin litellm_internal_staging
+ ($(UV_RUN) basedpyright --outputjson || true) | $(UV_RUN) python scripts/type_check_gate.py --base origin/litellm_internal_staging
+
+lint-basedpyright-budget-update: install-dev
+ ($(UV_RUN) basedpyright --outputjson || true) | $(UV_RUN) python scripts/type_check_gate.py --update
lint-black: format-check
+lint-ruff-budget: install-dev
+ $(UV_RUN) python scripts/ruff_strict_gate.py
+
+# Strict gate, invoked the same way CI does in test-linting.yml so a local pass
+# means the CI check will pass too.
+lint-gate: install-dev
+ git fetch origin litellm_internal_staging
+ $(UV_RUN) python scripts/ruff_strict_gate.py --base origin/litellm_internal_staging
+
+lint-ruff-budget-update: install-dev
+ $(UV_RUN) python scripts/ruff_strict_gate.py --update
+
+# Ratchet all budgets in one shot (ruff strict + basedpyright)
+lint-budget-update: lint-ruff-budget-update lint-basedpyright-budget-update
+
check-circular-imports: install-dev
cd litellm && $(UV_RUN) python ../tests/documentation_tests/test_circular_imports.py && cd ..
@@ -123,10 +155,10 @@ check-import-safety: install-dev
@$(UV_RUN) python -c "from litellm import *; print('[from litellm import *] OK! no issues!');" || (echo '🚨 import failed, this means you introduced unprotected imports! 🚨'; exit 1)
# Combined linting (matches test-linting.yml workflow)
-lint: format-check lint-ruff lint-mypy check-circular-imports check-import-safety
+lint: format-check lint-ruff lint-basedpyright check-circular-imports check-import-safety lint-ruff-budget
# Faster linting for local development (only checks changed code)
-lint-dev: lint-format-changed lint-mypy check-circular-imports check-import-safety
+lint-dev: lint-format-changed check-circular-imports check-import-safety
# Testing targets
test: install-test-deps
diff --git a/README.md b/README.md
index d600f3952c6..3d0f7282d7c 100644
--- a/README.md
+++ b/README.md
@@ -6,10 +6,10 @@
Open Source AI Gateway for 100+ LLMs. Self-hosted. Enterprise-ready. Call any LLM in OpenAI format.
You can close this tab."
+ if result["error"]
+ else b"
xAI authorization received.
You can close this tab."
+ )
+ self.wfile.write(body)
+
+ def log_message(self, format: str, *args: Any) -> None:
+ return
+
+
+class _CallbackServer(HTTPServer):
+ expected_state: str
+ callback_result: Optional[Dict[str, Optional[str]]]
+
+
+class XAIOAuthAuthenticator:
+ def __init__(
+ self, http_client: Optional[Union[httpx.Client, HTTPHandler]] = None
+ ) -> None:
+ self.token_dir = get_secret_str("XAI_OAUTH_TOKEN_DIR") or os.path.expanduser(
+ "~/.config/litellm/xai_oauth"
+ )
+ self.auth_file = os.path.join(
+ self.token_dir, get_secret_str("XAI_OAUTH_AUTH_FILE") or "auth.json"
+ )
+ self.http_client = http_client
+
+ def get_api_base(self) -> str:
+ return (
+ get_secret_str("XAI_OAUTH_API_BASE")
+ or get_secret_str("XAI_API_BASE")
+ or XAI_API_BASE
+ )
+
+ def get_access_token(self) -> str:
+ auth_data = self._read_auth_file()
+ if not auth_data:
+ raise XAIOAuthLoginRequiredError(
+ "xAI OAuth login required. Run `litellm xai-oauth login`."
+ )
+
+ access_token = auth_data.get("access_token")
+ if access_token and not self._is_expired(auth_data):
+ return access_token
+
+ refresh_token = auth_data.get("refresh_token")
+ if not refresh_token:
+ raise XAIOAuthLoginRequiredError(
+ "xAI OAuth refresh token missing. Run `litellm xai-oauth login`."
+ )
+
+ with _XAI_OAUTH_REFRESH_LOCK:
+ locked_auth_data = self._read_auth_file() or auth_data
+ access_token = locked_auth_data.get("access_token")
+ if access_token and not self._is_expired(locked_auth_data):
+ return access_token
+
+ refreshed = self._refresh_tokens(locked_auth_data)
+ return refreshed["access_token"]
+
+ def login(self, force: bool = False, no_browser: bool = False) -> Dict[str, Any]:
+ existing = self._read_auth_file()
+ if existing and not force and existing.get("access_token"):
+ if not self._is_expired(existing):
+ return existing
+ if existing.get("refresh_token"):
+ try:
+ return self._refresh_tokens(existing)
+ except XAIOAuthError:
+ pass
+
+ discovery = self._discover()
+ verifier, challenge = self._pkce_pair()
+ state = uuid.uuid4().hex
+ nonce = uuid.uuid4().hex
+ server, redirect_uri = self._start_callback_server(state)
+ authorize_url = self._build_authorize_url(
+ authorization_endpoint=discovery["authorization_endpoint"],
+ redirect_uri=redirect_uri,
+ challenge=challenge,
+ state=state,
+ nonce=nonce,
+ )
+
+ if no_browser or not webbrowser.open(authorize_url):
+ sys.stdout.write(
+ f"Open this URL to authenticate with xAI:\n{authorize_url}\n"
+ )
+ sys.stdout.flush()
+
+ result = self._wait_for_callback(server)
+ if result.get("state") != state:
+ raise XAIOAuthError("xAI OAuth state mismatch")
+ if result.get("error"):
+ description = result.get("error_description") or result["error"]
+ raise XAIOAuthError(f"xAI authorization failed: {description}")
+ code = result.get("code")
+ if not code:
+ raise XAIOAuthError("xAI authorization failed: no code returned")
+
+ token_payload = self._exchange_token(
+ discovery["token_endpoint"],
+ {
+ "grant_type": "authorization_code",
+ "code": code,
+ "redirect_uri": redirect_uri,
+ "client_id": XAI_OAUTH_CLIENT_ID,
+ "code_verifier": verifier,
+ },
+ )
+ auth_data = self._build_auth_record(token_payload, discovery["token_endpoint"])
+ self._write_auth_file(auth_data)
+ return auth_data
+
+ def _client(self) -> Union[httpx.Client, HTTPHandler]:
+ return self.http_client or _get_httpx_client()
+
+ def _ensure_token_dir(self) -> None:
+ os.makedirs(self.token_dir, mode=0o700, exist_ok=True)
+ try:
+ os.chmod(self.token_dir, 0o700)
+ except OSError:
+ verbose_logger.debug("Could not chmod xAI OAuth token directory")
+
+ def _read_auth_file(self) -> Optional[Dict[str, Any]]:
+ try:
+ with open(self.auth_file, "r") as f:
+ data = json.load(f)
+ return data if isinstance(data, dict) else None
+ except (IOError, json.JSONDecodeError):
+ return None
+
+ def _write_auth_file(self, data: Dict[str, Any]) -> None:
+ self._ensure_token_dir()
+ tmp_file = os.path.join(
+ self.token_dir,
+ f".{os.path.basename(self.auth_file)}.{uuid.uuid4().hex}.tmp",
+ )
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
+ if hasattr(os, "O_NOFOLLOW"):
+ flags |= os.O_NOFOLLOW
+ fd = os.open(tmp_file, flags, 0o600)
+ try:
+ with os.fdopen(fd, "w") as f:
+ json.dump(data, f)
+ f.flush()
+ os.fsync(f.fileno())
+ os.replace(tmp_file, self.auth_file)
+ try:
+ os.chmod(self.auth_file, 0o600)
+ except OSError:
+ verbose_logger.debug("Could not chmod xAI OAuth auth file")
+ except Exception:
+ try:
+ os.close(fd)
+ except OSError:
+ pass
+ try:
+ os.unlink(tmp_file)
+ except OSError:
+ pass
+ raise
+
+ def _is_expired(self, auth_data: Dict[str, Any]) -> bool:
+ expires_at = auth_data.get("expires_at")
+ if expires_at is None:
+ return True
+ try:
+ return time.time() >= float(expires_at) - XAI_OAUTH_EXPIRY_SKEW_SECONDS
+ except (TypeError, ValueError):
+ return True
+
+ def _discover(self) -> Dict[str, str]:
+ try:
+ response = self._client().get(
+ XAI_OAUTH_DISCOVERY_URL, headers={"Accept": "application/json"}
+ )
+ response.raise_for_status()
+ except httpx.HTTPStatusError as exc:
+ raise XAIOAuthError(
+ f"xAI OAuth discovery request failed: {exc.response.status_code} {exc.response.text}"
+ ) from exc
+ try:
+ data = response.json()
+ except ValueError as exc:
+ raise XAIOAuthError(
+ "xAI OAuth discovery response was not valid JSON"
+ ) from exc
+ authorization_endpoint = data.get("authorization_endpoint")
+ token_endpoint = data.get("token_endpoint")
+ if not authorization_endpoint or not token_endpoint:
+ raise XAIOAuthError("xAI OAuth discovery missing endpoints")
+ return {
+ "authorization_endpoint": self._validate_xai_endpoint(
+ authorization_endpoint
+ ),
+ "token_endpoint": self._validate_xai_endpoint(token_endpoint),
+ }
+
+ def _validate_xai_endpoint(self, url: str) -> str:
+ parsed = urlparse(url)
+ host = (parsed.hostname or "").lower()
+ if parsed.scheme != "https" or (host != "x.ai" and not host.endswith(".x.ai")):
+ raise XAIOAuthError(
+ f"xAI OAuth discovery returned unexpected endpoint: {url}"
+ )
+ return url
+
+ def _pkce_pair(self) -> Tuple[str, str]:
+ verifier = (
+ base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
+ )
+ challenge = (
+ base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
+ .rstrip(b"=")
+ .decode()
+ )
+ return verifier, challenge
+
+ def _start_callback_server(self, state: str) -> Tuple[_CallbackServer, str]:
+ last_error: Optional[OSError] = None
+ for port in (XAI_OAUTH_REDIRECT_PORT, 0):
+ try:
+ server = _CallbackServer(
+ (XAI_OAUTH_REDIRECT_HOST, port), _CallbackHandler
+ )
+ server.expected_state = state
+ server.callback_result = None
+ actual_port = server.server_address[1]
+ redirect_uri = f"http://{XAI_OAUTH_REDIRECT_HOST}:{actual_port}{XAI_OAUTH_REDIRECT_PATH}"
+ return server, redirect_uri
+ except OSError as exc:
+ last_error = exc
+ raise XAIOAuthError(f"Could not start xAI OAuth callback server: {last_error}")
+
+ def _build_authorize_url(
+ self,
+ authorization_endpoint: str,
+ redirect_uri: str,
+ challenge: str,
+ state: str,
+ nonce: str,
+ ) -> str:
+ params = {
+ "response_type": "code",
+ "client_id": XAI_OAUTH_CLIENT_ID,
+ "redirect_uri": redirect_uri,
+ "scope": XAI_OAUTH_SCOPE,
+ "code_challenge": challenge,
+ "code_challenge_method": "S256",
+ "state": state,
+ "nonce": nonce,
+ }
+ return f"{authorization_endpoint}?{urlencode(params)}"
+
+ def _wait_for_callback(self, server: _CallbackServer) -> Dict[str, Optional[str]]:
+ server.timeout = 1
+ deadline = time.time() + XAI_OAUTH_CALLBACK_TIMEOUT_SECONDS
+ try:
+ while time.time() < deadline:
+ server.handle_request()
+ if server.callback_result is not None:
+ return server.callback_result
+ finally:
+ server.server_close()
+ raise XAIOAuthError("Timed out waiting for xAI OAuth callback")
+
+ def _exchange_token(
+ self, token_endpoint: str, data: Dict[str, str]
+ ) -> Dict[str, Any]:
+ try:
+ response = self._client().post(
+ token_endpoint,
+ headers={
+ "Accept": "application/json",
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ data=data,
+ )
+ response.raise_for_status()
+ except httpx.HTTPStatusError as exc:
+ raise XAIOAuthError(
+ f"xAI OAuth token request failed: {exc.response.status_code} {exc.response.text}"
+ ) from exc
+ try:
+ body = response.json()
+ except ValueError as exc:
+ raise XAIOAuthError("xAI OAuth token response was not valid JSON") from exc
+ if not isinstance(body, dict):
+ raise XAIOAuthError("xAI OAuth token response was not an object")
+ return body
+
+ def _build_auth_record(
+ self,
+ token_payload: Dict[str, Any],
+ token_endpoint: str,
+ fallback_refresh_token: Optional[str] = None,
+ ) -> Dict[str, Any]:
+ access_token = token_payload.get("access_token")
+ refresh_token = token_payload.get("refresh_token") or fallback_refresh_token
+ if not access_token:
+ raise XAIOAuthError("xAI OAuth token response missing access_token")
+ if not refresh_token:
+ raise XAIOAuthError("xAI OAuth token response missing refresh_token")
+ expires_in = token_payload.get("expires_in") or 3600
+ try:
+ expires_at = int(time.time() + int(expires_in))
+ except (TypeError, ValueError):
+ expires_at = int(time.time() + 3600)
+ return {
+ "access_token": access_token,
+ "refresh_token": refresh_token,
+ "id_token": token_payload.get("id_token"),
+ "token_type": token_payload.get("token_type") or "Bearer",
+ "token_endpoint": token_endpoint,
+ "expires_at": expires_at,
+ }
+
+ def _refresh_tokens(self, auth_data: Dict[str, Any]) -> Dict[str, Any]:
+ token_endpoint = auth_data.get("token_endpoint")
+ if not token_endpoint:
+ token_endpoint = self._discover()["token_endpoint"]
+ token_endpoint = self._validate_xai_endpoint(token_endpoint)
+ refresh_token = auth_data.get("refresh_token")
+ if not refresh_token:
+ raise XAIOAuthLoginRequiredError(
+ "xAI OAuth refresh token missing. Run `litellm xai-oauth login`."
+ )
+
+ token_payload = self._exchange_token(
+ token_endpoint,
+ {
+ "grant_type": "refresh_token",
+ "refresh_token": refresh_token,
+ "client_id": XAI_OAUTH_CLIENT_ID,
+ },
+ )
+ refreshed = self._build_auth_record(
+ token_payload,
+ token_endpoint,
+ fallback_refresh_token=refresh_token,
+ )
+ self._write_auth_file(refreshed)
+ return refreshed
+
+
+def should_use_xai_oauth(litellm_params: Optional[Dict[str, Any]]) -> bool:
+ return bool((litellm_params or {}).get("use_xai_oauth"))
diff --git a/litellm/llms/xai/responses/transformation.py b/litellm/llms/xai/responses/transformation.py
index 55805ddaede..f81e860a8ce 100644
--- a/litellm/llms/xai/responses/transformation.py
+++ b/litellm/llms/xai/responses/transformation.py
@@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
import litellm
from litellm._logging import verbose_logger
from litellm.constants import XAI_API_BASE
+from litellm.exceptions import AuthenticationError
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
from litellm.llms.xai.common_utils import XAIModelInfo
from litellm.secret_managers.main import get_secret_str
@@ -220,10 +221,27 @@ class XAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
litellm_params.api_key, legacy_generic_before_env=True
)
+ if not api_key:
+ from litellm.llms.xai.oauth import (
+ XAIOAuthAuthenticator,
+ XAIOAuthError,
+ should_use_xai_oauth,
+ )
+
+ if should_use_xai_oauth(litellm_params.model_dump()):
+ try:
+ api_key = XAIOAuthAuthenticator().get_access_token()
+ except XAIOAuthError as exc:
+ raise AuthenticationError(
+ model=model,
+ llm_provider=self.custom_llm_provider.value,
+ message=str(exc),
+ ) from exc
+
if not api_key:
raise ValueError(
"XAI API key is required. Set api_key, litellm.xai_key, "
- "litellm.api_key, or XAI_API_KEY."
+ "litellm.api_key, XAI_API_KEY, or use_xai_oauth=True."
)
headers.update(
@@ -244,12 +262,20 @@ class XAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
Returns:
str: The full URL for the XAI /responses endpoint
"""
- api_base = (
- api_base
- or litellm.api_base
- or get_secret_str("XAI_API_BASE")
- or XAI_API_BASE
+ from litellm.llms.xai.oauth import XAIOAuthAuthenticator, should_use_xai_oauth
+
+ api_key = XAIModelInfo.get_api_key(
+ litellm_params.get("api_key"), legacy_generic_before_env=True
)
+ if should_use_xai_oauth(litellm_params) and not api_key:
+ api_base = XAIOAuthAuthenticator().get_api_base()
+ else:
+ api_base = (
+ api_base
+ or litellm.api_base
+ or get_secret_str("XAI_API_BASE")
+ or XAI_API_BASE
+ )
# Remove trailing slashes
api_base = api_base.rstrip("/")
diff --git a/litellm/llms/you_com/search/transformation.py b/litellm/llms/you_com/search/transformation.py
index 3c94b991735..0c7916e4c05 100644
--- a/litellm/llms/you_com/search/transformation.py
+++ b/litellm/llms/you_com/search/transformation.py
@@ -64,7 +64,13 @@ class YouComSearchConfig(BaseSearchConfig):
endpoint with the `X-API-Key` header. Otherwise fall through to the
keyless free tier; no auth header is required.
"""
- api_key = api_key or get_secret_str("YOUCOM_API_KEY")
+ api_key = self.resolve_server_api_key(
+ caller_api_key=api_key,
+ caller_api_base=api_base,
+ key_env_vars=("YOUCOM_API_KEY",),
+ base_env_var="YOUCOM_API_BASE",
+ default_api_base=self.YOU_COM_API_BASE,
+ )
headers["Content-Type"] = "application/json"
# Pin Accept-Encoding to identity: the keyless `api.you.com/v1/agents/search`
# endpoint advertises gzip content-encoding but returns body bytes the
diff --git a/litellm/main.py b/litellm/main.py
index 1a0d0312d73..c3d7ca28c49 100644
--- a/litellm/main.py
+++ b/litellm/main.py
@@ -81,11 +81,18 @@ from litellm.constants import (
from litellm.exceptions import LiteLLMUnknownProvider
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.asyncify import run_async_function
+from litellm.litellm_core_utils.chat_completion_agentic_loop import (
+ maybe_run_chat_completion_agentic_loop,
+)
from litellm.litellm_core_utils.audio_utils.utils import (
calculate_request_duration,
get_audio_file_for_health_check,
)
from litellm.litellm_core_utils.completion_timeout import CompletionTimeout
+from litellm.litellm_core_utils.request_timeout_resolver import (
+ get_configured_request_timeout,
+)
+from litellm.litellm_core_utils.get_litellm_params import OPTIONAL_KWARGS_KEYS
from litellm.litellm_core_utils.dd_tracing import tracer
from litellm.litellm_core_utils.get_provider_specific_headers import (
ProviderSpecificHeaderUtils,
@@ -117,6 +124,10 @@ from litellm.llms.vertex_ai.common_utils import (
)
from litellm.realtime_api.main import _realtime_health_check
from litellm.secret_managers.main import get_secret_bool, get_secret_str
+from litellm.types.completion import (
+ _CompletionDispatchContext,
+ _CompletionDispatchResult,
+)
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import (
CustomPricingLiteLLMParams,
@@ -391,7 +402,7 @@ class AsyncCompletions:
@tracer.wrap()
@client
-async def acompletion( # noqa: PLR0915
+async def acompletion(
model: str,
# Optional OpenAI params: see https://platform.openai.com/docs/api-reference/chat/create
messages: List = [],
@@ -649,6 +660,39 @@ async def acompletion( # noqa: PLR0915
response_object=response,
model_response_object=litellm.ModelResponse(),
)
+ # Provider-agnostic dispatch point for the chat-completions agentic loop
+ # (code-interpreter interception, etc). Chat routing forks per provider
+ # before this (OpenAI goes through the OpenAI SDK in openai.py, others
+ # through the shared httpx handler), so a dispatch inside any single
+ # provider handler would miss the others. Here is where every fork
+ # reconverges, so the loop runs once for all providers. Responses needs
+ # no equivalent: every provider already funnels through one shared
+ # handler where the loop is dispatched.
+ if isinstance(response, litellm.ModelResponse):
+ looped = await maybe_run_chat_completion_agentic_loop(
+ response=response,
+ model=model,
+ messages=messages,
+ optional_params={
+ k: v
+ for k, v in completion_kwargs.items()
+ if v is not None
+ and k
+ not in (
+ "model",
+ "messages",
+ "stream",
+ "acompletion",
+ "deployment_id",
+ )
+ },
+ kwargs=kwargs,
+ logging_obj=kwargs.get("litellm_logging_obj"),
+ custom_llm_provider=custom_llm_provider,
+ stream=bool(stream),
+ )
+ if looped is not None:
+ response = looped
if isinstance(response, CustomStreamWrapper):
response.set_logging_event_loop(
loop=loop
@@ -1083,9 +1127,3828 @@ def _build_custom_pricing_entry(
return entry
+def _complete_azure(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ _azure_detection_model = ctx._azure_detection_model
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ api_version = ctx.api_version
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ extra_headers = ctx.extra_headers
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logger_fn = ctx.logger_fn
+ logging = ctx.logging
+ max_retries = ctx.max_retries
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ timeout = ctx.timeout
+
+ dynamic_params = False
+ if client is not None and (
+ isinstance(client, openai.AzureOpenAI)
+ or isinstance(client, openai.AsyncAzureOpenAI)
+ ):
+ dynamic_params = _check_dynamic_azure_params(
+ azure_client_params={"api_version": api_version},
+ azure_client=client,
+ )
+
+ api_type = get_secret("AZURE_API_TYPE") or "azure"
+
+ api_base = api_base or litellm.api_base or get_secret("AZURE_API_BASE")
+
+ api_version = (
+ api_version
+ or litellm.api_version
+ or get_secret_str("AZURE_API_VERSION")
+ or litellm.AZURE_DEFAULT_API_VERSION
+ )
+
+ api_key = (
+ api_key
+ or litellm.api_key
+ or litellm.azure_key
+ or get_secret_str("AZURE_OPENAI_API_KEY")
+ or get_secret_str("AZURE_API_KEY")
+ )
+
+ azure_ad_token = optional_params.get("extra_body", {}).pop(
+ "azure_ad_token", None
+ ) or get_secret_str("AZURE_AD_TOKEN")
+
+ azure_ad_token_provider = litellm_params.get("azure_ad_token_provider", None)
+
+ headers = headers or litellm.headers
+
+ if extra_headers is not None:
+ optional_params["extra_headers"] = extra_headers
+ if max_retries is not None:
+ optional_params["max_retries"] = max_retries
+
+ if litellm.AzureOpenAIO1Config().is_o_series_model(model=_azure_detection_model):
+ ## LOAD CONFIG - if set
+ config = litellm.AzureOpenAIO1Config.get_config()
+ for k, v in config.items():
+ if (
+ k not in optional_params
+ ): # completion(top_k=3) > azure_config(top_k=3) <- allows for dynamic variables to be passed in
+ optional_params[k] = v
+
+ response = azure_o1_chat_completions.completion(
+ model=model,
+ messages=messages,
+ headers=headers,
+ api_key=api_key,
+ api_base=api_base,
+ api_version=api_version,
+ dynamic_params=dynamic_params,
+ azure_ad_token=azure_ad_token,
+ model_response=model_response,
+ print_verbose=print_verbose,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ logger_fn=logger_fn,
+ logging_obj=logging,
+ acompletion=acompletion,
+ timeout=timeout, # type: ignore
+ client=client, # pass AsyncAzureOpenAI, AzureOpenAI client
+ custom_llm_provider=custom_llm_provider,
+ )
+ else:
+ ## LOAD CONFIG - if set
+ config = litellm.AzureOpenAIConfig.get_config()
+ for k, v in config.items():
+ if (
+ k not in optional_params
+ ): # completion(top_k=3) > azure_config(top_k=3) <- allows for dynamic variables to be passed in
+ optional_params[k] = v
+
+ ## COMPLETION CALL
+ response = azure_chat_completions.completion(
+ model=model,
+ messages=messages,
+ headers=headers,
+ api_key=api_key,
+ api_base=api_base,
+ api_version=api_version,
+ api_type=api_type,
+ dynamic_params=dynamic_params,
+ azure_ad_token=azure_ad_token,
+ azure_ad_token_provider=azure_ad_token_provider,
+ model_response=model_response,
+ print_verbose=print_verbose,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ logger_fn=logger_fn,
+ logging_obj=logging,
+ acompletion=acompletion,
+ timeout=timeout, # type: ignore
+ client=client, # pass AsyncAzureOpenAI, AzureOpenAI client
+ )
+
+ if optional_params.get("stream", False):
+ ## LOGGING
+ logging.post_call(
+ input=messages,
+ api_key=api_key,
+ original_response=response,
+ additional_args={
+ "headers": headers,
+ "api_version": api_version,
+ "api_base": api_base,
+ },
+ )
+
+ return response # pyright: ignore[reportReturnType] # provider SDK return type is broader than the dispatch contract
+
+
+def _complete_azure_text(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ api_version = ctx.api_version
+ client = ctx.client
+ extra_headers = ctx.extra_headers
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logger_fn = ctx.logger_fn
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ timeout = ctx.timeout
+
+ api_type = get_secret_str("AZURE_API_TYPE") or "azure"
+
+ api_base = api_base or litellm.api_base or get_secret_str("AZURE_API_BASE")
+
+ if api_base is None:
+ raise ValueError(
+ "api_base is required for Azure OpenAI LLM provider. Either set it dynamically or set the AZURE_API_BASE environment variable."
+ )
+
+ api_version = (
+ api_version or litellm.api_version or get_secret_str("AZURE_API_VERSION")
+ )
+
+ api_key = (
+ api_key
+ or litellm.api_key
+ or litellm.azure_key
+ or get_secret_str("AZURE_OPENAI_API_KEY")
+ or get_secret_str("AZURE_API_KEY")
+ )
+
+ azure_ad_token = optional_params.get("extra_body", {}).pop(
+ "azure_ad_token", None
+ ) or get_secret_str("AZURE_AD_TOKEN")
+
+ azure_ad_token_provider = litellm_params.get("azure_ad_token_provider", None)
+
+ headers = headers or litellm.headers
+
+ if extra_headers is not None:
+ optional_params["extra_headers"] = extra_headers
+
+ ## LOAD CONFIG - if set
+ config = litellm.AzureOpenAIConfig.get_config()
+ for k, v in config.items():
+ if (
+ k not in optional_params
+ ): # completion(top_k=3) > azure_config(top_k=3) <- allows for dynamic variables to be passed in
+ optional_params[k] = v
+
+ ## COMPLETION CALL
+ response = azure_text_completions.completion(
+ model=model,
+ messages=messages,
+ headers=headers,
+ api_key=api_key,
+ api_base=api_base,
+ api_version=cast(str, api_version),
+ api_type=api_type,
+ azure_ad_token=azure_ad_token,
+ azure_ad_token_provider=azure_ad_token_provider,
+ model_response=model_response,
+ print_verbose=print_verbose,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ logger_fn=logger_fn,
+ logging_obj=logging,
+ acompletion=acompletion,
+ timeout=timeout,
+ client=client, # pass AsyncAzureOpenAI, AzureOpenAI client
+ )
+
+ if optional_params.get("stream", False) or acompletion is True:
+ ## LOGGING
+ logging.post_call(
+ input=messages,
+ api_key=api_key,
+ original_response=response,
+ additional_args={
+ "headers": headers,
+ "api_version": api_version,
+ "api_base": api_base,
+ },
+ )
+
+ return response
+
+
+def _complete_deepseek(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ provider_config = ctx.provider_config
+ shared_session = ctx.shared_session
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ try:
+ response = base_llm_http_handler.completion(
+ model=model,
+ messages=messages,
+ headers=headers,
+ model_response=model_response,
+ api_key=api_key,
+ api_base=api_base,
+ acompletion=acompletion,
+ logging_obj=logging,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ shared_session=shared_session,
+ timeout=timeout, # type: ignore
+ client=client,
+ custom_llm_provider=custom_llm_provider,
+ encoding=_get_encoding(),
+ stream=stream,
+ provider_config=provider_config,
+ )
+ except Exception as e:
+ ## LOGGING - log the original exception returned
+ logging.post_call(
+ input=messages,
+ api_key=api_key,
+ original_response=str(e),
+ additional_args={"headers": headers},
+ )
+ raise e
+
+ return response
+
+
+def _complete_azure_ai(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ extra_headers = ctx.extra_headers
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logger_fn = ctx.logger_fn
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ shared_session = ctx.shared_session
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo
+
+ azure_ai_route = AzureFoundryModelInfo.get_azure_ai_route(model)
+
+ # Check if this is an agents route - model format: azure_ai/agents/
+ if azure_ai_route == "agents":
+ from litellm.llms.azure_ai.agents import AzureAIAgentsConfig
+
+ api_base = AzureFoundryModelInfo.get_api_base(api_base)
+ if api_base is None:
+ raise ValueError(
+ "Azure AI Agents requests require an api_base. "
+ "Set `api_base` or the AZURE_AI_API_BASE env var."
+ )
+ api_key = AzureFoundryModelInfo.get_api_key(api_key)
+
+ response = AzureAIAgentsConfig.completion(
+ model=model,
+ messages=messages,
+ api_base=api_base,
+ api_key=api_key,
+ model_response=model_response,
+ logging_obj=logging,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ timeout=timeout,
+ acompletion=acompletion,
+ stream=stream,
+ headers=headers or litellm.headers,
+ )
+
+ # Check if this is a Claude model - route to Azure Anthropic handler
+ elif "claude" in model.lower():
+ # Use Azure Anthropic handler for Claude models
+ api_base = AzureFoundryModelInfo.get_api_base(api_base)
+ if api_base is None:
+ raise ValueError(
+ "Azure Anthropic requests require an api_base. "
+ "Set `api_base` or the AZURE_AI_API_BASE env var."
+ )
+ api_key = AzureFoundryModelInfo.get_api_key(api_key)
+
+ # Ensure the URL ends with /v1/messages for Anthropic
+ if api_base:
+ api_base = api_base.rstrip("/")
+ if not api_base.endswith("/v1/messages"):
+ if "/anthropic" in api_base:
+ parts = api_base.split("/anthropic", 1)
+ api_base = parts[0] + "/anthropic"
+ else:
+ api_base = api_base + "/anthropic"
+ api_base = api_base + "/v1/messages"
+
+ response = azure_anthropic_chat_completions.completion(
+ model=model,
+ messages=messages,
+ api_base=api_base,
+ acompletion=acompletion,
+ custom_prompt_dict=litellm.custom_prompt_dict,
+ model_response=model_response,
+ print_verbose=print_verbose,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ logger_fn=logger_fn,
+ encoding=_get_encoding(),
+ api_key=api_key,
+ logging_obj=logging,
+ headers=headers,
+ timeout=timeout,
+ client=client,
+ custom_llm_provider=custom_llm_provider,
+ )
+ if optional_params.get("stream", False) or acompletion is True:
+ ## LOGGING
+ logging.post_call(
+ input=messages,
+ api_key=api_key,
+ original_response=response,
+ )
+ response = response
+ else:
+ # Non-Claude models use standard Azure AI flow
+ api_base = AzureFoundryModelInfo.get_api_base(api_base)
+ # set API KEY
+ api_key = AzureFoundryModelInfo.get_api_key(api_key)
+
+ headers = headers or litellm.headers
+
+ if extra_headers is not None:
+ optional_params["extra_headers"] = extra_headers
+
+ ## FOR COHERE
+ if "command-r" in model: # make sure tool call in messages are str
+ messages = stringify_json_tool_call_content(messages=messages)
+
+ ## COMPLETION CALL
+ try:
+ response = base_llm_http_handler.completion(
+ model=model,
+ messages=messages,
+ headers=headers,
+ model_response=model_response,
+ api_key=api_key,
+ api_base=api_base,
+ acompletion=acompletion,
+ logging_obj=logging,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ shared_session=shared_session,
+ timeout=timeout, # type: ignore
+ client=client, # pass AsyncOpenAI, OpenAI client
+ custom_llm_provider=custom_llm_provider,
+ encoding=_get_encoding(),
+ stream=stream,
+ )
+ except Exception as e:
+ ## LOGGING - log the original exception returned
+ logging.post_call(
+ input=messages,
+ api_key=api_key,
+ original_response=str(e),
+ additional_args={"headers": headers},
+ )
+ raise e
+
+ if optional_params.get("stream", False):
+ ## LOGGING
+ logging.post_call(
+ input=messages,
+ api_key=api_key,
+ original_response=response,
+ additional_args={"headers": headers},
+ )
+
+ return response
+
+
+def _complete_text_completion_openai(
+ ctx: _CompletionDispatchContext,
+) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logger_fn = ctx.logger_fn
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ text_completion = ctx.text_completion
+ timeout = ctx.timeout
+
+ openai.api_type = "openai"
+
+ api_base = (
+ api_base
+ or litellm.api_base
+ or get_secret("OPENAI_BASE_URL")
+ or get_secret("OPENAI_API_BASE")
+ or "https://api.openai.com/v1"
+ )
+
+ openai.api_version = None
+ # set API KEY
+
+ api_key = (
+ api_key or litellm.api_key or litellm.openai_key or get_secret("OPENAI_API_KEY")
+ )
+
+ headers = headers or litellm.headers
+
+ ## LOAD CONFIG - if set
+ config = litellm.OpenAITextCompletionConfig.get_config()
+ for k, v in config.items():
+ if (
+ k not in optional_params
+ ): # completion(top_k=3) > openai_text_config(top_k=3) <- allows for dynamic variables to be passed in
+ optional_params[k] = v
+ if litellm.organization:
+ openai.organization = litellm.organization
+
+ ## COMPLETION CALL
+ _response = openai_text_completions.completion(
+ model=model,
+ messages=messages,
+ headers=headers,
+ model_response=model_response,
+ print_verbose=print_verbose,
+ api_key=api_key,
+ custom_llm_provider=custom_llm_provider,
+ api_base=api_base,
+ acompletion=acompletion,
+ client=client, # pass AsyncOpenAI, OpenAI client
+ logging_obj=logging,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ logger_fn=logger_fn,
+ timeout=timeout, # type: ignore
+ )
+
+ if (
+ optional_params.get("stream", False) is False
+ and acompletion is False
+ and text_completion is False
+ ):
+ # convert to chat completion response
+ _response = (
+ litellm.OpenAITextCompletionConfig().convert_to_chat_model_response_object(
+ response_object=_response, model_response_object=model_response
+ )
+ )
+
+ if optional_params.get("stream", False) or acompletion is True:
+ ## LOGGING
+ logging.post_call(
+ input=messages,
+ api_key=api_key,
+ original_response=_response,
+ additional_args={"headers": headers},
+ )
+ return _response # pyright: ignore[reportReturnType] # provider SDK return type is broader than the dispatch contract
+
+
+def _complete_fireworks_ai(
+ ctx: _CompletionDispatchContext,
+) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ provider_config = ctx.provider_config
+ shared_session = ctx.shared_session
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ try:
+ response = base_llm_http_handler.completion(
+ model=model,
+ messages=messages,
+ headers=headers,
+ model_response=model_response,
+ api_key=api_key,
+ api_base=api_base,
+ acompletion=acompletion,
+ logging_obj=logging,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ shared_session=shared_session,
+ timeout=timeout, # type: ignore
+ client=client,
+ custom_llm_provider=custom_llm_provider,
+ encoding=_get_encoding(),
+ stream=stream,
+ provider_config=provider_config,
+ )
+ except Exception as e:
+ ## LOGGING - log the original exception returned
+ logging.post_call(
+ input=messages,
+ api_key=api_key,
+ original_response=str(e),
+ additional_args={"headers": headers},
+ )
+ raise e
+
+ return response
+
+
+def _complete_heroku(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ provider_config = ctx.provider_config
+ shared_session = ctx.shared_session
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ try:
+ response = base_llm_http_handler.completion(
+ model=model,
+ messages=messages,
+ headers=headers,
+ model_response=model_response,
+ api_key=api_key,
+ api_base=api_base,
+ acompletion=acompletion,
+ logging_obj=logging,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ shared_session=shared_session,
+ timeout=timeout,
+ client=client,
+ custom_llm_provider=custom_llm_provider,
+ encoding=_get_encoding(),
+ stream=stream,
+ provider_config=provider_config,
+ )
+ except Exception as e:
+ logging.post_call(
+ input=messages,
+ api_key=api_key,
+ original_response=str(e),
+ additional_args={"headers": headers},
+ )
+ raise e
+
+ return response
+
+
+def _complete_ragflow(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ provider_config = ctx.provider_config
+ shared_session = ctx.shared_session
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ try:
+ response = base_llm_http_handler.completion(
+ model=model,
+ messages=messages,
+ headers=headers,
+ model_response=model_response,
+ api_key=api_key,
+ api_base=api_base,
+ acompletion=acompletion,
+ logging_obj=logging,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ shared_session=shared_session,
+ timeout=timeout,
+ client=client,
+ custom_llm_provider=custom_llm_provider,
+ encoding=_get_encoding(),
+ stream=stream,
+ provider_config=provider_config,
+ )
+ except Exception as e:
+ logging.post_call(
+ input=messages,
+ api_key=api_key,
+ original_response=str(e),
+ additional_args={"headers": headers},
+ )
+ raise e
+
+ return response
+
+
+def _complete_xai(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ provider_config = ctx.provider_config
+ shared_session = ctx.shared_session
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ try:
+ response = base_llm_http_handler.completion(
+ model=model,
+ messages=messages,
+ headers=headers,
+ model_response=model_response,
+ api_key=api_key,
+ api_base=api_base,
+ acompletion=acompletion,
+ logging_obj=logging,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ shared_session=shared_session,
+ timeout=timeout, # type: ignore
+ client=client,
+ custom_llm_provider=custom_llm_provider,
+ encoding=_get_encoding(),
+ stream=stream,
+ provider_config=provider_config,
+ )
+ except Exception as e:
+ ## LOGGING - log the original exception returned
+ logging.post_call(
+ input=messages,
+ api_key=api_key,
+ original_response=str(e),
+ additional_args={"headers": headers},
+ )
+ raise e
+
+ return response
+
+
+def _complete_groq(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ shared_session = ctx.shared_session
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ api_base = (
+ api_base # for deepinfra/perplexity/anyscale/groq/friendliai we check in get_llm_provider and pass in the api base from there
+ or litellm.api_base
+ or get_secret("GROQ_API_BASE")
+ or "https://api.groq.com/openai/v1"
+ )
+
+ # set API KEY
+ api_key = (
+ api_key
+ or litellm.api_key # for deepinfra/perplexity/anyscale/friendliai we check in get_llm_provider and pass in the api key from there
+ or litellm.groq_key
+ or get_secret("GROQ_API_KEY")
+ )
+
+ headers = headers or litellm.headers
+
+ ## LOAD CONFIG - if set
+ config = litellm.GroqChatConfig.get_config()
+ for k, v in config.items():
+ if (
+ k not in optional_params
+ ): # completion(top_k=3) > openai_config(top_k=3) <- allows for dynamic variables to be passed in
+ optional_params[k] = v
+
+ return base_llm_http_handler.completion(
+ model=model,
+ stream=stream,
+ messages=messages,
+ acompletion=acompletion,
+ api_base=api_base,
+ model_response=model_response,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ shared_session=shared_session,
+ custom_llm_provider=custom_llm_provider,
+ timeout=timeout,
+ headers=headers,
+ encoding=_get_encoding(),
+ api_key=api_key,
+ logging_obj=logging, # model call logging done inside the class as we make need to modify I/O to fit aleph alpha's requirements
+ client=client,
+ )
+
+
+def _complete_bedrock_mantle(
+ ctx: _CompletionDispatchContext,
+) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ shared_session = ctx.shared_session
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ api_base = api_base or litellm.api_base or get_secret("BEDROCK_MANTLE_API_BASE")
+ api_key = api_key or litellm.api_key or get_secret("BEDROCK_MANTLE_API_KEY")
+ headers = headers or litellm.headers
+ config = litellm.BedrockMantleChatConfig.get_config()
+ for k, v in config.items():
+ if k not in optional_params:
+ optional_params[k] = v
+ return base_llm_http_handler.completion(
+ model=model,
+ stream=stream,
+ messages=messages,
+ acompletion=acompletion,
+ api_base=api_base,
+ model_response=model_response,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ shared_session=shared_session,
+ custom_llm_provider=custom_llm_provider,
+ timeout=timeout,
+ headers=headers,
+ encoding=_get_encoding(),
+ api_key=api_key,
+ logging_obj=logging,
+ client=client,
+ )
+
+
+def _complete_a2a(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ provider_config = ctx.provider_config
+ shared_session = ctx.shared_session
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ (
+ api_base,
+ api_key,
+ headers,
+ ) = litellm.A2AConfig.resolve_agent_config_from_registry(
+ model=model,
+ api_base=api_base,
+ api_key=api_key,
+ headers=headers,
+ optional_params=optional_params,
+ )
+
+ # Fall back to environment variables and defaults
+ api_base = api_base or litellm.api_base or get_secret_str("A2A_API_BASE")
+
+ if api_base is None:
+ raise Exception(
+ "api_base is required for A2A provider. "
+ "Either provide api_base parameter, set A2A_API_BASE environment variable, "
+ "or register the agent in the proxy with model='a2a/'."
+ )
+
+ headers = headers or litellm.headers
+
+ return base_llm_http_handler.completion(
+ model=model,
+ stream=stream,
+ messages=messages,
+ acompletion=acompletion,
+ api_base=api_base,
+ model_response=model_response,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ shared_session=shared_session,
+ custom_llm_provider=custom_llm_provider,
+ timeout=timeout,
+ headers=headers,
+ encoding=_get_encoding(),
+ api_key=api_key,
+ logging_obj=logging,
+ client=client,
+ provider_config=provider_config,
+ )
+
+
+def _complete_gigachat(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ provider_config = ctx.provider_config
+ shared_session = ctx.shared_session
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ api_key = (
+ api_key
+ or litellm.api_key
+ or litellm.gigachat_key
+ or get_secret("GIGACHAT_API_KEY")
+ or get_secret("GIGACHAT_CREDENTIALS")
+ )
+
+ headers = headers or litellm.headers or {}
+
+ ## COMPLETION CALL
+ try:
+ response = base_llm_http_handler.completion(
+ model=model,
+ messages=messages,
+ headers=headers,
+ model_response=model_response,
+ api_key=api_key,
+ api_base=api_base,
+ acompletion=acompletion,
+ logging_obj=logging,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ shared_session=shared_session,
+ timeout=timeout,
+ client=client,
+ custom_llm_provider=custom_llm_provider,
+ encoding=_get_encoding(),
+ stream=stream,
+ provider_config=provider_config,
+ )
+ except Exception as e:
+ ## LOGGING - log the original exception returned
+ logging.post_call(
+ input=messages,
+ api_key=api_key,
+ original_response=str(e),
+ additional_args={"headers": headers},
+ )
+ raise e
+
+ return response
+
+
+def _complete_sap(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ shared_session = ctx.shared_session
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ headers = headers or litellm.headers
+ ## LOAD CONFIG - if set
+ config = litellm.GenAIHubOrchestrationConfig.get_config()
+ for k, v in config.items():
+ if (
+ k not in optional_params
+ ): # completion(top_k=3) > openai_config(top_k=3) <- allows for dynamic variables to be passed in
+ optional_params[k] = v
+
+ return sap_gen_ai_hub_chat_completions.completion(
+ model=model,
+ messages=messages,
+ headers=headers,
+ model_response=model_response,
+ acompletion=acompletion,
+ logging_obj=logging,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ timeout=timeout, # type: ignore
+ shared_session=shared_session,
+ client=client,
+ custom_llm_provider=custom_llm_provider,
+ encoding=_get_encoding(),
+ api_key=api_key,
+ api_base=api_base,
+ stream=stream,
+ )
+
+
+def _complete_aiohttp_openai(
+ ctx: _CompletionDispatchContext,
+) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ extra_headers = ctx.extra_headers
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ api_base = (
+ api_base # for deepinfra/perplexity/anyscale/groq/friendliai we check in get_llm_provider and pass in the api base from there
+ or litellm.api_base
+ or get_secret("OPENAI_BASE_URL")
+ or get_secret("OPENAI_API_BASE")
+ or "https://api.openai.com/v1"
+ )
+ # set API KEY
+ api_key = (
+ api_key
+ or litellm.api_key # for deepinfra/perplexity/anyscale/friendliai we check in get_llm_provider and pass in the api key from there
+ or litellm.openai_key
+ or get_secret("OPENAI_API_KEY")
+ )
+
+ headers = headers or litellm.headers
+
+ if extra_headers is not None:
+ optional_params["extra_headers"] = extra_headers
+ return base_llm_aiohttp_handler.completion(
+ model=model,
+ messages=messages,
+ headers=headers,
+ model_response=model_response,
+ api_key=api_key,
+ api_base=api_base,
+ acompletion=acompletion,
+ logging_obj=logging,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ timeout=timeout,
+ client=client,
+ custom_llm_provider=custom_llm_provider,
+ encoding=_get_encoding(),
+ stream=stream,
+ )
+
+
+def _complete_cometapi(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ provider_config = ctx.provider_config
+ shared_session = ctx.shared_session
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ api_key = (
+ api_key
+ or litellm.cometapi_key
+ or get_secret_str("COMETAPI_KEY")
+ or litellm.api_key
+ )
+
+ api_base = (
+ api_base
+ or litellm.api_base
+ or get_secret_str("COMETAPI_API_BASE")
+ or "https://api.cometapi.com/v1"
+ )
+
+ ## COMPLETION CALL
+ response = base_llm_http_handler.completion(
+ model=model,
+ messages=messages,
+ headers=headers,
+ model_response=model_response,
+ api_key=api_key,
+ api_base=api_base,
+ acompletion=acompletion,
+ logging_obj=logging,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ shared_session=shared_session,
+ timeout=timeout,
+ client=client,
+ custom_llm_provider=custom_llm_provider,
+ encoding=_get_encoding(),
+ stream=stream,
+ provider_config=provider_config,
+ )
+
+ ## LOGGING
+ logging.post_call(input=messages, api_key=api_key, original_response=response)
+
+ return response
+
+
+def _complete_minimax(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ provider_config = ctx.provider_config
+ shared_session = ctx.shared_session
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ api_key = api_key or get_secret_str("MINIMAX_API_KEY") or litellm.api_key
+
+ api_base = (
+ api_base
+ or litellm.api_base
+ or get_secret_str("MINIMAX_API_BASE")
+ or "https://api.minimax.io/v1"
+ )
+
+ response = base_llm_http_handler.completion(
+ model=model,
+ messages=messages,
+ api_base=api_base,
+ custom_llm_provider=custom_llm_provider,
+ model_response=model_response,
+ encoding=_get_encoding(),
+ logging_obj=logging,
+ optional_params=optional_params,
+ timeout=timeout,
+ litellm_params=litellm_params,
+ shared_session=shared_session,
+ acompletion=acompletion,
+ stream=stream,
+ api_key=api_key,
+ headers=headers,
+ client=client,
+ provider_config=provider_config,
+ )
+ logging.post_call(input=messages, api_key=api_key, original_response=response)
+
+ return response
+
+
+def _complete_hosted_vllm(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ provider_config = ctx.provider_config
+ shared_session = ctx.shared_session
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ api_base = api_base or litellm.api_base or get_secret_str("HOSTED_VLLM_API_BASE")
+
+ response = base_llm_http_handler.completion(
+ model=model,
+ messages=messages,
+ api_base=api_base,
+ custom_llm_provider=custom_llm_provider,
+ model_response=model_response,
+ encoding=_get_encoding(),
+ logging_obj=logging,
+ optional_params=optional_params,
+ timeout=timeout,
+ litellm_params=litellm_params,
+ shared_session=shared_session,
+ acompletion=acompletion,
+ stream=stream,
+ api_key=api_key,
+ headers=headers,
+ client=client,
+ provider_config=provider_config,
+ )
+ logging.post_call(input=messages, api_key=api_key, original_response=response)
+
+ return response
+
+
+def _complete_custom_openai(
+ ctx: _CompletionDispatchContext,
+) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ custom_prompt_dict = ctx.custom_prompt_dict
+ extra_headers = ctx.extra_headers
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logger_fn = ctx.logger_fn
+ logging = ctx.logging
+ messages = ctx.messages
+ metadata = ctx.metadata
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ organization = ctx.organization
+ provider_config = ctx.provider_config
+ shared_session = ctx.shared_session
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ api_base = (
+ api_base # for deepinfra/perplexity/anyscale/groq/friendliai we check in get_llm_provider and pass in the api base from there
+ or litellm.api_base
+ or get_secret("OPENAI_BASE_URL")
+ or get_secret("OPENAI_API_BASE")
+ or "https://api.openai.com/v1"
+ )
+ organization = (
+ organization
+ or litellm.organization
+ or get_secret("OPENAI_ORGANIZATION")
+ or None # default - https://github.com/openai/openai-python/blob/284c1799070c723c6a553337134148a7ab088dd8/openai/util.py#L105
+ )
+ openai.organization = organization
+ # set API KEY
+ api_key = (
+ api_key
+ or litellm.api_key # for deepinfra/perplexity/anyscale/friendliai we check in get_llm_provider and pass in the api key from there
+ or litellm.openai_key
+ or get_secret("OPENAI_API_KEY")
+ )
+
+ headers = headers or litellm.headers
+
+ # Add GitHub Copilot headers (same as /responses endpoint does)
+ if custom_llm_provider == "github_copilot":
+ from litellm.llms.github_copilot.authenticator import Authenticator
+ from litellm.llms.github_copilot.common_utils import (
+ get_copilot_default_headers,
+ )
+
+ copilot_auth = Authenticator()
+ copilot_api_key = copilot_auth.get_api_key()
+ copilot_headers = get_copilot_default_headers(copilot_api_key)
+ if extra_headers:
+ copilot_headers.update(extra_headers)
+ extra_headers = copilot_headers
+
+ if extra_headers is not None:
+ optional_params["extra_headers"] = extra_headers
+
+ if (
+ litellm.enable_preview_features and metadata is not None
+ ): # [PREVIEW] allow metadata to be passed to OPENAI
+ openai_metadata = get_requester_metadata(metadata)
+ if openai_metadata is not None:
+ optional_params["metadata"] = openai_metadata
+
+ ## LOAD CONFIG - if set
+ config = litellm.OpenAIConfig.get_config()
+ for k, v in config.items():
+ if (
+ k not in optional_params
+ ): # completion(top_k=3) > openai_config(top_k=3) <- allows for dynamic variables to be passed in
+ optional_params[k] = v
+
+ ## COMPLETION CALL
+ use_base_llm_http_handler = get_secret_bool(
+ "EXPERIMENTAL_OPENAI_BASE_LLM_HTTP_HANDLER"
+ )
+
+ try:
+ if use_base_llm_http_handler:
+ response = base_llm_http_handler.completion(
+ model=model,
+ messages=messages,
+ api_base=api_base,
+ custom_llm_provider=custom_llm_provider,
+ model_response=model_response,
+ encoding=_get_encoding(),
+ logging_obj=logging,
+ optional_params=optional_params,
+ timeout=timeout,
+ litellm_params=litellm_params,
+ shared_session=shared_session,
+ acompletion=acompletion,
+ stream=stream,
+ api_key=api_key,
+ headers=headers,
+ client=client,
+ provider_config=provider_config,
+ )
+ else:
+ response = openai_chat_completions.completion(
+ model=model,
+ messages=messages,
+ headers=headers,
+ model_response=model_response,
+ print_verbose=print_verbose,
+ api_key=api_key,
+ api_base=api_base,
+ acompletion=acompletion,
+ logging_obj=logging,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ logger_fn=logger_fn,
+ timeout=timeout, # type: ignore
+ custom_prompt_dict=custom_prompt_dict,
+ client=client, # pass AsyncOpenAI, OpenAI client
+ organization=organization,
+ custom_llm_provider=custom_llm_provider,
+ shared_session=shared_session,
+ )
+ except Exception as e:
+ ## LOGGING - log the original exception returned
+ logging.post_call(
+ input=messages,
+ api_key=api_key,
+ original_response=str(e),
+ additional_args={"headers": headers},
+ )
+ raise e
+
+ if optional_params.get("stream", False):
+ ## LOGGING
+ logging.post_call(
+ input=messages,
+ api_key=api_key,
+ original_response=response,
+ additional_args={"headers": headers},
+ )
+
+ return response # pyright: ignore[reportReturnType] # provider SDK return type is broader than the dispatch contract
+
+
+def _complete_mistral(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ provider_config = ctx.provider_config
+ shared_session = ctx.shared_session
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ api_key = api_key or litellm.api_key or get_secret("MISTRAL_API_KEY")
+ api_base = (
+ api_base
+ or litellm.api_base
+ or get_secret("MISTRAL_API_BASE")
+ or "https://api.mistral.ai/v1"
+ )
+
+ return base_llm_http_handler.completion(
+ model=model,
+ messages=messages,
+ api_base=api_base,
+ custom_llm_provider=custom_llm_provider,
+ model_response=model_response,
+ encoding=_get_encoding(),
+ logging_obj=logging,
+ optional_params=optional_params,
+ timeout=timeout,
+ litellm_params=litellm_params,
+ shared_session=shared_session,
+ acompletion=acompletion,
+ stream=stream,
+ api_key=api_key,
+ headers=headers,
+ client=client,
+ provider_config=provider_config,
+ )
+
+
+def _complete_replicate(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ custom_prompt_dict = ctx.custom_prompt_dict
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logger_fn = ctx.logger_fn
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+
+ replicate_key = (
+ api_key
+ or litellm.replicate_key
+ or litellm.api_key
+ or get_secret("REPLICATE_API_KEY")
+ or get_secret("REPLICATE_API_TOKEN")
+ )
+
+ api_base = (
+ api_base
+ or litellm.api_base
+ or get_secret("REPLICATE_API_BASE")
+ or "https://api.replicate.com/v1"
+ )
+
+ custom_prompt_dict = custom_prompt_dict or litellm.custom_prompt_dict
+
+ model_response = replicate_chat_completion( # type: ignore
+ model=model,
+ messages=messages,
+ api_base=api_base,
+ model_response=model_response,
+ print_verbose=print_verbose,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ logger_fn=logger_fn,
+ encoding=_get_encoding(), # for calculating input/output tokens
+ api_key=replicate_key,
+ logging_obj=logging,
+ custom_prompt_dict=custom_prompt_dict,
+ acompletion=acompletion,
+ headers=headers,
+ )
+
+ if optional_params.get("stream", False) is True:
+ ## LOGGING
+ logging.post_call(
+ input=messages,
+ api_key=replicate_key,
+ original_response=model_response,
+ )
+
+ return model_response
+
+
+def _complete_anthropic_text(
+ ctx: _CompletionDispatchContext,
+) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ custom_prompt_dict = ctx.custom_prompt_dict
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ shared_session = ctx.shared_session
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ api_key = (
+ api_key
+ or litellm.anthropic_key
+ or litellm.api_key
+ or os.environ.get("ANTHROPIC_API_KEY")
+ )
+ custom_prompt_dict = custom_prompt_dict or litellm.custom_prompt_dict
+ api_base = cast(
+ Optional[str],
+ api_base
+ or litellm.api_base
+ or get_secret("ANTHROPIC_API_BASE")
+ or get_secret("ANTHROPIC_BASE_URL")
+ or "https://api.anthropic.com/v1/complete",
+ )
+
+ # Check if we should disable automatic URL suffix appending
+ disable_url_suffix = get_secret_bool("LITELLM_ANTHROPIC_DISABLE_URL_SUFFIX")
+ if (
+ api_base is not None
+ and not disable_url_suffix
+ and not api_base.endswith("/v1/complete")
+ ):
+ api_base += "/v1/complete"
+ elif disable_url_suffix:
+ verbose_logger.debug(
+ "LITELLM_ANTHROPIC_DISABLE_URL_SUFFIX is set, skipping /v1/complete suffix"
+ )
+
+ return base_llm_http_handler.completion(
+ model=model,
+ stream=stream,
+ messages=messages,
+ acompletion=acompletion,
+ api_base=api_base,
+ model_response=model_response,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ shared_session=shared_session,
+ custom_llm_provider="anthropic_text",
+ timeout=timeout,
+ headers=headers,
+ encoding=_get_encoding(),
+ api_key=api_key,
+ logging_obj=logging, # model call logging done inside the class as we make need to modify I/O to fit aleph alpha's requirements
+ )
+
+
+def _complete_anthropic(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ custom_prompt_dict = ctx.custom_prompt_dict
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logger_fn = ctx.logger_fn
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ timeout = ctx.timeout
+
+ api_key = (
+ api_key
+ or litellm.anthropic_key
+ or litellm.api_key
+ or os.environ.get("ANTHROPIC_API_KEY")
+ )
+ custom_prompt_dict = custom_prompt_dict or litellm.custom_prompt_dict
+ # call /messages
+ # default route for all anthropic models
+ api_base = cast(
+ Optional[str],
+ api_base
+ or litellm.api_base
+ or get_secret("ANTHROPIC_API_BASE")
+ or get_secret("ANTHROPIC_BASE_URL")
+ or "https://api.anthropic.com/v1/messages",
+ )
+
+ # Check if we should disable automatic URL suffix appending
+ disable_url_suffix = get_secret_bool("LITELLM_ANTHROPIC_DISABLE_URL_SUFFIX")
+ if (
+ api_base is not None
+ and not disable_url_suffix
+ and not api_base.endswith("/v1/messages")
+ ):
+ api_base += "/v1/messages"
+ elif disable_url_suffix:
+ verbose_logger.debug(
+ "LITELLM_ANTHROPIC_DISABLE_URL_SUFFIX is set, skipping /v1/messages suffix"
+ )
+
+ response = anthropic_chat_completions.completion(
+ model=model,
+ messages=messages,
+ api_base=api_base,
+ acompletion=acompletion,
+ custom_prompt_dict=litellm.custom_prompt_dict,
+ model_response=model_response,
+ print_verbose=print_verbose,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ logger_fn=logger_fn,
+ encoding=_get_encoding(), # for calculating input/output tokens
+ api_key=api_key,
+ logging_obj=logging,
+ headers=headers,
+ timeout=timeout,
+ client=client,
+ custom_llm_provider=custom_llm_provider,
+ )
+ if optional_params.get("stream", False) or acompletion is True:
+ ## LOGGING
+ logging.post_call(
+ input=messages,
+ api_key=api_key,
+ original_response=response,
+ )
+ return response
+
+
+def _complete_nlp_cloud(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ litellm_params = ctx.litellm_params
+ logger_fn = ctx.logger_fn
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+
+ nlp_cloud_key = (
+ api_key
+ or litellm.nlp_cloud_key
+ or get_secret("NLP_CLOUD_API_KEY")
+ or litellm.api_key
+ )
+
+ api_base = (
+ api_base
+ or litellm.api_base
+ or get_secret("NLP_CLOUD_API_BASE")
+ or "https://api.nlpcloud.io/v1/gpu/"
+ )
+
+ response = nlp_cloud_chat_completion(
+ model=model,
+ messages=messages,
+ api_base=api_base,
+ model_response=model_response,
+ print_verbose=print_verbose,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ logger_fn=logger_fn,
+ encoding=_get_encoding(),
+ api_key=nlp_cloud_key,
+ logging_obj=logging,
+ )
+
+ if "stream" in optional_params and optional_params["stream"] is True:
+ # don't try to access stream object,
+ response = CustomStreamWrapper(
+ response,
+ model,
+ custom_llm_provider="nlp_cloud",
+ logging_obj=logging,
+ )
+
+ if optional_params.get("stream", False) or acompletion is True:
+ ## LOGGING
+ logging.post_call(
+ input=messages,
+ api_key=api_key,
+ original_response=response,
+ )
+
+ return response # pyright: ignore[reportReturnType] # provider SDK return type is broader than the dispatch contract
+
+
+def _complete_aleph_alpha(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ litellm_params = ctx.litellm_params
+ logger_fn = ctx.logger_fn
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+
+ aleph_alpha_key = (
+ api_key
+ or litellm.aleph_alpha_key
+ or get_secret("ALEPH_ALPHA_API_KEY")
+ or get_secret("ALEPHALPHA_API_KEY")
+ or litellm.api_key
+ )
+
+ api_base = (
+ api_base
+ or litellm.api_base
+ or get_secret("ALEPH_ALPHA_API_BASE")
+ or "https://api.aleph-alpha.com/complete"
+ )
+
+ model_response = aleph_alpha.completion(
+ model=model,
+ messages=messages,
+ api_base=api_base,
+ model_response=model_response,
+ print_verbose=print_verbose,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ logger_fn=logger_fn,
+ encoding=_get_encoding(),
+ default_max_tokens_to_sample=litellm.max_tokens,
+ api_key=aleph_alpha_key,
+ logging_obj=logging, # model call logging done inside the class as we make need to modify I/O to fit aleph alpha's requirements
+ )
+
+ if "stream" in optional_params and optional_params["stream"] is True:
+ # don't try to access stream object,
+ return CustomStreamWrapper(
+ model_response,
+ model,
+ custom_llm_provider="aleph_alpha",
+ logging_obj=logging,
+ )
+ return model_response # pyright: ignore[reportReturnType] # provider SDK return type is broader than the dispatch contract
+
+
+def _complete_cohere_chat(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ extra_headers = ctx.extra_headers
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ provider_config = ctx.provider_config
+ shared_session = ctx.shared_session
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ cohere_key = (
+ api_key
+ or litellm.cohere_key
+ or get_secret_str("COHERE_API_KEY")
+ or get_secret_str("CO_API_KEY")
+ or litellm.api_key
+ )
+
+ cohere_route = CohereModelInfo.get_cohere_route(model)
+ verbose_logger.debug(f"Cohere route: {cohere_route}")
+ # Set API base based on route
+ if cohere_route == "v2":
+ api_base = (
+ api_base
+ or litellm.api_base
+ or get_secret_str("COHERE_API_BASE")
+ or "https://api.cohere.com/v2/chat"
+ )
+ # Remove v2/ prefix from model name for the actual API call
+ if "v2/" in model:
+ model = model.replace("v2/", "")
+ else:
+ api_base = (
+ api_base
+ or litellm.api_base
+ or get_secret_str("COHERE_API_BASE")
+ or "https://api.cohere.ai/v1/chat"
+ )
+
+ headers = headers or litellm.headers or {}
+ if headers is None:
+ headers = {}
+
+ if extra_headers is not None:
+ headers.update(extra_headers)
+
+ verbose_logger.debug(f"Model: {model}, API Base: {api_base}")
+ verbose_logger.debug(f"Provider Config: {provider_config}")
+ return base_llm_http_handler.completion(
+ model=model,
+ stream=stream,
+ messages=messages,
+ acompletion=acompletion,
+ api_base=api_base,
+ model_response=model_response,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ shared_session=shared_session,
+ custom_llm_provider="cohere_chat",
+ timeout=timeout,
+ headers=headers,
+ encoding=_get_encoding(),
+ api_key=cohere_key,
+ provider_config=provider_config,
+ logging_obj=logging, # model call logging done inside the class as we make need to modify I/O to fit aleph alpha's requirements
+ )
+
+
+def _complete_maritalk(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ custom_prompt_dict = ctx.custom_prompt_dict
+ litellm_params = ctx.litellm_params
+ logger_fn = ctx.logger_fn
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+
+ maritalk_key = (
+ api_key
+ or litellm.maritalk_key
+ or get_secret("MARITALK_API_KEY")
+ or litellm.api_key
+ )
+
+ api_base = (
+ api_base
+ or litellm.api_base
+ or get_secret("MARITALK_API_BASE")
+ or "https://chat.maritaca.ai/api"
+ )
+
+ return openai_like_chat_completion.completion(
+ model=model,
+ messages=messages,
+ api_base=api_base,
+ model_response=model_response,
+ print_verbose=print_verbose,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ logger_fn=logger_fn,
+ encoding=_get_encoding(),
+ api_key=maritalk_key,
+ logging_obj=logging,
+ custom_llm_provider="maritalk",
+ custom_prompt_dict=custom_prompt_dict,
+ )
+
+
+def _complete_amazon_nova(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ custom_llm_provider = ctx.custom_llm_provider
+ custom_prompt_dict = ctx.custom_prompt_dict
+ litellm_params = ctx.litellm_params
+ logger_fn = ctx.logger_fn
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ timeout = ctx.timeout
+
+ api_key = (
+ api_key
+ or litellm.amazon_nova_api_key
+ or get_secret_str("AMAZON_NOVA_API_KEY")
+ or litellm.api_key
+ )
+ api_base = (
+ api_base
+ or litellm.api_base
+ or get_secret_str("AMAZON_NOVA_API_BASE")
+ or "https://api.nova.amazon.com/v1"
+ )
+ return openai_like_chat_completion.completion(
+ model=model,
+ messages=messages,
+ api_base=api_base,
+ model_response=model_response,
+ print_verbose=print_verbose,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ logger_fn=logger_fn,
+ encoding=_get_encoding(),
+ api_key=api_key,
+ logging_obj=logging,
+ timeout=timeout,
+ custom_llm_provider=custom_llm_provider,
+ custom_prompt_dict=custom_prompt_dict,
+ )
+
+
+def _complete_huggingface(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ huggingface_key = (
+ api_key
+ or litellm.huggingface_key
+ or os.environ.get("HF_TOKEN")
+ or os.environ.get("HUGGINGFACE_API_KEY")
+ or litellm.api_key
+ )
+ hf_headers = headers or litellm.headers
+ return base_llm_http_handler.completion(
+ model=model,
+ messages=messages,
+ headers=hf_headers,
+ model_response=model_response,
+ api_key=huggingface_key,
+ api_base=api_base,
+ acompletion=acompletion,
+ logging_obj=logging,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ timeout=timeout, # type: ignore
+ client=client,
+ custom_llm_provider=custom_llm_provider,
+ encoding=_get_encoding(),
+ stream=stream,
+ )
+
+
+def _complete_oci(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ return base_llm_http_handler.completion(
+ model=model,
+ messages=messages,
+ headers=headers,
+ model_response=model_response,
+ api_key=api_key,
+ api_base=api_base,
+ acompletion=acompletion,
+ logging_obj=logging,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ timeout=timeout, # type: ignore
+ client=client,
+ custom_llm_provider=custom_llm_provider,
+ encoding=_get_encoding(),
+ stream=stream,
+ )
+
+
+def _complete_compactifai(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ provider_config = ctx.provider_config
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ api_key = api_key or get_secret_str("COMPACTIFAI_API_KEY") or litellm.api_key
+
+ api_base = api_base or "https://api.compactif.ai/v1"
+
+ ## COMPLETION CALL
+ return base_llm_http_handler.completion(
+ model=model,
+ messages=messages,
+ headers=headers,
+ model_response=model_response,
+ api_key=api_key,
+ api_base=api_base,
+ acompletion=acompletion,
+ logging_obj=logging,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ timeout=timeout,
+ client=client,
+ custom_llm_provider=custom_llm_provider,
+ encoding=_get_encoding(),
+ stream=stream,
+ provider_config=provider_config,
+ )
+
+
+def _complete_oobabooga(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ api_base = ctx.api_base
+ litellm_params = ctx.litellm_params
+ logger_fn = ctx.logger_fn
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+
+ model_response = oobabooga.completion(
+ model=model,
+ messages=messages,
+ model_response=model_response,
+ api_base=api_base, # type: ignore
+ print_verbose=print_verbose,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ api_key=None,
+ logger_fn=logger_fn,
+ encoding=_get_encoding(),
+ logging_obj=logging,
+ )
+ if "stream" in optional_params and optional_params["stream"] is True:
+ # don't try to access stream object,
+ return CustomStreamWrapper(
+ model_response,
+ model,
+ custom_llm_provider="oobabooga",
+ logging_obj=logging,
+ )
+ return model_response # pyright: ignore[reportReturnType] # provider SDK return type is broader than the dispatch contract
+
+
+def _complete_databricks(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ api_base = (
+ api_base # for databricks we check in get_llm_provider and pass in the api base from there
+ or litellm.api_base
+ or os.getenv("DATABRICKS_API_BASE")
+ )
+
+ # set API KEY
+ api_key = (
+ api_key
+ or litellm.api_key # for databricks we check in get_llm_provider and pass in the api key from there
+ or litellm.databricks_key
+ or get_secret("DATABRICKS_API_KEY")
+ )
+
+ headers = headers or litellm.headers
+
+ ## COMPLETION CALL
+ try:
+ response = base_llm_http_handler.completion(
+ model=model,
+ stream=stream,
+ messages=messages,
+ acompletion=acompletion,
+ api_base=api_base,
+ model_response=model_response,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ custom_llm_provider="databricks",
+ timeout=timeout,
+ headers=headers,
+ encoding=_get_encoding(),
+ api_key=api_key,
+ logging_obj=logging, # model call logging done inside the class as we make need to modify I/O to fit aleph alpha's requirements
+ client=client,
+ )
+ except Exception as e:
+ ## LOGGING - log the original exception returned
+ logging.post_call(
+ input=messages,
+ api_key=api_key,
+ original_response=str(e),
+ additional_args={"headers": headers},
+ )
+ raise e
+
+ if optional_params.get("stream", False):
+ ## LOGGING
+ logging.post_call(
+ input=messages,
+ api_key=api_key,
+ original_response=response,
+ additional_args={"headers": headers},
+ )
+
+ return response
+
+
+def _complete_datarobot(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ provider_config = ctx.provider_config
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ return base_llm_http_handler.completion(
+ model=model,
+ messages=messages,
+ headers=headers,
+ model_response=model_response,
+ api_key=api_key,
+ api_base=api_base,
+ acompletion=acompletion,
+ logging_obj=logging,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ timeout=timeout, # type: ignore
+ client=client,
+ custom_llm_provider=custom_llm_provider,
+ encoding=_get_encoding(),
+ stream=stream,
+ provider_config=provider_config,
+ )
+
+
+def _complete_openrouter(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ shared_session = ctx.shared_session
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ api_base = (
+ api_base
+ or litellm.api_base
+ or get_secret_str("OPENROUTER_API_BASE")
+ or "https://openrouter.ai/api/v1"
+ )
+
+ api_key = (
+ api_key
+ or litellm.api_key
+ or litellm.openrouter_key
+ or get_secret_str("OPENROUTER_API_KEY")
+ or get_secret_str("OR_API_KEY")
+ )
+
+ openrouter_site_url = get_secret("OR_SITE_URL") or "https://litellm.ai"
+ openrouter_app_name = get_secret("OR_APP_NAME") or "liteLLM"
+
+ openrouter_headers = {
+ "HTTP-Referer": openrouter_site_url,
+ "X-Title": openrouter_app_name,
+ }
+
+ _headers = headers or litellm.headers
+ if _headers:
+ openrouter_headers.update(_headers)
+
+ headers = openrouter_headers
+
+ ## Load Config
+ config = litellm.OpenrouterConfig.get_config()
+ for k, v in config.items():
+ if k == "extra_body":
+ # we use openai 'extra_body' to pass openrouter specific params - transforms, route, models
+ if "extra_body" in optional_params:
+ optional_params[k].update(v)
+ else:
+ optional_params[k] = v
+ elif k not in optional_params:
+ optional_params[k] = v
+
+ ## COMPLETION CALL
+ response = base_llm_http_handler.completion(
+ model=model,
+ stream=stream,
+ messages=messages,
+ acompletion=acompletion,
+ api_base=api_base,
+ model_response=model_response,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ shared_session=shared_session,
+ custom_llm_provider="openrouter",
+ timeout=timeout,
+ headers=headers,
+ encoding=_get_encoding(),
+ api_key=api_key,
+ logging_obj=logging, # model call logging done inside the class as we make need to modify I/O to fit aleph alpha's requirements
+ client=client,
+ )
+ ## LOGGING
+ logging.post_call(
+ input=messages, api_key=openai.api_key, original_response=response
+ )
+
+ return response
+
+
+def _complete_vercel_ai_gateway(
+ ctx: _CompletionDispatchContext,
+) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ shared_session = ctx.shared_session
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ api_base = (
+ api_base
+ or litellm.api_base
+ or get_secret_str("VERCEL_AI_GATEWAY_API_BASE")
+ or "https://ai-gateway.vercel.sh/v1"
+ )
+
+ api_key = api_key or litellm.api_key or get_secret("VERCEL_AI_GATEWAY_API_KEY")
+
+ vercel_site_url = get_secret("VERCEL_SITE_URL") or "https://litellm.ai"
+ vercel_app_name = get_secret("VERCEL_APP_NAME") or "liteLLM"
+
+ vercel_headers = {
+ "http-referer": vercel_site_url,
+ "x-title": vercel_app_name,
+ }
+
+ _headers = headers or litellm.headers
+ if _headers:
+ vercel_headers.update(_headers)
+
+ headers = vercel_headers
+
+ ## Load Config
+ config = litellm.VercelAIGatewayConfig.get_config()
+ for k, v in config.items():
+ if k == "extra_body":
+ # we use openai 'extra_body' to pass vercel specific params - providerOptions
+ if "extra_body" in optional_params:
+ optional_params[k].update(v)
+ else:
+ optional_params[k] = v
+ elif k not in optional_params:
+ optional_params[k] = v
+
+ ## COMPLETION CALL
+ response = base_llm_http_handler.completion(
+ model=model,
+ stream=stream,
+ messages=messages,
+ acompletion=acompletion,
+ api_base=api_base,
+ model_response=model_response,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ shared_session=shared_session,
+ custom_llm_provider="vercel_ai_gateway",
+ timeout=timeout,
+ headers=headers,
+ encoding=_get_encoding(),
+ api_key=api_key,
+ logging_obj=logging, # model call logging done inside the class as we make need to modify I/O to fit aleph alpha's requirements
+ client=client,
+ )
+ ## LOGGING
+ logging.post_call(
+ input=messages, api_key=openai.api_key, original_response=response
+ )
+
+ return response
+
+
+def _complete_vertex_ai_beta(
+ ctx: _CompletionDispatchContext,
+) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logger_fn = ctx.logger_fn
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ timeout = ctx.timeout
+
+ vertex_ai_project = (
+ optional_params.pop("vertex_project", None)
+ or optional_params.pop("vertex_ai_project", None)
+ or litellm.vertex_project
+ or get_secret("VERTEXAI_PROJECT")
+ )
+ vertex_ai_location = (
+ optional_params.pop("vertex_location", None)
+ or optional_params.pop("vertex_ai_location", None)
+ or litellm.vertex_location
+ or get_secret("VERTEXAI_LOCATION")
+ )
+ vertex_credentials = (
+ optional_params.pop("vertex_credentials", None)
+ or optional_params.pop("vertex_ai_credentials", None)
+ or get_secret("VERTEXAI_CREDENTIALS")
+ )
+
+ gemini_api_key = (
+ api_key
+ or get_api_key_from_env()
+ or get_secret("PALM_API_KEY") # older palm api key should also work
+ or litellm.api_key
+ )
+
+ api_base = api_base or litellm.api_base or get_secret("GEMINI_API_BASE")
+ new_params = safe_deep_copy(optional_params or {})
+ return vertex_chat_completion.completion( # type: ignore
+ model=model,
+ messages=messages,
+ model_response=model_response,
+ print_verbose=print_verbose,
+ optional_params=new_params,
+ litellm_params=litellm_params, # type: ignore
+ logger_fn=logger_fn,
+ encoding=_get_encoding(),
+ vertex_location=vertex_ai_location,
+ vertex_project=vertex_ai_project,
+ vertex_credentials=vertex_credentials,
+ gemini_api_key=gemini_api_key,
+ logging_obj=logging,
+ acompletion=acompletion,
+ timeout=timeout,
+ custom_llm_provider=custom_llm_provider, # type: ignore
+ client=client,
+ api_base=api_base,
+ extra_headers=headers,
+ )
+
+
+def _complete_vertex_ai(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ custom_prompt_dict = ctx.custom_prompt_dict
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logger_fn = ctx.logger_fn
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ vertex_ai_project = (
+ optional_params.pop("vertex_project", None)
+ or optional_params.pop("vertex_ai_project", None)
+ or litellm.vertex_project
+ or get_secret("VERTEXAI_PROJECT")
+ )
+ vertex_ai_location = (
+ optional_params.pop("vertex_location", None)
+ or optional_params.pop("vertex_ai_location", None)
+ or litellm.vertex_location
+ or get_secret("VERTEXAI_LOCATION")
+ )
+ vertex_credentials = (
+ optional_params.pop("vertex_credentials", None)
+ or optional_params.pop("vertex_ai_credentials", None)
+ or get_secret("VERTEXAI_CREDENTIALS")
+ )
+
+ api_base = api_base or litellm.api_base or get_secret("VERTEXAI_API_BASE")
+
+ new_params = safe_deep_copy(optional_params or {})
+ model_route = get_vertex_ai_model_route(model=model, litellm_params=litellm_params)
+
+ if model_route == VertexAIModelRoute.PARTNER_MODELS:
+ model_response = vertex_partner_models_chat_completion.completion(
+ model=model,
+ messages=messages,
+ model_response=model_response,
+ print_verbose=print_verbose,
+ optional_params=new_params,
+ litellm_params=litellm_params, # type: ignore
+ logger_fn=logger_fn,
+ encoding=_get_encoding(),
+ api_base=api_base,
+ vertex_location=vertex_ai_location,
+ vertex_project=vertex_ai_project,
+ vertex_credentials=vertex_credentials,
+ logging_obj=logging,
+ acompletion=acompletion,
+ headers=headers,
+ custom_prompt_dict=custom_prompt_dict,
+ timeout=timeout,
+ client=client,
+ )
+ elif model_route == VertexAIModelRoute.GEMINI:
+ model_response = vertex_chat_completion.completion( # type: ignore
+ model=model,
+ messages=messages,
+ model_response=model_response,
+ print_verbose=print_verbose,
+ optional_params=new_params,
+ litellm_params=litellm_params, # type: ignore
+ logger_fn=logger_fn,
+ encoding=_get_encoding(),
+ vertex_location=vertex_ai_location,
+ vertex_project=vertex_ai_project,
+ vertex_credentials=vertex_credentials,
+ gemini_api_key=None,
+ logging_obj=logging,
+ acompletion=acompletion,
+ timeout=timeout,
+ custom_llm_provider=custom_llm_provider, # type: ignore
+ client=client,
+ api_base=api_base,
+ extra_headers=headers,
+ )
+ elif model_route == VertexAIModelRoute.GEMMA:
+ # Vertex Gemma Models with custom prediction endpoint
+ model_response = vertex_gemma_chat_completion.completion(
+ model=model,
+ messages=messages,
+ model_response=model_response,
+ print_verbose=print_verbose,
+ optional_params=new_params,
+ litellm_params=litellm_params, # type: ignore
+ logger_fn=logger_fn,
+ encoding=_get_encoding(),
+ api_base=api_base,
+ vertex_location=vertex_ai_location,
+ vertex_project=vertex_ai_project,
+ vertex_credentials=vertex_credentials,
+ logging_obj=logging,
+ acompletion=acompletion,
+ headers=headers,
+ custom_prompt_dict=custom_prompt_dict,
+ timeout=timeout,
+ client=client,
+ )
+ elif model_route == VertexAIModelRoute.MODEL_GARDEN:
+ # Vertex Model Garden - OpenAI compatible models
+ model_response = vertex_model_garden_chat_completion.completion(
+ model=model,
+ messages=messages,
+ model_response=model_response,
+ print_verbose=print_verbose,
+ optional_params=new_params,
+ litellm_params=litellm_params, # type: ignore
+ logger_fn=logger_fn,
+ encoding=_get_encoding(),
+ api_base=api_base,
+ vertex_location=vertex_ai_location,
+ vertex_project=vertex_ai_project,
+ vertex_credentials=vertex_credentials,
+ logging_obj=logging,
+ acompletion=acompletion,
+ headers=headers,
+ custom_prompt_dict=custom_prompt_dict,
+ timeout=timeout,
+ client=client,
+ )
+ elif model_route == VertexAIModelRoute.AGENT_ENGINE:
+ # Vertex AI Agent Engine (Reasoning Engines)
+ from litellm.llms.vertex_ai.agent_engine.transformation import (
+ VertexAgentEngineConfig,
+ )
+
+ vertex_agent_engine_config = VertexAgentEngineConfig()
+
+ # Update litellm_params with vertex credentials
+ litellm_params["vertex_project"] = vertex_ai_project
+ litellm_params["vertex_location"] = vertex_ai_location
+ litellm_params["vertex_credentials"] = vertex_credentials
+
+ model_response = base_llm_http_handler.completion(
+ model=model,
+ stream=stream,
+ messages=messages,
+ model_response=model_response,
+ optional_params=new_params,
+ litellm_params=litellm_params, # type: ignore
+ encoding=_get_encoding(),
+ api_key=None,
+ api_base=api_base,
+ logging_obj=logging,
+ acompletion=acompletion,
+ timeout=timeout,
+ client=client,
+ custom_llm_provider="vertex_ai",
+ provider_config=vertex_agent_engine_config,
+ headers=headers or {},
+ )
+ else: # VertexAIModelRoute.NON_GEMINI
+ model_response = vertex_ai_non_gemini.completion(
+ model=model,
+ messages=messages,
+ model_response=model_response,
+ print_verbose=print_verbose,
+ optional_params=new_params,
+ litellm_params=litellm_params,
+ logger_fn=logger_fn,
+ encoding=_get_encoding(),
+ vertex_location=vertex_ai_location,
+ vertex_project=vertex_ai_project,
+ vertex_credentials=vertex_credentials,
+ logging_obj=logging,
+ acompletion=acompletion,
+ )
+
+ if (
+ "stream" in optional_params
+ and optional_params["stream"] is True
+ and acompletion is False
+ ):
+ return CustomStreamWrapper(
+ model_response,
+ model,
+ custom_llm_provider="vertex_ai",
+ logging_obj=logging,
+ )
+ return model_response # pyright: ignore[reportReturnType] # provider SDK return type is broader than the dispatch contract
+
+
+def _complete_predibase(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ custom_prompt_dict = ctx.custom_prompt_dict
+ litellm_params = ctx.litellm_params
+ logger_fn = ctx.logger_fn
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ timeout = ctx.timeout
+
+ tenant_id = (
+ optional_params.pop("tenant_id", None)
+ or optional_params.pop("predibase_tenant_id", None)
+ or litellm.predibase_tenant_id
+ or get_secret("PREDIBASE_TENANT_ID")
+ )
+
+ if tenant_id is None:
+ raise ValueError(
+ "Missing Predibase Tenant ID - Required for making the request. Set dynamically (e.g. `completion(..tenant_id=)`) or in env - `PREDIBASE_TENANT_ID`."
+ )
+
+ api_base = (
+ api_base
+ or optional_params.pop("api_base", None)
+ or optional_params.pop("base_url", None)
+ or litellm.api_base
+ or get_secret("PREDIBASE_API_BASE")
+ )
+
+ api_key = (
+ api_key
+ or litellm.api_key
+ or litellm.predibase_key
+ or get_secret("PREDIBASE_API_KEY")
+ )
+
+ _model_response = predibase_chat_completions.completion(
+ model=model,
+ messages=messages,
+ model_response=model_response,
+ print_verbose=print_verbose,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ logger_fn=logger_fn,
+ encoding=_get_encoding(),
+ logging_obj=logging,
+ acompletion=acompletion,
+ api_base=api_base,
+ custom_prompt_dict=custom_prompt_dict,
+ api_key=api_key,
+ tenant_id=tenant_id,
+ timeout=timeout,
+ )
+
+ if (
+ "stream" in optional_params
+ and optional_params["stream"] is True
+ and acompletion is False
+ ):
+ return _model_response
+ return _model_response
+
+
+def _complete_text_completion_codestral(
+ ctx: _CompletionDispatchContext,
+) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ custom_prompt_dict = ctx.custom_prompt_dict
+ litellm_params = ctx.litellm_params
+ logger_fn = ctx.logger_fn
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ optional_params = ctx.optional_params
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ api_base = (
+ api_base
+ or optional_params.pop("api_base", None)
+ or optional_params.pop("base_url", None)
+ or litellm.api_base
+ or "https://codestral.mistral.ai/v1/fim/completions"
+ )
+
+ api_key = api_key or litellm.api_key or get_secret("CODESTRAL_API_KEY")
+
+ text_completion_model_response = litellm.TextCompletionResponse(stream=stream)
+
+ _model_response = codestral_text_completions.completion( # type: ignore
+ model=model,
+ messages=messages,
+ model_response=text_completion_model_response,
+ print_verbose=print_verbose,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ logger_fn=logger_fn,
+ encoding=_get_encoding(),
+ logging_obj=logging,
+ acompletion=acompletion,
+ api_base=api_base,
+ custom_prompt_dict=custom_prompt_dict,
+ api_key=api_key,
+ timeout=timeout,
+ )
+
+ if (
+ "stream" in optional_params
+ and optional_params["stream"] is True
+ and acompletion is False
+ ):
+ return _model_response # pyright: ignore[reportReturnType] # provider SDK return type is broader than the dispatch contract
+ return _model_response # pyright: ignore[reportReturnType] # provider SDK return type is broader than the dispatch contract
+
+
+def _complete_text_completion_inception(
+ ctx: _CompletionDispatchContext,
+) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logger_fn = ctx.logger_fn
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ text_completion = ctx.text_completion
+ timeout = ctx.timeout
+
+ passed_api_base = (
+ api_base
+ or optional_params.pop("api_base", None)
+ or optional_params.pop("base_url", None)
+ )
+ api_base = (
+ passed_api_base
+ or get_secret_str("INCEPTION_API_BASE")
+ or "https://api.inceptionlabs.ai/v1"
+ )
+ # FIM is served at `/v1/fim/completions`; the OpenAI client appends
+ # `/completions`, so point it at the `/v1/fim` base.
+ api_base = api_base.rstrip("/")
+ if not api_base.endswith("/fim"):
+ api_base += "/fim"
+
+ # Don't forward the server-managed Inception key to a caller-supplied
+ # api_base; only resolve it for the default/server base, or when the
+ # caller passes their own key.
+ if passed_api_base is None or api_key:
+ api_key = (
+ api_key or litellm.inception_key or get_secret_str("INCEPTION_API_KEY")
+ )
+
+ _response = openai_text_completions.completion(
+ model=model,
+ messages=messages,
+ model_response=model_response,
+ print_verbose=print_verbose,
+ api_key=api_key, # type: ignore[arg-type]
+ custom_llm_provider="text-completion-inception",
+ api_base=api_base,
+ acompletion=acompletion,
+ client=client,
+ logging_obj=logging,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ logger_fn=logger_fn,
+ timeout=timeout, # type: ignore
+ )
+
+ if (
+ optional_params.get("stream", False) is False
+ and acompletion is False
+ and text_completion is False
+ ):
+ _response = (
+ litellm.OpenAITextCompletionConfig().convert_to_chat_model_response_object(
+ response_object=_response, model_response_object=model_response
+ )
+ )
+
+ if optional_params.get("stream", False) or acompletion is True:
+ logging.post_call(
+ input=messages,
+ api_key=api_key,
+ original_response=_response,
+ additional_args={"headers": headers},
+ )
+ return _response # pyright: ignore[reportReturnType] # provider SDK return type is broader than the dispatch contract
+
+
+def _complete_sagemaker_chat(
+ ctx: _CompletionDispatchContext,
+) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ return base_llm_http_handler.completion(
+ model=model,
+ stream=stream,
+ messages=messages,
+ acompletion=acompletion,
+ api_base=api_base,
+ model_response=model_response,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ custom_llm_provider=custom_llm_provider,
+ timeout=timeout,
+ headers=headers,
+ encoding=_get_encoding(),
+ api_key=api_key,
+ logging_obj=logging, # model call logging done inside the class as we make need to modify I/O to fit aleph alpha's requirements
+ client=client,
+ )
+
+
+def _complete_sagemaker(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ custom_prompt_dict = ctx.custom_prompt_dict
+ hf_model_name = ctx.hf_model_name
+ litellm_params = ctx.litellm_params
+ logger_fn = ctx.logger_fn
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+
+ return sagemaker_llm.completion(
+ model=model,
+ messages=messages,
+ model_response=model_response,
+ print_verbose=print_verbose,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ custom_prompt_dict=custom_prompt_dict,
+ hf_model_name=hf_model_name,
+ logger_fn=logger_fn,
+ encoding=_get_encoding(),
+ logging_obj=logging,
+ acompletion=acompletion,
+ )
+
+
+def _complete_bedrock(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_prompt_dict = ctx.custom_prompt_dict
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logger_fn = ctx.logger_fn
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ provider_config = ctx.provider_config
+ shared_session = ctx.shared_session
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ custom_prompt_dict = custom_prompt_dict or litellm.custom_prompt_dict
+
+ if "aws_bedrock_client" in optional_params:
+ verbose_logger.warning(
+ "'aws_bedrock_client' is a deprecated param. Please move to another auth method - https://docs.litellm.ai/docs/providers/bedrock#boto3---authentication."
+ )
+ # Extract credentials for legacy boto3 client and pass thru to httpx
+ aws_bedrock_client = optional_params.pop("aws_bedrock_client")
+ creds = aws_bedrock_client._get_credentials().get_frozen_credentials()
+
+ if creds.access_key:
+ optional_params["aws_access_key_id"] = creds.access_key
+ if creds.secret_key:
+ optional_params["aws_secret_access_key"] = creds.secret_key
+ if creds.token:
+ optional_params["aws_session_token"] = creds.token
+ if (
+ "aws_region_name" not in optional_params
+ or optional_params["aws_region_name"] is None
+ ):
+ optional_params["aws_region_name"] = aws_bedrock_client.meta.region_name
+
+ bedrock_route = BedrockModelInfo.get_bedrock_route(model)
+ if bedrock_route == "claude_platform":
+ provider_config = ProviderConfigManager.get_provider_chat_config(
+ model=model,
+ provider=LlmProviders.BEDROCK,
+ )
+ model = BedrockModelInfo.get_claude_platform_model(model)
+ return base_llm_http_handler.completion(
+ model=model,
+ stream=stream,
+ messages=messages,
+ acompletion=acompletion,
+ api_base=api_base,
+ model_response=model_response,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ shared_session=shared_session,
+ custom_llm_provider="bedrock",
+ timeout=timeout,
+ headers=headers,
+ encoding=_get_encoding(),
+ api_key=api_key,
+ logging_obj=logging,
+ client=client,
+ provider_config=provider_config,
+ )
+ elif bedrock_route == "converse":
+ model = model.replace("converse/", "")
+ response = bedrock_converse_chat_completion.completion(
+ model=model,
+ messages=messages,
+ custom_prompt_dict=custom_prompt_dict,
+ model_response=model_response,
+ optional_params=optional_params,
+ litellm_params=litellm_params, # type: ignore
+ logger_fn=logger_fn,
+ encoding=_get_encoding(),
+ logging_obj=logging,
+ extra_headers=headers, # Use merged headers instead of original extra_headers
+ timeout=timeout,
+ acompletion=acompletion,
+ client=client,
+ api_base=api_base,
+ api_key=api_key,
+ )
+ elif bedrock_route == "converse_like":
+ model = model.replace("converse_like/", "")
+ response = base_llm_http_handler.completion(
+ model=model,
+ stream=stream,
+ messages=messages,
+ acompletion=acompletion,
+ api_base=api_base,
+ model_response=model_response,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ custom_llm_provider="bedrock",
+ timeout=timeout,
+ headers=headers,
+ encoding=_get_encoding(),
+ api_key=api_key,
+ logging_obj=logging, # model call logging done inside the class as we make need to modify I/O to fit aleph alpha's requirements
+ client=client,
+ )
+ else:
+ response = base_llm_http_handler.completion(
+ model=model,
+ stream=stream,
+ messages=messages,
+ acompletion=acompletion,
+ api_base=api_base,
+ model_response=model_response,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ custom_llm_provider="bedrock",
+ timeout=timeout,
+ headers=headers,
+ encoding=_get_encoding(),
+ api_key=api_key,
+ logging_obj=logging,
+ client=client,
+ )
+
+ return response
+
+
+def _complete_watsonx(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_prompt_dict = ctx.custom_prompt_dict
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logger_fn = ctx.logger_fn
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ timeout = ctx.timeout
+
+ return watsonx_chat_completion.completion(
+ model=model,
+ messages=messages,
+ headers=headers,
+ model_response=model_response,
+ print_verbose=print_verbose,
+ api_key=api_key,
+ api_base=api_base,
+ acompletion=acompletion,
+ logging_obj=logging,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ logger_fn=logger_fn,
+ timeout=timeout, # type: ignore
+ custom_prompt_dict=custom_prompt_dict,
+ client=client, # pass AsyncOpenAI, OpenAI client
+ encoding=_get_encoding(),
+ custom_llm_provider="watsonx",
+ )
+
+
+def _complete_watsonx_text(
+ ctx: _CompletionDispatchContext,
+) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ shared_session = ctx.shared_session
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ api_key = (
+ api_key
+ or optional_params.pop("apikey", None)
+ or get_secret_str("WATSONX_APIKEY")
+ or get_secret_str("WATSONX_API_KEY")
+ or get_secret_str("WX_API_KEY")
+ )
+
+ api_base = (
+ api_base
+ or optional_params.pop(
+ "url",
+ optional_params.pop("api_base", optional_params.pop("base_url", None)),
+ )
+ or get_secret_str("WATSONX_API_BASE")
+ or get_secret_str("WATSONX_URL")
+ or get_secret_str("WX_URL")
+ or get_secret_str("WML_URL")
+ )
+
+ wx_credentials = optional_params.pop(
+ "wx_credentials",
+ optional_params.pop(
+ "watsonx_credentials", None
+ ), # follow {provider}_credentials, same as vertex ai
+ )
+
+ token: Optional[str] = None
+ if wx_credentials is not None:
+ api_base = wx_credentials.get("url", api_base)
+ api_key = wx_credentials.get("apikey", wx_credentials.get("api_key", api_key))
+ token = wx_credentials.get(
+ "token",
+ wx_credentials.get(
+ "watsonx_token", None
+ ), # follow format of {provider}_token, same as azure - e.g. 'azure_ad_token=..'
+ )
+
+ if token is not None:
+ optional_params["token"] = token
+
+ return base_llm_http_handler.completion(
+ model=model,
+ stream=stream,
+ messages=messages,
+ acompletion=acompletion,
+ api_base=api_base,
+ model_response=model_response,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ shared_session=shared_session,
+ custom_llm_provider="watsonx_text",
+ timeout=timeout,
+ headers=headers,
+ encoding=_get_encoding(),
+ api_key=api_key,
+ logging_obj=logging, # model call logging done inside the class as we make need to modify I/O to fit aleph alpha's requirements
+ client=client,
+ )
+
+
+def _complete_vllm(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ custom_prompt_dict = ctx.custom_prompt_dict
+ litellm_params = ctx.litellm_params
+ logger_fn = ctx.logger_fn
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+
+ custom_prompt_dict = custom_prompt_dict or litellm.custom_prompt_dict
+ model_response = vllm_handler.completion(
+ model=model,
+ messages=messages,
+ custom_prompt_dict=custom_prompt_dict,
+ model_response=model_response,
+ print_verbose=print_verbose,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ logger_fn=logger_fn,
+ encoding=_get_encoding(),
+ logging_obj=logging,
+ )
+
+ if "stream" in optional_params and optional_params["stream"] is True: ## [BETA]
+ # don't try to access stream object,
+ return CustomStreamWrapper(
+ model_response,
+ model,
+ custom_llm_provider="vllm",
+ logging_obj=logging,
+ )
+
+ ## RESPONSE OBJECT
+ return model_response
+
+
+def _complete_ollama(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ shared_session = ctx.shared_session
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ api_base = (
+ litellm.api_base
+ or api_base
+ or get_secret("OLLAMA_API_BASE")
+ or "http://localhost:11434"
+ )
+ if api_key is not None and "Authorization" not in headers:
+ headers["Authorization"] = f"Bearer {api_key}"
+
+ return base_llm_http_handler.completion(
+ model=model,
+ stream=stream,
+ messages=messages,
+ acompletion=acompletion,
+ api_base=api_base,
+ model_response=model_response,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ shared_session=shared_session,
+ custom_llm_provider="ollama",
+ timeout=timeout,
+ headers=headers,
+ encoding=_get_encoding(),
+ api_key=api_key,
+ logging_obj=logging, # model call logging done inside the class as we make need to modify I/O to fit aleph alpha's requirements
+ client=client,
+ )
+
+
+def _complete_ollama_chat(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ shared_session = ctx.shared_session
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ api_base = (
+ litellm.api_base
+ or api_base
+ or get_secret("OLLAMA_API_BASE")
+ or "http://localhost:11434"
+ )
+
+ api_key = (
+ api_key
+ or litellm.ollama_key
+ or os.environ.get("OLLAMA_API_KEY")
+ or litellm.api_key
+ )
+ if api_key is not None and "Authorization" not in headers:
+ headers["Authorization"] = f"Bearer {api_key}"
+
+ return base_llm_http_handler.completion(
+ model=model,
+ stream=stream,
+ messages=messages,
+ acompletion=acompletion,
+ api_base=api_base,
+ model_response=model_response,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ shared_session=shared_session,
+ custom_llm_provider="ollama_chat",
+ timeout=timeout,
+ headers=headers,
+ encoding=_get_encoding(),
+ api_key=api_key,
+ logging_obj=logging, # model call logging done inside the class as we make need to modify I/O to fit aleph alpha's requirements
+ client=client,
+ )
+
+
+def _complete_triton(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ custom_llm_provider = ctx.custom_llm_provider
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ shared_session = ctx.shared_session
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ api_base = litellm.api_base or api_base
+ return base_llm_http_handler.completion(
+ model=model,
+ stream=stream,
+ messages=messages,
+ acompletion=acompletion,
+ api_base=api_base,
+ model_response=model_response,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ shared_session=shared_session,
+ custom_llm_provider=custom_llm_provider,
+ timeout=timeout,
+ headers=headers,
+ encoding=_get_encoding(),
+ api_key=api_key,
+ logging_obj=logging,
+ )
+
+
+def _complete_cloudflare(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ custom_prompt_dict = ctx.custom_prompt_dict
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ shared_session = ctx.shared_session
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ api_key = (
+ api_key
+ or litellm.cloudflare_api_key
+ or litellm.api_key
+ or get_secret("CLOUDFLARE_API_KEY")
+ )
+ api_base = api_base or litellm.api_base or get_secret("CLOUDFLARE_API_BASE")
+
+ custom_prompt_dict = custom_prompt_dict or litellm.custom_prompt_dict
+ return base_llm_http_handler.completion(
+ model=model,
+ stream=stream,
+ messages=messages,
+ acompletion=acompletion,
+ api_base=api_base,
+ model_response=model_response,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ shared_session=shared_session,
+ custom_llm_provider="cloudflare",
+ timeout=timeout,
+ headers=headers,
+ encoding=_get_encoding(),
+ api_key=api_key,
+ logging_obj=logging, # model call logging done inside the class as we make need to modify I/O to fit aleph alpha's requirements
+ )
+
+
+def _complete_petals(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ api_base = ctx.api_base
+ client = ctx.client
+ litellm_params = ctx.litellm_params
+ logger_fn = ctx.logger_fn
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ stream = ctx.stream
+
+ api_base = api_base or litellm.api_base
+
+ stream = optional_params.pop("stream", False)
+ model_response = petals_handler.completion(
+ model=model,
+ messages=messages,
+ api_base=api_base,
+ model_response=model_response,
+ print_verbose=print_verbose,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ logger_fn=logger_fn,
+ encoding=_get_encoding(),
+ logging_obj=logging,
+ client=client,
+ )
+ if stream is True: ## [BETA]
+ # Fake streaming for petals
+ resp_string = model_response["choices"][0]["message"]["content"]
+ return CustomStreamWrapper(
+ resp_string,
+ model,
+ custom_llm_provider="petals",
+ logging_obj=logging,
+ )
+ return model_response
+
+
+def _complete_snowflake(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ shared_session = ctx.shared_session
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ try:
+ client = (
+ HTTPHandler(timeout=timeout) if stream is False else None
+ ) # Keep this here, otherwise, the httpx.client closes and streaming is impossible
+ response = base_llm_http_handler.completion(
+ model=model,
+ messages=messages,
+ headers=headers,
+ model_response=model_response,
+ api_key=api_key,
+ api_base=api_base,
+ acompletion=acompletion,
+ logging_obj=logging,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ shared_session=shared_session,
+ timeout=timeout, # type: ignore
+ client=client,
+ custom_llm_provider=custom_llm_provider,
+ encoding=_get_encoding(),
+ stream=stream,
+ )
+
+ except Exception as e:
+ ## LOGGING - log the original exception returned
+ logging.post_call(
+ input=messages,
+ api_key=api_key,
+ original_response=str(e),
+ additional_args={"headers": headers},
+ )
+ raise e
+
+ return response
+
+
+def _complete_gradient_ai(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ shared_session = ctx.shared_session
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ api_base = litellm.api_base or api_base
+ return base_llm_http_handler.completion(
+ model=model,
+ stream=stream,
+ messages=messages,
+ acompletion=acompletion,
+ api_base=api_base,
+ model_response=model_response,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ shared_session=shared_session,
+ custom_llm_provider="gradient_ai",
+ timeout=timeout,
+ headers=headers,
+ encoding=_get_encoding(),
+ api_key=api_key,
+ logging_obj=logging,
+ )
+
+
+def _complete_bytez(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ api_key = (
+ api_key
+ or litellm.bytez_key
+ or get_secret_str("BYTEZ_API_KEY")
+ or litellm.api_key
+ )
+
+ response = base_llm_http_handler.completion(
+ model=model,
+ messages=messages,
+ headers=headers,
+ model_response=model_response,
+ api_key=api_key,
+ api_base=api_base,
+ acompletion=acompletion,
+ logging_obj=logging,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ timeout=timeout, # type: ignore
+ client=client,
+ custom_llm_provider=custom_llm_provider,
+ encoding=_get_encoding(),
+ stream=stream,
+ provider_config=bytez_transformation,
+ )
+
+ pass
+
+ return response
+
+
+def _complete_lemonade(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ api_key = (
+ api_key
+ or litellm.lemonade_key
+ or get_secret_str("LEMONADE_API_KEY")
+ or litellm.api_key
+ )
+
+ response = base_llm_http_handler.completion(
+ model=model,
+ messages=messages,
+ headers=headers,
+ model_response=model_response,
+ api_key=api_key,
+ api_base=api_base,
+ acompletion=acompletion,
+ logging_obj=logging,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ timeout=timeout, # type: ignore
+ client=client,
+ custom_llm_provider=custom_llm_provider,
+ encoding=_get_encoding(),
+ stream=stream,
+ provider_config=lemonade_transformation,
+ )
+
+ pass
+
+ return response
+
+
+def _complete_ovhcloud(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ api_key = (
+ api_key
+ or litellm.ovhcloud_key
+ or get_secret_str("OVHCLOUD_API_KEY")
+ or litellm.api_key
+ )
+
+ api_base = (
+ api_base
+ or litellm.api_base
+ or get_secret_str("OVHCLOUD_API_BASE")
+ or "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1"
+ )
+
+ response = base_llm_http_handler.completion(
+ model=model,
+ messages=messages,
+ headers=headers,
+ model_response=model_response,
+ api_key=api_key,
+ api_base=api_base,
+ acompletion=acompletion,
+ logging_obj=logging,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ timeout=timeout, # type: ignore
+ client=client,
+ custom_llm_provider=custom_llm_provider,
+ encoding=_get_encoding(),
+ stream=stream,
+ provider_config=ovhcloud_transformation,
+ )
+
+ pass
+
+ return response
+
+
+def _complete_custom(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ api_base = ctx.api_base
+ headers = ctx.headers
+ kwargs = ctx.kwargs
+ max_tokens = ctx.max_tokens
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ temperature = ctx.temperature
+ top_p = ctx.top_p
+
+ url = litellm.api_base or api_base or ""
+ if url is None or url == "":
+ raise ValueError(
+ "api_base not set. Set api_base or litellm.api_base for custom endpoints"
+ )
+
+ """
+ assume input to custom LLM api bases follow this format:
+ resp = litellm.module_level_client.post(
+ api_base,
+ json={
+ 'model': 'meta-llama/Llama-2-13b-hf', # model name
+ 'params': {
+ 'prompt': ["The capital of France is P"],
+ 'max_tokens': 32,
+ 'temperature': 0.7,
+ 'top_p': 1.0,
+ 'top_k': 40,
+ }
+ }
+ )
+
+ """
+ prompt = " ".join([message["content"] for message in messages]) # type: ignore
+ resp = litellm.module_level_client.post(
+ url,
+ headers=headers,
+ json={
+ "model": model,
+ "params": {
+ "prompt": [prompt],
+ "max_tokens": max_tokens,
+ "temperature": temperature,
+ "top_p": top_p,
+ "top_k": kwargs.get("top_k"),
+ },
+ **kwargs.get("extra_body", {}),
+ },
+ )
+ response_json = resp.json()
+ """
+ assume all responses from custom api_bases of this format:
+ {
+ 'data': [
+ {
+ 'prompt': 'The capital of France is P',
+ 'output': ['The capital of France is PARIS.\nThe capital of France is PARIS.\nThe capital of France is PARIS.\nThe capital of France is PARIS.\nThe capital of France is PARIS.\nThe capital of France is PARIS.\nThe capital of France is PARIS.\nThe capital of France is PARIS.\nThe capital of France is PARIS.\nThe capital of France is PARIS.\nThe capital of France is PARIS.\nThe capital of France is PARIS.\nThe capital of France is PARIS.\nThe capital of France'],
+ 'params': {'temperature': 0.7, 'top_k': 40, 'top_p': 1}}],
+ 'message': 'ok'
+ }
+ ]
+ }
+ """
+ string_response = response_json["data"][0]["output"][0]
+ ## RESPONSE OBJECT
+ model_response.choices[0].message.content = string_response # type: ignore
+ model_response.created = int(time.time())
+ model_response.model = model
+ return model_response
+
+
+def _complete_custom_providers(
+ ctx: _CompletionDispatchContext,
+) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ custom_prompt_dict = ctx.custom_prompt_dict
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logger_fn = ctx.logger_fn
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ custom_handler: Optional[CustomLLM] = None
+ for item in litellm.custom_provider_map:
+ if item["provider"] == custom_llm_provider:
+ custom_handler = item["custom_handler"]
+
+ if custom_handler is None:
+ raise LiteLLMUnknownProvider(
+ model=model, custom_llm_provider=custom_llm_provider
+ )
+
+ ## ROUTE LLM CALL ##
+ handler_fn = custom_chat_llm_router(
+ async_fn=acompletion, stream=stream, custom_llm=custom_handler
+ )
+
+ headers = headers or litellm.headers or {}
+
+ ## CALL FUNCTION
+ response = handler_fn(
+ model=model,
+ messages=messages,
+ headers=headers,
+ model_response=model_response,
+ print_verbose=print_verbose,
+ api_key=api_key,
+ api_base=api_base,
+ acompletion=acompletion,
+ logging_obj=logging,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ logger_fn=logger_fn,
+ timeout=timeout, # type: ignore
+ custom_prompt_dict=custom_prompt_dict,
+ client=client, # pass AsyncOpenAI, OpenAI client
+ encoding=_get_encoding(),
+ )
+ if stream is True:
+ return CustomStreamWrapper(
+ completion_stream=response,
+ model=model,
+ custom_llm_provider=custom_llm_provider,
+ logging_obj=logging,
+ )
+
+ return response # pyright: ignore[reportReturnType] # provider SDK return type is broader than the dispatch contract
+
+
+def _complete_langgraph(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ shared_session = ctx.shared_session
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ from litellm.llms.langgraph.chat.transformation import LangGraphConfig
+
+ (
+ api_base,
+ api_key,
+ ) = LangGraphConfig()._get_openai_compatible_provider_info(
+ api_base=api_base or litellm.api_base,
+ api_key=api_key or litellm.api_key,
+ )
+
+ headers = headers or litellm.headers
+
+ return base_llm_http_handler.completion(
+ model=model,
+ stream=stream,
+ messages=messages,
+ acompletion=acompletion,
+ api_base=api_base,
+ model_response=model_response,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ shared_session=shared_session,
+ custom_llm_provider=custom_llm_provider,
+ timeout=timeout,
+ headers=headers,
+ encoding=_get_encoding(),
+ api_key=api_key,
+ logging_obj=logging,
+ client=client,
+ )
+
+
+def _complete_langflow(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ acompletion = ctx.acompletion
+ api_base = ctx.api_base
+ api_key = ctx.api_key
+ client = ctx.client
+ custom_llm_provider = ctx.custom_llm_provider
+ headers = ctx.headers
+ litellm_params = ctx.litellm_params
+ logging = ctx.logging
+ messages = ctx.messages
+ model = ctx.model
+ model_response = ctx.model_response
+ optional_params = ctx.optional_params
+ shared_session = ctx.shared_session
+ stream = ctx.stream
+ timeout = ctx.timeout
+
+ from litellm.llms.langflow.chat.transformation import LangFlowConfig
+
+ (
+ api_base,
+ api_key,
+ ) = LangFlowConfig()._get_openai_compatible_provider_info(
+ api_base=api_base or litellm.api_base,
+ api_key=api_key or litellm.api_key,
+ )
+
+ headers = headers or litellm.headers
+
+ return base_llm_http_handler.completion(
+ model=model,
+ stream=stream,
+ messages=messages,
+ acompletion=acompletion,
+ api_base=api_base,
+ model_response=model_response,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ shared_session=shared_session,
+ custom_llm_provider=custom_llm_provider,
+ timeout=timeout,
+ headers=headers,
+ encoding=_get_encoding(),
+ api_key=api_key,
+ logging_obj=logging,
+ client=client,
+ )
+
+
@tracer.wrap()
@client
-def completion( # type: ignore # noqa: PLR0915
+def completion( # type: ignore
model: str,
# Optional OpenAI params: see https://platform.openai.com/docs/api-reference/chat/create
messages: List = [],
@@ -1214,9 +5077,7 @@ def completion( # type: ignore # noqa: PLR0915
if LiteLLM_Proxy_MCP_Handler._should_use_litellm_mcp_gateway(
tools=tools_for_mcp
):
- # Return coroutine - acompletion will await it
- # completion() can return a coroutine when MCP tools are present, which acompletion() awaits
- return acompletion_with_mcp( # type: ignore[return-value]
+ return acompletion_with_mcp( # pyright: ignore[reportReturnType] # MCP path returns a coroutine that acompletion() awaits; completion()'s sync return type omits it
model=model,
messages=messages,
functions=functions,
@@ -1388,12 +5249,16 @@ def completion( # type: ignore # noqa: PLR0915
logging: LiteLLMLoggingObj = cast(LiteLLMLoggingObj, litellm_logging_obj)
fallbacks = fallbacks or litellm.model_fallbacks
if fallbacks is not None:
- return completion_with_fallbacks(**args)
+ return completion_with_fallbacks( # pyright: ignore[reportReturnType] # fallback runner is untyped; resolves to ModelResponse|CustomStreamWrapper at runtime
+ **args
+ )
if model_list is not None:
deployments = [
m["litellm_params"] for m in model_list if m["model_name"] == model
]
- return litellm.batch_completion_models(deployments=deployments, **args)
+ return litellm.batch_completion_models( # pyright: ignore[reportReturnType] # batch path returns a list of responses, outside completion()'s single-response return type
+ deployments=deployments, **args
+ )
if litellm.model_alias_map and model in litellm.model_alias_map:
model = litellm.model_alias_map[
model
@@ -1407,11 +5272,19 @@ def completion( # type: ignore # noqa: PLR0915
if deployment_id is not None: # azure llms
model = deployment_id
custom_llm_provider = "azure"
+ _supplemental_provider_params = {
+ k: kwargs[k] for k in OPTIONAL_KWARGS_KEYS if k in kwargs
+ }
model, custom_llm_provider, dynamic_api_key, api_base = get_llm_provider(
model=model,
custom_llm_provider=custom_llm_provider,
api_base=api_base,
api_key=api_key,
+ litellm_params=(
+ GenericLiteLLMParams(**_supplemental_provider_params)
+ if _supplemental_provider_params
+ else None
+ ),
)
## RESPONSES API BRIDGE LOGIC ## - check early and normalize model name
@@ -1445,7 +5318,7 @@ def completion( # type: ignore # noqa: PLR0915
timeout,
kwargs,
custom_llm_provider,
- global_timeout=getattr(litellm, "request_timeout", None),
+ global_timeout=get_configured_request_timeout(),
supports_httpx_timeout=supports_httpx_timeout,
)
@@ -1638,6 +5511,8 @@ def completion( # type: ignore # noqa: PLR0915
litellm_request_debug=kwargs.get("litellm_request_debug", False),
tpm=kwargs.get("tpm"),
rpm=kwargs.get("rpm"),
+ use_xai_oauth=kwargs.get("use_xai_oauth", False),
+ aws_bedrock_project_id=kwargs.get("aws_bedrock_project_id"),
)
cast(LiteLLMLoggingObj, logging).update_environment_variables(
model=model,
@@ -1705,7 +5580,7 @@ def completion( # type: ignore # noqa: PLR0915
else:
optional_params["reasoning_effort"] = {"summary": rs_val}
- return responses_api_bridge.completion(
+ return responses_api_bridge.completion( # pyright: ignore[reportReturnType] # bridge returns a coroutine on the acompletion path; awaited by the async caller
model=model,
messages=messages,
headers=headers,
@@ -1735,375 +5610,52 @@ def completion( # type: ignore # noqa: PLR0915
optional_params
)
+ _dispatch_ctx = _CompletionDispatchContext(
+ _azure_detection_model=_azure_detection_model,
+ acompletion=acompletion,
+ api_base=api_base,
+ api_key=api_key,
+ api_version=api_version,
+ client=client,
+ custom_llm_provider=custom_llm_provider,
+ custom_prompt_dict=custom_prompt_dict,
+ extra_headers=extra_headers,
+ headers=headers,
+ hf_model_name=hf_model_name,
+ kwargs=kwargs,
+ litellm_params=litellm_params,
+ logger_fn=logger_fn,
+ logging=logging,
+ max_retries=max_retries,
+ max_tokens=max_tokens,
+ messages=messages,
+ metadata=metadata,
+ model=model,
+ model_response=model_response,
+ optional_params=optional_params,
+ organization=organization,
+ provider_config=provider_config,
+ shared_session=shared_session,
+ stream=stream,
+ temperature=temperature,
+ text_completion=text_completion,
+ timeout=timeout,
+ top_p=top_p,
+ )
if custom_llm_provider == "azure":
# azure configs
## check dynamic params ##
- dynamic_params = False
- if client is not None and (
- isinstance(client, openai.AzureOpenAI)
- or isinstance(client, openai.AsyncAzureOpenAI)
- ):
- dynamic_params = _check_dynamic_azure_params(
- azure_client_params={"api_version": api_version},
- azure_client=client,
- )
-
- api_type = get_secret("AZURE_API_TYPE") or "azure"
-
- api_base = api_base or litellm.api_base or get_secret("AZURE_API_BASE")
-
- api_version = (
- api_version
- or litellm.api_version
- or get_secret_str("AZURE_API_VERSION")
- or litellm.AZURE_DEFAULT_API_VERSION
- )
-
- api_key = (
- api_key
- or litellm.api_key
- or litellm.azure_key
- or get_secret_str("AZURE_OPENAI_API_KEY")
- or get_secret_str("AZURE_API_KEY")
- )
-
- azure_ad_token = optional_params.get("extra_body", {}).pop(
- "azure_ad_token", None
- ) or get_secret_str("AZURE_AD_TOKEN")
-
- azure_ad_token_provider = litellm_params.get(
- "azure_ad_token_provider", None
- )
-
- headers = headers or litellm.headers
-
- if extra_headers is not None:
- optional_params["extra_headers"] = extra_headers
- if max_retries is not None:
- optional_params["max_retries"] = max_retries
-
- if litellm.AzureOpenAIO1Config().is_o_series_model(
- model=_azure_detection_model
- ):
- ## LOAD CONFIG - if set
- config = litellm.AzureOpenAIO1Config.get_config()
- for k, v in config.items():
- if (
- k not in optional_params
- ): # completion(top_k=3) > azure_config(top_k=3) <- allows for dynamic variables to be passed in
- optional_params[k] = v
-
- response = azure_o1_chat_completions.completion(
- model=model,
- messages=messages,
- headers=headers,
- api_key=api_key,
- api_base=api_base,
- api_version=api_version,
- dynamic_params=dynamic_params,
- azure_ad_token=azure_ad_token,
- model_response=model_response,
- print_verbose=print_verbose,
- optional_params=optional_params,
- litellm_params=litellm_params,
- logger_fn=logger_fn,
- logging_obj=logging,
- acompletion=acompletion,
- timeout=timeout, # type: ignore
- client=client, # pass AsyncAzureOpenAI, AzureOpenAI client
- custom_llm_provider=custom_llm_provider,
- )
- else:
- ## LOAD CONFIG - if set
- config = litellm.AzureOpenAIConfig.get_config()
- for k, v in config.items():
- if (
- k not in optional_params
- ): # completion(top_k=3) > azure_config(top_k=3) <- allows for dynamic variables to be passed in
- optional_params[k] = v
-
- ## COMPLETION CALL
- response = azure_chat_completions.completion(
- model=model,
- messages=messages,
- headers=headers,
- api_key=api_key,
- api_base=api_base,
- api_version=api_version,
- api_type=api_type,
- dynamic_params=dynamic_params,
- azure_ad_token=azure_ad_token,
- azure_ad_token_provider=azure_ad_token_provider,
- model_response=model_response,
- print_verbose=print_verbose,
- optional_params=optional_params,
- litellm_params=litellm_params,
- logger_fn=logger_fn,
- logging_obj=logging,
- acompletion=acompletion,
- timeout=timeout, # type: ignore
- client=client, # pass AsyncAzureOpenAI, AzureOpenAI client
- )
-
- if optional_params.get("stream", False):
- ## LOGGING
- logging.post_call(
- input=messages,
- api_key=api_key,
- original_response=response,
- additional_args={
- "headers": headers,
- "api_version": api_version,
- "api_base": api_base,
- },
- )
+ response = _complete_azure(_dispatch_ctx)
elif custom_llm_provider == "azure_text":
# azure configs
- api_type = get_secret_str("AZURE_API_TYPE") or "azure"
-
- api_base = api_base or litellm.api_base or get_secret_str("AZURE_API_BASE")
-
- if api_base is None:
- raise ValueError(
- "api_base is required for Azure OpenAI LLM provider. Either set it dynamically or set the AZURE_API_BASE environment variable."
- )
-
- api_version = (
- api_version
- or litellm.api_version
- or get_secret_str("AZURE_API_VERSION")
- )
-
- api_key = (
- api_key
- or litellm.api_key
- or litellm.azure_key
- or get_secret_str("AZURE_OPENAI_API_KEY")
- or get_secret_str("AZURE_API_KEY")
- )
-
- azure_ad_token = optional_params.get("extra_body", {}).pop(
- "azure_ad_token", None
- ) or get_secret_str("AZURE_AD_TOKEN")
-
- azure_ad_token_provider = litellm_params.get(
- "azure_ad_token_provider", None
- )
-
- headers = headers or litellm.headers
-
- if extra_headers is not None:
- optional_params["extra_headers"] = extra_headers
-
- ## LOAD CONFIG - if set
- config = litellm.AzureOpenAIConfig.get_config()
- for k, v in config.items():
- if (
- k not in optional_params
- ): # completion(top_k=3) > azure_config(top_k=3) <- allows for dynamic variables to be passed in
- optional_params[k] = v
-
- ## COMPLETION CALL
- response = azure_text_completions.completion(
- model=model,
- messages=messages,
- headers=headers,
- api_key=api_key,
- api_base=api_base,
- api_version=cast(str, api_version),
- api_type=api_type,
- azure_ad_token=azure_ad_token,
- azure_ad_token_provider=azure_ad_token_provider,
- model_response=model_response,
- print_verbose=print_verbose,
- optional_params=optional_params,
- litellm_params=litellm_params,
- logger_fn=logger_fn,
- logging_obj=logging,
- acompletion=acompletion,
- timeout=timeout,
- client=client, # pass AsyncAzureOpenAI, AzureOpenAI client
- )
-
- if optional_params.get("stream", False) or acompletion is True:
- ## LOGGING
- logging.post_call(
- input=messages,
- api_key=api_key,
- original_response=response,
- additional_args={
- "headers": headers,
- "api_version": api_version,
- "api_base": api_base,
- },
- )
+ response = _complete_azure_text(_dispatch_ctx)
elif custom_llm_provider == "deepseek":
## COMPLETION CALL
- try:
- response = base_llm_http_handler.completion(
- model=model,
- messages=messages,
- headers=headers,
- model_response=model_response,
- api_key=api_key,
- api_base=api_base,
- acompletion=acompletion,
- logging_obj=logging,
- optional_params=optional_params,
- litellm_params=litellm_params,
- shared_session=shared_session,
- timeout=timeout, # type: ignore
- client=client,
- custom_llm_provider=custom_llm_provider,
- encoding=_get_encoding(),
- stream=stream,
- provider_config=provider_config,
- )
- except Exception as e:
- ## LOGGING - log the original exception returned
- logging.post_call(
- input=messages,
- api_key=api_key,
- original_response=str(e),
- additional_args={"headers": headers},
- )
- raise e
+ response = _complete_deepseek(_dispatch_ctx)
elif custom_llm_provider == "azure_ai":
- from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo
-
- azure_ai_route = AzureFoundryModelInfo.get_azure_ai_route(model)
-
- # Check if this is an agents route - model format: azure_ai/agents/
- if azure_ai_route == "agents":
- from litellm.llms.azure_ai.agents import AzureAIAgentsConfig
-
- api_base = AzureFoundryModelInfo.get_api_base(api_base)
- if api_base is None:
- raise ValueError(
- "Azure AI Agents requests require an api_base. "
- "Set `api_base` or the AZURE_AI_API_BASE env var."
- )
- api_key = AzureFoundryModelInfo.get_api_key(api_key)
-
- response = AzureAIAgentsConfig.completion(
- model=model,
- messages=messages,
- api_base=api_base,
- api_key=api_key,
- model_response=model_response,
- logging_obj=logging,
- optional_params=optional_params,
- litellm_params=litellm_params,
- timeout=timeout,
- acompletion=acompletion,
- stream=stream,
- headers=headers or litellm.headers,
- )
-
- # Check if this is a Claude model - route to Azure Anthropic handler
- elif "claude" in model.lower():
- # Use Azure Anthropic handler for Claude models
- api_base = AzureFoundryModelInfo.get_api_base(api_base)
- if api_base is None:
- raise ValueError(
- "Azure Anthropic requests require an api_base. "
- "Set `api_base` or the AZURE_AI_API_BASE env var."
- )
- api_key = AzureFoundryModelInfo.get_api_key(api_key)
-
- # Ensure the URL ends with /v1/messages for Anthropic
- if api_base:
- api_base = api_base.rstrip("/")
- if not api_base.endswith("/v1/messages"):
- if "/anthropic" in api_base:
- parts = api_base.split("/anthropic", 1)
- api_base = parts[0] + "/anthropic"
- else:
- api_base = api_base + "/anthropic"
- api_base = api_base + "/v1/messages"
-
- response = azure_anthropic_chat_completions.completion(
- model=model,
- messages=messages,
- api_base=api_base,
- acompletion=acompletion,
- custom_prompt_dict=litellm.custom_prompt_dict,
- model_response=model_response,
- print_verbose=print_verbose,
- optional_params=optional_params,
- litellm_params=litellm_params,
- logger_fn=logger_fn,
- encoding=_get_encoding(),
- api_key=api_key,
- logging_obj=logging,
- headers=headers,
- timeout=timeout,
- client=client,
- custom_llm_provider=custom_llm_provider,
- )
- if optional_params.get("stream", False) or acompletion is True:
- ## LOGGING
- logging.post_call(
- input=messages,
- api_key=api_key,
- original_response=response,
- )
- response = response
- else:
- # Non-Claude models use standard Azure AI flow
- api_base = AzureFoundryModelInfo.get_api_base(api_base)
- # set API KEY
- api_key = AzureFoundryModelInfo.get_api_key(api_key)
-
- headers = headers or litellm.headers
-
- if extra_headers is not None:
- optional_params["extra_headers"] = extra_headers
-
- ## FOR COHERE
- if "command-r" in model: # make sure tool call in messages are str
- messages = stringify_json_tool_call_content(messages=messages)
-
- ## COMPLETION CALL
- try:
- response = base_llm_http_handler.completion(
- model=model,
- messages=messages,
- headers=headers,
- model_response=model_response,
- api_key=api_key,
- api_base=api_base,
- acompletion=acompletion,
- logging_obj=logging,
- optional_params=optional_params,
- litellm_params=litellm_params,
- shared_session=shared_session,
- timeout=timeout, # type: ignore
- client=client, # pass AsyncOpenAI, OpenAI client
- custom_llm_provider=custom_llm_provider,
- encoding=_get_encoding(),
- stream=stream,
- )
- except Exception as e:
- ## LOGGING - log the original exception returned
- logging.post_call(
- input=messages,
- api_key=api_key,
- original_response=str(e),
- additional_args={"headers": headers},
- )
- raise e
-
- if optional_params.get("stream", False):
- ## LOGGING
- logging.post_call(
- input=messages,
- api_key=api_key,
- original_response=response,
- additional_args={"headers": headers},
- )
+ response = _complete_azure_ai(_dispatch_ctx)
elif (
custom_llm_provider == "text-completion-openai"
or "ft:babbage-002" in model
@@ -2112,537 +5664,42 @@ def completion( # type: ignore # noqa: PLR0915
in litellm.openai_text_completion_compatible_providers
and kwargs.get("text_completion") is True
):
- openai.api_type = "openai"
-
- api_base = (
- api_base
- or litellm.api_base
- or get_secret("OPENAI_BASE_URL")
- or get_secret("OPENAI_API_BASE")
- or "https://api.openai.com/v1"
- )
-
- openai.api_version = None
- # set API KEY
-
- api_key = (
- api_key
- or litellm.api_key
- or litellm.openai_key
- or get_secret("OPENAI_API_KEY")
- )
-
- headers = headers or litellm.headers
-
- if extra_headers is not None:
- optional_params["extra_headers"] = extra_headers
-
- ## LOAD CONFIG - if set
- config = litellm.OpenAITextCompletionConfig.get_config()
- for k, v in config.items():
- if (
- k not in optional_params
- ): # completion(top_k=3) > openai_text_config(top_k=3) <- allows for dynamic variables to be passed in
- optional_params[k] = v
- if litellm.organization:
- openai.organization = litellm.organization
-
- if (
- len(messages) > 0
- and "content" in messages[0]
- and isinstance(messages[0]["content"], list)
- ):
- # text-davinci-003 can accept a string or array, if it's an array, assume the array is set in messages[0]['content']
- # https://platform.openai.com/docs/api-reference/completions/create
- prompt = messages[0]["content"]
- else:
- prompt = " ".join([message["content"] for message in messages]) # type: ignore
-
- ## COMPLETION CALL
- _response = openai_text_completions.completion(
- model=model,
- messages=messages,
- model_response=model_response,
- print_verbose=print_verbose,
- api_key=api_key,
- custom_llm_provider=custom_llm_provider,
- api_base=api_base,
- acompletion=acompletion,
- client=client, # pass AsyncOpenAI, OpenAI client
- logging_obj=logging,
- optional_params=optional_params,
- litellm_params=litellm_params,
- logger_fn=logger_fn,
- timeout=timeout, # type: ignore
- )
-
- if (
- optional_params.get("stream", False) is False
- and acompletion is False
- and text_completion is False
- ):
- # convert to chat completion response
- _response = litellm.OpenAITextCompletionConfig().convert_to_chat_model_response_object(
- response_object=_response, model_response_object=model_response
- )
-
- if optional_params.get("stream", False) or acompletion is True:
- ## LOGGING
- logging.post_call(
- input=messages,
- api_key=api_key,
- original_response=_response,
- additional_args={"headers": headers},
- )
- response = _response
+ response = _complete_text_completion_openai(_dispatch_ctx)
elif custom_llm_provider == "fireworks_ai":
## COMPLETION CALL
- try:
- response = base_llm_http_handler.completion(
- model=model,
- messages=messages,
- headers=headers,
- model_response=model_response,
- api_key=api_key,
- api_base=api_base,
- acompletion=acompletion,
- logging_obj=logging,
- optional_params=optional_params,
- litellm_params=litellm_params,
- shared_session=shared_session,
- timeout=timeout, # type: ignore
- client=client,
- custom_llm_provider=custom_llm_provider,
- encoding=_get_encoding(),
- stream=stream,
- provider_config=provider_config,
- )
- except Exception as e:
- ## LOGGING - log the original exception returned
- logging.post_call(
- input=messages,
- api_key=api_key,
- original_response=str(e),
- additional_args={"headers": headers},
- )
- raise e
+ response = _complete_fireworks_ai(_dispatch_ctx)
elif custom_llm_provider == "heroku":
- try:
- response = base_llm_http_handler.completion(
- model=model,
- messages=messages,
- headers=headers,
- model_response=model_response,
- api_key=api_key,
- api_base=api_base,
- acompletion=acompletion,
- logging_obj=logging,
- optional_params=optional_params,
- litellm_params=litellm_params,
- shared_session=shared_session,
- timeout=timeout,
- client=client,
- custom_llm_provider=custom_llm_provider,
- encoding=_get_encoding(),
- stream=stream,
- provider_config=provider_config,
- )
- except Exception as e:
- logging.post_call(
- input=messages,
- api_key=api_key,
- original_response=str(e),
- additional_args={"headers": headers},
- )
- raise e
+ response = _complete_heroku(_dispatch_ctx)
elif custom_llm_provider == "ragflow":
## COMPLETION CALL - RAGFlow uses HTTP handler to support custom URL paths
- try:
- response = base_llm_http_handler.completion(
- model=model,
- messages=messages,
- headers=headers,
- model_response=model_response,
- api_key=api_key,
- api_base=api_base,
- acompletion=acompletion,
- logging_obj=logging,
- optional_params=optional_params,
- litellm_params=litellm_params,
- shared_session=shared_session,
- timeout=timeout,
- client=client,
- custom_llm_provider=custom_llm_provider,
- encoding=_get_encoding(),
- stream=stream,
- provider_config=provider_config,
- )
- except Exception as e:
- logging.post_call(
- input=messages,
- api_key=api_key,
- original_response=str(e),
- additional_args={"headers": headers},
- )
- raise e
+ response = _complete_ragflow(_dispatch_ctx)
elif custom_llm_provider == "xai":
## COMPLETION CALL
- try:
- response = base_llm_http_handler.completion(
- model=model,
- messages=messages,
- headers=headers,
- model_response=model_response,
- api_key=api_key,
- api_base=api_base,
- acompletion=acompletion,
- logging_obj=logging,
- optional_params=optional_params,
- litellm_params=litellm_params,
- shared_session=shared_session,
- timeout=timeout, # type: ignore
- client=client,
- custom_llm_provider=custom_llm_provider,
- encoding=_get_encoding(),
- stream=stream,
- provider_config=provider_config,
- )
- except Exception as e:
- ## LOGGING - log the original exception returned
- logging.post_call(
- input=messages,
- api_key=api_key,
- original_response=str(e),
- additional_args={"headers": headers},
- )
- raise e
+ response = _complete_xai(_dispatch_ctx)
elif custom_llm_provider == "groq":
- api_base = (
- api_base # for deepinfra/perplexity/anyscale/groq/friendliai we check in get_llm_provider and pass in the api base from there
- or litellm.api_base
- or get_secret("GROQ_API_BASE")
- or "https://api.groq.com/openai/v1"
- )
-
- # set API KEY
- api_key = (
- api_key
- or litellm.api_key # for deepinfra/perplexity/anyscale/friendliai we check in get_llm_provider and pass in the api key from there
- or litellm.groq_key
- or get_secret("GROQ_API_KEY")
- )
-
- headers = headers or litellm.headers
-
- ## LOAD CONFIG - if set
- config = litellm.GroqChatConfig.get_config()
- for k, v in config.items():
- if (
- k not in optional_params
- ): # completion(top_k=3) > openai_config(top_k=3) <- allows for dynamic variables to be passed in
- optional_params[k] = v
-
- response = base_llm_http_handler.completion(
- model=model,
- stream=stream,
- messages=messages,
- acompletion=acompletion,
- api_base=api_base,
- model_response=model_response,
- optional_params=optional_params,
- litellm_params=litellm_params,
- shared_session=shared_session,
- custom_llm_provider=custom_llm_provider,
- timeout=timeout,
- headers=headers,
- encoding=_get_encoding(),
- api_key=api_key,
- logging_obj=logging, # model call logging done inside the class as we make need to modify I/O to fit aleph alpha's requirements
- client=client,
- )
+ response = _complete_groq(_dispatch_ctx)
elif custom_llm_provider == "bedrock_mantle":
- api_base = (
- api_base or litellm.api_base or get_secret("BEDROCK_MANTLE_API_BASE")
- )
- api_key = api_key or litellm.api_key or get_secret("BEDROCK_MANTLE_API_KEY")
- headers = headers or litellm.headers
- config = litellm.BedrockMantleChatConfig.get_config()
- for k, v in config.items():
- if k not in optional_params:
- optional_params[k] = v
- response = base_llm_http_handler.completion(
- model=model,
- stream=stream,
- messages=messages,
- acompletion=acompletion,
- api_base=api_base,
- model_response=model_response,
- optional_params=optional_params,
- litellm_params=litellm_params,
- shared_session=shared_session,
- custom_llm_provider=custom_llm_provider,
- timeout=timeout,
- headers=headers,
- encoding=_get_encoding(),
- api_key=api_key,
- logging_obj=logging,
- client=client,
- )
+ response = _complete_bedrock_mantle(_dispatch_ctx)
elif custom_llm_provider == "a2a":
# A2A (Agent-to-Agent) Protocol
# Resolve agent configuration from registry if model format is "a2a/"
- (
- api_base,
- api_key,
- headers,
- ) = litellm.A2AConfig.resolve_agent_config_from_registry(
- model=model,
- api_base=api_base,
- api_key=api_key,
- headers=headers,
- optional_params=optional_params,
- )
-
- # Fall back to environment variables and defaults
- api_base = api_base or litellm.api_base or get_secret_str("A2A_API_BASE")
-
- if api_base is None:
- raise Exception(
- "api_base is required for A2A provider. "
- "Either provide api_base parameter, set A2A_API_BASE environment variable, "
- "or register the agent in the proxy with model='a2a/'."
- )
-
- headers = headers or litellm.headers
-
- response = base_llm_http_handler.completion(
- model=model,
- stream=stream,
- messages=messages,
- acompletion=acompletion,
- api_base=api_base,
- model_response=model_response,
- optional_params=optional_params,
- litellm_params=litellm_params,
- shared_session=shared_session,
- custom_llm_provider=custom_llm_provider,
- timeout=timeout,
- headers=headers,
- encoding=_get_encoding(),
- api_key=api_key,
- logging_obj=logging,
- client=client,
- provider_config=provider_config,
- )
+ response = _complete_a2a(_dispatch_ctx)
elif custom_llm_provider == "gigachat":
# GigaChat - Sber AI's LLM (Russia)
- api_key = (
- api_key
- or litellm.api_key
- or litellm.gigachat_key
- or get_secret("GIGACHAT_API_KEY")
- or get_secret("GIGACHAT_CREDENTIALS")
- )
-
- headers = headers or litellm.headers or {}
-
- ## COMPLETION CALL
- try:
- response = base_llm_http_handler.completion(
- model=model,
- messages=messages,
- headers=headers,
- model_response=model_response,
- api_key=api_key,
- api_base=api_base,
- acompletion=acompletion,
- logging_obj=logging,
- optional_params=optional_params,
- litellm_params=litellm_params,
- shared_session=shared_session,
- timeout=timeout,
- client=client,
- custom_llm_provider=custom_llm_provider,
- encoding=_get_encoding(),
- stream=stream,
- provider_config=provider_config,
- )
- except Exception as e:
- ## LOGGING - log the original exception returned
- logging.post_call(
- input=messages,
- api_key=api_key,
- original_response=str(e),
- additional_args={"headers": headers},
- )
- raise e
+ response = _complete_gigachat(_dispatch_ctx)
elif custom_llm_provider == "sap":
- headers = headers or litellm.headers
- ## LOAD CONFIG - if set
- config = litellm.GenAIHubOrchestrationConfig.get_config()
- for k, v in config.items():
- if (
- k not in optional_params
- ): # completion(top_k=3) > openai_config(top_k=3) <- allows for dynamic variables to be passed in
- optional_params[k] = v
-
- response = sap_gen_ai_hub_chat_completions.completion(
- model=model,
- messages=messages,
- headers=headers,
- model_response=model_response,
- acompletion=acompletion,
- logging_obj=logging,
- optional_params=optional_params,
- litellm_params=litellm_params,
- timeout=timeout, # type: ignore
- shared_session=shared_session,
- client=client,
- custom_llm_provider=custom_llm_provider,
- encoding=_get_encoding(),
- api_key=api_key,
- api_base=api_base,
- stream=stream,
- )
+ response = _complete_sap(_dispatch_ctx)
elif custom_llm_provider == "aiohttp_openai":
# NEW aiohttp provider for 10-100x higher RPS
- api_base = (
- api_base # for deepinfra/perplexity/anyscale/groq/friendliai we check in get_llm_provider and pass in the api base from there
- or litellm.api_base
- or get_secret("OPENAI_BASE_URL")
- or get_secret("OPENAI_API_BASE")
- or "https://api.openai.com/v1"
- )
- # set API KEY
- api_key = (
- api_key
- or litellm.api_key # for deepinfra/perplexity/anyscale/friendliai we check in get_llm_provider and pass in the api key from there
- or litellm.openai_key
- or get_secret("OPENAI_API_KEY")
- )
-
- headers = headers or litellm.headers
-
- if extra_headers is not None:
- optional_params["extra_headers"] = extra_headers
- response = base_llm_aiohttp_handler.completion(
- model=model,
- messages=messages,
- headers=headers,
- model_response=model_response,
- api_key=api_key,
- api_base=api_base,
- acompletion=acompletion,
- logging_obj=logging,
- optional_params=optional_params,
- litellm_params=litellm_params,
- timeout=timeout,
- client=client,
- custom_llm_provider=custom_llm_provider,
- encoding=_get_encoding(),
- stream=stream,
- )
+ response = _complete_aiohttp_openai(_dispatch_ctx)
elif custom_llm_provider == "cometapi":
- api_key = (
- api_key
- or litellm.cometapi_key
- or get_secret_str("COMETAPI_KEY")
- or litellm.api_key
- )
-
- api_base = (
- api_base
- or litellm.api_base
- or get_secret_str("COMETAPI_API_BASE")
- or "https://api.cometapi.com/v1"
- )
-
- ## COMPLETION CALL
- response = base_llm_http_handler.completion(
- model=model,
- messages=messages,
- headers=headers,
- model_response=model_response,
- api_key=api_key,
- api_base=api_base,
- acompletion=acompletion,
- logging_obj=logging,
- optional_params=optional_params,
- litellm_params=litellm_params,
- shared_session=shared_session,
- timeout=timeout,
- client=client,
- custom_llm_provider=custom_llm_provider,
- encoding=_get_encoding(),
- stream=stream,
- provider_config=provider_config,
- )
-
- ## LOGGING
- logging.post_call(
- input=messages, api_key=api_key, original_response=response
- )
+ response = _complete_cometapi(_dispatch_ctx)
elif custom_llm_provider == "minimax":
- api_key = api_key or get_secret_str("MINIMAX_API_KEY") or litellm.api_key
-
- api_base = (
- api_base
- or litellm.api_base
- or get_secret_str("MINIMAX_API_BASE")
- or "https://api.minimax.io/v1"
- )
-
- response = base_llm_http_handler.completion(
- model=model,
- messages=messages,
- api_base=api_base,
- custom_llm_provider=custom_llm_provider,
- model_response=model_response,
- encoding=_get_encoding(),
- logging_obj=logging,
- optional_params=optional_params,
- timeout=timeout,
- litellm_params=litellm_params,
- shared_session=shared_session,
- acompletion=acompletion,
- stream=stream,
- api_key=api_key,
- headers=headers,
- client=client,
- provider_config=provider_config,
- )
- logging.post_call(
- input=messages, api_key=api_key, original_response=response
- )
+ response = _complete_minimax(_dispatch_ctx)
elif custom_llm_provider == "hosted_vllm":
- api_base = (
- api_base or litellm.api_base or get_secret_str("HOSTED_VLLM_API_BASE")
- )
-
- response = base_llm_http_handler.completion(
- model=model,
- messages=messages,
- api_base=api_base,
- custom_llm_provider=custom_llm_provider,
- model_response=model_response,
- encoding=_get_encoding(),
- logging_obj=logging,
- optional_params=optional_params,
- timeout=timeout,
- litellm_params=litellm_params,
- shared_session=shared_session,
- acompletion=acompletion,
- stream=stream,
- api_key=api_key,
- headers=headers,
- client=client,
- provider_config=provider_config,
- )
- logging.post_call(
- input=messages, api_key=api_key, original_response=response
- )
+ response = _complete_hosted_vllm(_dispatch_ctx)
elif (
model in litellm.open_ai_chat_completion_models
or custom_llm_provider == "custom_openai"
@@ -2667,205 +5724,17 @@ def completion( # type: ignore # noqa: PLR0915
): # allow user to make an openai call with a custom base
# note: if a user sets a custom base - we should ensure this works
# allow for the setting of dynamic and stateful api-bases
- api_base = (
- api_base # for deepinfra/perplexity/anyscale/groq/friendliai we check in get_llm_provider and pass in the api base from there
- or litellm.api_base
- or get_secret("OPENAI_BASE_URL")
- or get_secret("OPENAI_API_BASE")
- or "https://api.openai.com/v1"
- )
- organization = (
- organization
- or litellm.organization
- or get_secret("OPENAI_ORGANIZATION")
- or None # default - https://github.com/openai/openai-python/blob/284c1799070c723c6a553337134148a7ab088dd8/openai/util.py#L105
- )
- openai.organization = organization
- # set API KEY
- api_key = (
- api_key
- or litellm.api_key # for deepinfra/perplexity/anyscale/friendliai we check in get_llm_provider and pass in the api key from there
- or litellm.openai_key
- or get_secret("OPENAI_API_KEY")
- )
-
- headers = headers or litellm.headers
-
- # Add GitHub Copilot headers (same as /responses endpoint does)
- if custom_llm_provider == "github_copilot":
- from litellm.llms.github_copilot.authenticator import Authenticator
- from litellm.llms.github_copilot.common_utils import (
- get_copilot_default_headers,
- )
-
- copilot_auth = Authenticator()
- copilot_api_key = copilot_auth.get_api_key()
- copilot_headers = get_copilot_default_headers(copilot_api_key)
- if extra_headers:
- copilot_headers.update(extra_headers)
- extra_headers = copilot_headers
-
- if extra_headers is not None:
- optional_params["extra_headers"] = extra_headers
-
- if (
- litellm.enable_preview_features and metadata is not None
- ): # [PREVIEW] allow metadata to be passed to OPENAI
- openai_metadata = get_requester_metadata(metadata)
- if openai_metadata is not None:
- optional_params["metadata"] = openai_metadata
-
- ## LOAD CONFIG - if set
- config = litellm.OpenAIConfig.get_config()
- for k, v in config.items():
- if (
- k not in optional_params
- ): # completion(top_k=3) > openai_config(top_k=3) <- allows for dynamic variables to be passed in
- optional_params[k] = v
-
- ## COMPLETION CALL
- use_base_llm_http_handler = get_secret_bool(
- "EXPERIMENTAL_OPENAI_BASE_LLM_HTTP_HANDLER"
- )
-
- try:
- if use_base_llm_http_handler:
- response = base_llm_http_handler.completion(
- model=model,
- messages=messages,
- api_base=api_base,
- custom_llm_provider=custom_llm_provider,
- model_response=model_response,
- encoding=_get_encoding(),
- logging_obj=logging,
- optional_params=optional_params,
- timeout=timeout,
- litellm_params=litellm_params,
- shared_session=shared_session,
- acompletion=acompletion,
- stream=stream,
- api_key=api_key,
- headers=headers,
- client=client,
- provider_config=provider_config,
- )
- else:
- response = openai_chat_completions.completion(
- model=model,
- messages=messages,
- headers=headers,
- model_response=model_response,
- print_verbose=print_verbose,
- api_key=api_key,
- api_base=api_base,
- acompletion=acompletion,
- logging_obj=logging,
- optional_params=optional_params,
- litellm_params=litellm_params,
- logger_fn=logger_fn,
- timeout=timeout, # type: ignore
- custom_prompt_dict=custom_prompt_dict,
- client=client, # pass AsyncOpenAI, OpenAI client
- organization=organization,
- custom_llm_provider=custom_llm_provider,
- shared_session=shared_session,
- )
- except Exception as e:
- ## LOGGING - log the original exception returned
- logging.post_call(
- input=messages,
- api_key=api_key,
- original_response=str(e),
- additional_args={"headers": headers},
- )
- raise e
-
- if optional_params.get("stream", False):
- ## LOGGING
- logging.post_call(
- input=messages,
- api_key=api_key,
- original_response=response,
- additional_args={"headers": headers},
- )
+ response = _complete_custom_openai(_dispatch_ctx)
elif custom_llm_provider == "mistral":
- api_key = api_key or litellm.api_key or get_secret("MISTRAL_API_KEY")
- api_base = (
- api_base
- or litellm.api_base
- or get_secret("MISTRAL_API_BASE")
- or "https://api.mistral.ai/v1"
- )
-
- response = base_llm_http_handler.completion(
- model=model,
- messages=messages,
- api_base=api_base,
- custom_llm_provider=custom_llm_provider,
- model_response=model_response,
- encoding=_get_encoding(),
- logging_obj=logging,
- optional_params=optional_params,
- timeout=timeout,
- litellm_params=litellm_params,
- shared_session=shared_session,
- acompletion=acompletion,
- stream=stream,
- api_key=api_key,
- headers=headers,
- client=client,
- provider_config=provider_config,
- )
+ response = _complete_mistral(_dispatch_ctx)
elif (
"replicate" in model
or custom_llm_provider == "replicate"
or model in litellm.replicate_models
):
# Setting the relevant API KEY for replicate, replicate defaults to using os.environ.get("REPLICATE_API_TOKEN")
- replicate_key = (
- api_key
- or litellm.replicate_key
- or litellm.api_key
- or get_secret("REPLICATE_API_KEY")
- or get_secret("REPLICATE_API_TOKEN")
- )
-
- api_base = (
- api_base
- or litellm.api_base
- or get_secret("REPLICATE_API_BASE")
- or "https://api.replicate.com/v1"
- )
-
- custom_prompt_dict = custom_prompt_dict or litellm.custom_prompt_dict
-
- model_response = replicate_chat_completion( # type: ignore
- model=model,
- messages=messages,
- api_base=api_base,
- model_response=model_response,
- print_verbose=print_verbose,
- optional_params=optional_params,
- litellm_params=litellm_params,
- logger_fn=logger_fn,
- encoding=_get_encoding(), # for calculating input/output tokens
- api_key=replicate_key,
- logging_obj=logging,
- custom_prompt_dict=custom_prompt_dict,
- acompletion=acompletion,
- headers=headers,
- )
-
- if optional_params.get("stream", False) is True:
- ## LOGGING
- logging.post_call(
- input=messages,
- api_key=replicate_key,
- original_response=model_response,
- )
-
- response = model_response
+ response = _complete_replicate(_dispatch_ctx)
elif (
"clarifai" in model
or custom_llm_provider == "clarifai"
@@ -2873,614 +5742,36 @@ def completion( # type: ignore # noqa: PLR0915
):
pass # Deprecated - handled in the openai compatible provider section above
elif custom_llm_provider == "anthropic_text":
- api_key = (
- api_key
- or litellm.anthropic_key
- or litellm.api_key
- or os.environ.get("ANTHROPIC_API_KEY")
- )
- custom_prompt_dict = custom_prompt_dict or litellm.custom_prompt_dict
- api_base = (
- api_base
- or litellm.api_base
- or get_secret("ANTHROPIC_API_BASE")
- or get_secret("ANTHROPIC_BASE_URL")
- or "https://api.anthropic.com/v1/complete"
- )
-
- # Check if we should disable automatic URL suffix appending
- disable_url_suffix = get_secret_bool("LITELLM_ANTHROPIC_DISABLE_URL_SUFFIX")
- if (
- api_base is not None
- and not disable_url_suffix
- and not api_base.endswith("/v1/complete")
- ):
- api_base += "/v1/complete"
- elif disable_url_suffix:
- verbose_logger.debug(
- "LITELLM_ANTHROPIC_DISABLE_URL_SUFFIX is set, skipping /v1/complete suffix"
- )
-
- response = base_llm_http_handler.completion(
- model=model,
- stream=stream,
- messages=messages,
- acompletion=acompletion,
- api_base=api_base,
- model_response=model_response,
- optional_params=optional_params,
- litellm_params=litellm_params,
- shared_session=shared_session,
- custom_llm_provider="anthropic_text",
- timeout=timeout,
- headers=headers,
- encoding=_get_encoding(),
- api_key=api_key,
- logging_obj=logging, # model call logging done inside the class as we make need to modify I/O to fit aleph alpha's requirements
- )
+ response = _complete_anthropic_text(_dispatch_ctx)
elif custom_llm_provider == "anthropic":
- api_key = (
- api_key
- or litellm.anthropic_key
- or litellm.api_key
- or os.environ.get("ANTHROPIC_API_KEY")
- )
- custom_prompt_dict = custom_prompt_dict or litellm.custom_prompt_dict
- # call /messages
- # default route for all anthropic models
- api_base = (
- api_base
- or litellm.api_base
- or get_secret("ANTHROPIC_API_BASE")
- or get_secret("ANTHROPIC_BASE_URL")
- or "https://api.anthropic.com/v1/messages"
- )
-
- # Check if we should disable automatic URL suffix appending
- disable_url_suffix = get_secret_bool("LITELLM_ANTHROPIC_DISABLE_URL_SUFFIX")
- if (
- api_base is not None
- and not disable_url_suffix
- and not api_base.endswith("/v1/messages")
- ):
- api_base += "/v1/messages"
- elif disable_url_suffix:
- verbose_logger.debug(
- "LITELLM_ANTHROPIC_DISABLE_URL_SUFFIX is set, skipping /v1/messages suffix"
- )
-
- response = anthropic_chat_completions.completion(
- model=model,
- messages=messages,
- api_base=api_base,
- acompletion=acompletion,
- custom_prompt_dict=litellm.custom_prompt_dict,
- model_response=model_response,
- print_verbose=print_verbose,
- optional_params=optional_params,
- litellm_params=litellm_params,
- logger_fn=logger_fn,
- encoding=_get_encoding(), # for calculating input/output tokens
- api_key=api_key,
- logging_obj=logging,
- headers=headers,
- timeout=timeout,
- client=client,
- custom_llm_provider=custom_llm_provider,
- )
- if optional_params.get("stream", False) or acompletion is True:
- ## LOGGING
- logging.post_call(
- input=messages,
- api_key=api_key,
- original_response=response,
- )
- response = response
+ response = _complete_anthropic(_dispatch_ctx)
elif custom_llm_provider == "nlp_cloud":
- nlp_cloud_key = (
- api_key
- or litellm.nlp_cloud_key
- or get_secret("NLP_CLOUD_API_KEY")
- or litellm.api_key
- )
-
- api_base = (
- api_base
- or litellm.api_base
- or get_secret("NLP_CLOUD_API_BASE")
- or "https://api.nlpcloud.io/v1/gpu/"
- )
-
- response = nlp_cloud_chat_completion(
- model=model,
- messages=messages,
- api_base=api_base,
- model_response=model_response,
- print_verbose=print_verbose,
- optional_params=optional_params,
- litellm_params=litellm_params,
- logger_fn=logger_fn,
- encoding=_get_encoding(),
- api_key=nlp_cloud_key,
- logging_obj=logging,
- )
-
- if "stream" in optional_params and optional_params["stream"] is True:
- # don't try to access stream object,
- response = CustomStreamWrapper(
- response,
- model,
- custom_llm_provider="nlp_cloud",
- logging_obj=logging,
- )
-
- if optional_params.get("stream", False) or acompletion is True:
- ## LOGGING
- logging.post_call(
- input=messages,
- api_key=api_key,
- original_response=response,
- )
-
- response = response
+ response = _complete_nlp_cloud(_dispatch_ctx)
elif custom_llm_provider == "aleph_alpha":
- aleph_alpha_key = (
- api_key
- or litellm.aleph_alpha_key
- or get_secret("ALEPH_ALPHA_API_KEY")
- or get_secret("ALEPHALPHA_API_KEY")
- or litellm.api_key
- )
-
- api_base = (
- api_base
- or litellm.api_base
- or get_secret("ALEPH_ALPHA_API_BASE")
- or "https://api.aleph-alpha.com/complete"
- )
-
- model_response = aleph_alpha.completion(
- model=model,
- messages=messages,
- api_base=api_base,
- model_response=model_response,
- print_verbose=print_verbose,
- optional_params=optional_params,
- litellm_params=litellm_params,
- logger_fn=logger_fn,
- encoding=_get_encoding(),
- default_max_tokens_to_sample=litellm.max_tokens,
- api_key=aleph_alpha_key,
- logging_obj=logging, # model call logging done inside the class as we make need to modify I/O to fit aleph alpha's requirements
- )
-
- if "stream" in optional_params and optional_params["stream"] is True:
- # don't try to access stream object,
- response = CustomStreamWrapper(
- model_response,
- model,
- custom_llm_provider="aleph_alpha",
- logging_obj=logging,
- )
- return response
- response = model_response
+ response = _complete_aleph_alpha(_dispatch_ctx)
elif custom_llm_provider == "cohere_chat" or custom_llm_provider == "cohere":
- cohere_key = (
- api_key
- or litellm.cohere_key
- or get_secret_str("COHERE_API_KEY")
- or get_secret_str("CO_API_KEY")
- or litellm.api_key
- )
-
- cohere_route = CohereModelInfo.get_cohere_route(model)
- verbose_logger.debug(f"Cohere route: {cohere_route}")
- # Set API base based on route
- if cohere_route == "v2":
- api_base = (
- api_base
- or litellm.api_base
- or get_secret_str("COHERE_API_BASE")
- or "https://api.cohere.com/v2/chat"
- )
- # Remove v2/ prefix from model name for the actual API call
- if "v2/" in model:
- model = model.replace("v2/", "")
- else:
- api_base = (
- api_base
- or litellm.api_base
- or get_secret_str("COHERE_API_BASE")
- or "https://api.cohere.ai/v1/chat"
- )
-
- headers = headers or litellm.headers or {}
- if headers is None:
- headers = {}
-
- if extra_headers is not None:
- headers.update(extra_headers)
-
- verbose_logger.debug(f"Model: {model}, API Base: {api_base}")
- verbose_logger.debug(f"Provider Config: {provider_config}")
- response = base_llm_http_handler.completion(
- model=model,
- stream=stream,
- messages=messages,
- acompletion=acompletion,
- api_base=api_base,
- model_response=model_response,
- optional_params=optional_params,
- litellm_params=litellm_params,
- shared_session=shared_session,
- custom_llm_provider="cohere_chat",
- timeout=timeout,
- headers=headers,
- encoding=_get_encoding(),
- api_key=cohere_key,
- provider_config=provider_config,
- logging_obj=logging, # model call logging done inside the class as we make need to modify I/O to fit aleph alpha's requirements
- )
+ response = _complete_cohere_chat(_dispatch_ctx)
elif custom_llm_provider == "maritalk":
- maritalk_key = (
- api_key
- or litellm.maritalk_key
- or get_secret("MARITALK_API_KEY")
- or litellm.api_key
- )
-
- api_base = (
- api_base
- or litellm.api_base
- or get_secret("MARITALK_API_BASE")
- or "https://chat.maritaca.ai/api"
- )
-
- model_response = openai_like_chat_completion.completion(
- model=model,
- messages=messages,
- api_base=api_base,
- model_response=model_response,
- print_verbose=print_verbose,
- optional_params=optional_params,
- litellm_params=litellm_params,
- logger_fn=logger_fn,
- encoding=_get_encoding(),
- api_key=maritalk_key,
- logging_obj=logging,
- custom_llm_provider="maritalk",
- custom_prompt_dict=custom_prompt_dict,
- )
-
- response = model_response
+ response = _complete_maritalk(_dispatch_ctx)
elif custom_llm_provider == "amazon_nova":
- api_key = (
- api_key
- or litellm.amazon_nova_api_key
- or get_secret_str("AMAZON_NOVA_API_KEY")
- or litellm.api_key
- )
- api_base = (
- api_base
- or litellm.api_base
- or get_secret_str("AMAZON_NOVA_API_BASE")
- or "https://api.nova.amazon.com/v1"
- )
- response = openai_like_chat_completion.completion(
- model=model,
- messages=messages,
- api_base=api_base,
- model_response=model_response,
- print_verbose=print_verbose,
- optional_params=optional_params,
- litellm_params=litellm_params,
- logger_fn=logger_fn,
- encoding=_get_encoding(),
- api_key=api_key,
- logging_obj=logging,
- timeout=timeout,
- custom_llm_provider=custom_llm_provider,
- custom_prompt_dict=custom_prompt_dict,
- )
+ response = _complete_amazon_nova(_dispatch_ctx)
elif custom_llm_provider == "huggingface":
- huggingface_key = (
- api_key
- or litellm.huggingface_key
- or os.environ.get("HF_TOKEN")
- or os.environ.get("HUGGINGFACE_API_KEY")
- or litellm.api_key
- )
- hf_headers = headers or litellm.headers
- response = base_llm_http_handler.completion(
- model=model,
- messages=messages,
- headers=hf_headers,
- model_response=model_response,
- api_key=huggingface_key,
- api_base=api_base,
- acompletion=acompletion,
- logging_obj=logging,
- optional_params=optional_params,
- litellm_params=litellm_params,
- timeout=timeout, # type: ignore
- client=client,
- custom_llm_provider=custom_llm_provider,
- encoding=_get_encoding(),
- stream=stream,
- )
+ response = _complete_huggingface(_dispatch_ctx)
elif custom_llm_provider == "oci":
- response = base_llm_http_handler.completion(
- model=model,
- messages=messages,
- headers=headers,
- model_response=model_response,
- api_key=api_key,
- api_base=api_base,
- acompletion=acompletion,
- logging_obj=logging,
- optional_params=optional_params,
- litellm_params=litellm_params,
- timeout=timeout, # type: ignore
- client=client,
- custom_llm_provider=custom_llm_provider,
- encoding=_get_encoding(),
- stream=stream,
- )
+ response = _complete_oci(_dispatch_ctx)
elif custom_llm_provider == "compactifai":
- api_key = (
- api_key or get_secret_str("COMPACTIFAI_API_KEY") or litellm.api_key
- )
-
- api_base = api_base or "https://api.compactif.ai/v1"
-
- ## COMPLETION CALL
- response = base_llm_http_handler.completion(
- model=model,
- messages=messages,
- headers=headers,
- model_response=model_response,
- api_key=api_key,
- api_base=api_base,
- acompletion=acompletion,
- logging_obj=logging,
- optional_params=optional_params,
- litellm_params=litellm_params,
- timeout=timeout,
- client=client,
- custom_llm_provider=custom_llm_provider,
- encoding=_get_encoding(),
- stream=stream,
- provider_config=provider_config,
- )
+ response = _complete_compactifai(_dispatch_ctx)
elif custom_llm_provider == "oobabooga":
- custom_llm_provider = "oobabooga"
- model_response = oobabooga.completion(
- model=model,
- messages=messages,
- model_response=model_response,
- api_base=api_base, # type: ignore
- print_verbose=print_verbose,
- optional_params=optional_params,
- litellm_params=litellm_params,
- api_key=None,
- logger_fn=logger_fn,
- encoding=_get_encoding(),
- logging_obj=logging,
- )
- if "stream" in optional_params and optional_params["stream"] is True:
- # don't try to access stream object,
- response = CustomStreamWrapper(
- model_response,
- model,
- custom_llm_provider="oobabooga",
- logging_obj=logging,
- )
- return response
- response = model_response
+ response = _complete_oobabooga(_dispatch_ctx)
elif custom_llm_provider == "databricks":
- api_base = (
- api_base # for databricks we check in get_llm_provider and pass in the api base from there
- or litellm.api_base
- or os.getenv("DATABRICKS_API_BASE")
- )
-
- # set API KEY
- api_key = (
- api_key
- or litellm.api_key # for databricks we check in get_llm_provider and pass in the api key from there
- or litellm.databricks_key
- or get_secret("DATABRICKS_API_KEY")
- )
-
- headers = headers or litellm.headers
-
- ## COMPLETION CALL
- try:
- response = base_llm_http_handler.completion(
- model=model,
- stream=stream,
- messages=messages,
- acompletion=acompletion,
- api_base=api_base,
- model_response=model_response,
- optional_params=optional_params,
- litellm_params=litellm_params,
- custom_llm_provider="databricks",
- timeout=timeout,
- headers=headers,
- encoding=_get_encoding(),
- api_key=api_key,
- logging_obj=logging, # model call logging done inside the class as we make need to modify I/O to fit aleph alpha's requirements
- client=client,
- )
- except Exception as e:
- ## LOGGING - log the original exception returned
- logging.post_call(
- input=messages,
- api_key=api_key,
- original_response=str(e),
- additional_args={"headers": headers},
- )
- raise e
-
- if optional_params.get("stream", False):
- ## LOGGING
- logging.post_call(
- input=messages,
- api_key=api_key,
- original_response=response,
- additional_args={"headers": headers},
- )
+ response = _complete_databricks(_dispatch_ctx)
elif custom_llm_provider == "datarobot":
- response = base_llm_http_handler.completion(
- model=model,
- messages=messages,
- headers=headers,
- model_response=model_response,
- api_key=api_key,
- api_base=api_base,
- acompletion=acompletion,
- logging_obj=logging,
- optional_params=optional_params,
- litellm_params=litellm_params,
- timeout=timeout, # type: ignore
- client=client,
- custom_llm_provider=custom_llm_provider,
- encoding=_get_encoding(),
- stream=stream,
- provider_config=provider_config,
- )
+ response = _complete_datarobot(_dispatch_ctx)
elif custom_llm_provider == "openrouter":
- api_base = (
- api_base
- or litellm.api_base
- or get_secret_str("OPENROUTER_API_BASE")
- or "https://openrouter.ai/api/v1"
- )
-
- api_key = (
- api_key
- or litellm.api_key
- or litellm.openrouter_key
- or get_secret_str("OPENROUTER_API_KEY")
- or get_secret_str("OR_API_KEY")
- )
-
- openrouter_site_url = get_secret("OR_SITE_URL") or "https://litellm.ai"
- openrouter_app_name = get_secret("OR_APP_NAME") or "liteLLM"
-
- openrouter_headers = {
- "HTTP-Referer": openrouter_site_url,
- "X-Title": openrouter_app_name,
- }
-
- _headers = headers or litellm.headers
- if _headers:
- openrouter_headers.update(_headers)
-
- headers = openrouter_headers
-
- ## Load Config
- config = litellm.OpenrouterConfig.get_config()
- for k, v in config.items():
- if k == "extra_body":
- # we use openai 'extra_body' to pass openrouter specific params - transforms, route, models
- if "extra_body" in optional_params:
- optional_params[k].update(v)
- else:
- optional_params[k] = v
- elif k not in optional_params:
- optional_params[k] = v
-
- data = {"model": model, "messages": messages, **optional_params}
-
- ## COMPLETION CALL
- response = base_llm_http_handler.completion(
- model=model,
- stream=stream,
- messages=messages,
- acompletion=acompletion,
- api_base=api_base,
- model_response=model_response,
- optional_params=optional_params,
- litellm_params=litellm_params,
- shared_session=shared_session,
- custom_llm_provider="openrouter",
- timeout=timeout,
- headers=headers,
- encoding=_get_encoding(),
- api_key=api_key,
- logging_obj=logging, # model call logging done inside the class as we make need to modify I/O to fit aleph alpha's requirements
- client=client,
- )
- ## LOGGING
- logging.post_call(
- input=messages, api_key=openai.api_key, original_response=response
- )
+ response = _complete_openrouter(_dispatch_ctx)
elif custom_llm_provider == "vercel_ai_gateway":
- api_base = (
- api_base
- or litellm.api_base
- or get_secret_str("VERCEL_AI_GATEWAY_API_BASE")
- or "https://ai-gateway.vercel.sh/v1"
- )
-
- api_key = (
- api_key or litellm.api_key or get_secret("VERCEL_AI_GATEWAY_API_KEY")
- )
-
- vercel_site_url = get_secret("VERCEL_SITE_URL") or "https://litellm.ai"
- vercel_app_name = get_secret("VERCEL_APP_NAME") or "liteLLM"
-
- vercel_headers = {
- "http-referer": vercel_site_url,
- "x-title": vercel_app_name,
- }
-
- _headers = headers or litellm.headers
- if _headers:
- vercel_headers.update(_headers)
-
- headers = vercel_headers
-
- ## Load Config
- config = litellm.VercelAIGatewayConfig.get_config()
- for k, v in config.items():
- if k == "extra_body":
- # we use openai 'extra_body' to pass vercel specific params - providerOptions
- if "extra_body" in optional_params:
- optional_params[k].update(v)
- else:
- optional_params[k] = v
- elif k not in optional_params:
- optional_params[k] = v
-
- data = {"model": model, "messages": messages, **optional_params}
-
- ## COMPLETION CALL
- response = base_llm_http_handler.completion(
- model=model,
- stream=stream,
- messages=messages,
- acompletion=acompletion,
- api_base=api_base,
- model_response=model_response,
- optional_params=optional_params,
- litellm_params=litellm_params,
- shared_session=shared_session,
- custom_llm_provider="vercel_ai_gateway",
- timeout=timeout,
- headers=headers,
- encoding=_get_encoding(),
- api_key=api_key,
- logging_obj=logging, # model call logging done inside the class as we make need to modify I/O to fit aleph alpha's requirements
- client=client,
- )
- ## LOGGING
- logging.post_call(
- input=messages, api_key=openai.api_key, original_response=response
- )
+ response = _complete_vercel_ai_gateway(_dispatch_ctx)
elif (
custom_llm_provider == "together_ai"
or ("togethercomputer" in model)
@@ -3495,1114 +5786,75 @@ def completion( # type: ignore # noqa: PLR0915
"Palm was decommisioned on October 2024. Please use the `gemini/` route for Gemini Google AI Studio Models. Announcement: https://ai.google.dev/palm_docs/palm?hl=en"
)
elif custom_llm_provider == "vertex_ai_beta" or custom_llm_provider == "gemini":
- vertex_ai_project = (
- optional_params.pop("vertex_project", None)
- or optional_params.pop("vertex_ai_project", None)
- or litellm.vertex_project
- or get_secret("VERTEXAI_PROJECT")
- )
- vertex_ai_location = (
- optional_params.pop("vertex_location", None)
- or optional_params.pop("vertex_ai_location", None)
- or litellm.vertex_location
- or get_secret("VERTEXAI_LOCATION")
- )
- vertex_credentials = (
- optional_params.pop("vertex_credentials", None)
- or optional_params.pop("vertex_ai_credentials", None)
- or get_secret("VERTEXAI_CREDENTIALS")
- )
-
- gemini_api_key = (
- api_key
- or get_api_key_from_env()
- or get_secret("PALM_API_KEY") # older palm api key should also work
- or litellm.api_key
- )
-
- api_base = api_base or litellm.api_base or get_secret("GEMINI_API_BASE")
- new_params = safe_deep_copy(optional_params or {})
- response = vertex_chat_completion.completion( # type: ignore
- model=model,
- messages=messages,
- model_response=model_response,
- print_verbose=print_verbose,
- optional_params=new_params,
- litellm_params=litellm_params, # type: ignore
- logger_fn=logger_fn,
- encoding=_get_encoding(),
- vertex_location=vertex_ai_location,
- vertex_project=vertex_ai_project,
- vertex_credentials=vertex_credentials,
- gemini_api_key=gemini_api_key,
- logging_obj=logging,
- acompletion=acompletion,
- timeout=timeout,
- custom_llm_provider=custom_llm_provider, # type: ignore
- client=client,
- api_base=api_base,
- extra_headers=headers,
- )
+ response = _complete_vertex_ai_beta(_dispatch_ctx)
elif custom_llm_provider == "vertex_ai":
- vertex_ai_project = (
- optional_params.pop("vertex_project", None)
- or optional_params.pop("vertex_ai_project", None)
- or litellm.vertex_project
- or get_secret("VERTEXAI_PROJECT")
- )
- vertex_ai_location = (
- optional_params.pop("vertex_location", None)
- or optional_params.pop("vertex_ai_location", None)
- or litellm.vertex_location
- or get_secret("VERTEXAI_LOCATION")
- )
- vertex_credentials = (
- optional_params.pop("vertex_credentials", None)
- or optional_params.pop("vertex_ai_credentials", None)
- or get_secret("VERTEXAI_CREDENTIALS")
- )
-
- api_base = api_base or litellm.api_base or get_secret("VERTEXAI_API_BASE")
-
- new_params = safe_deep_copy(optional_params or {})
- model_route = get_vertex_ai_model_route(
- model=model, litellm_params=litellm_params
- )
-
- if model_route == VertexAIModelRoute.PARTNER_MODELS:
- model_response = vertex_partner_models_chat_completion.completion(
- model=model,
- messages=messages,
- model_response=model_response,
- print_verbose=print_verbose,
- optional_params=new_params,
- litellm_params=litellm_params, # type: ignore
- logger_fn=logger_fn,
- encoding=_get_encoding(),
- api_base=api_base,
- vertex_location=vertex_ai_location,
- vertex_project=vertex_ai_project,
- vertex_credentials=vertex_credentials,
- logging_obj=logging,
- acompletion=acompletion,
- headers=headers,
- custom_prompt_dict=custom_prompt_dict,
- timeout=timeout,
- client=client,
- )
- elif model_route == VertexAIModelRoute.GEMINI:
- model_response = vertex_chat_completion.completion( # type: ignore
- model=model,
- messages=messages,
- model_response=model_response,
- print_verbose=print_verbose,
- optional_params=new_params,
- litellm_params=litellm_params, # type: ignore
- logger_fn=logger_fn,
- encoding=_get_encoding(),
- vertex_location=vertex_ai_location,
- vertex_project=vertex_ai_project,
- vertex_credentials=vertex_credentials,
- gemini_api_key=None,
- logging_obj=logging,
- acompletion=acompletion,
- timeout=timeout,
- custom_llm_provider=custom_llm_provider, # type: ignore
- client=client,
- api_base=api_base,
- extra_headers=headers,
- )
- elif model_route == VertexAIModelRoute.GEMMA:
- # Vertex Gemma Models with custom prediction endpoint
- model_response = vertex_gemma_chat_completion.completion(
- model=model,
- messages=messages,
- model_response=model_response,
- print_verbose=print_verbose,
- optional_params=new_params,
- litellm_params=litellm_params, # type: ignore
- logger_fn=logger_fn,
- encoding=_get_encoding(),
- api_base=api_base,
- vertex_location=vertex_ai_location,
- vertex_project=vertex_ai_project,
- vertex_credentials=vertex_credentials,
- logging_obj=logging,
- acompletion=acompletion,
- headers=headers,
- custom_prompt_dict=custom_prompt_dict,
- timeout=timeout,
- client=client,
- )
- elif model_route == VertexAIModelRoute.MODEL_GARDEN:
- # Vertex Model Garden - OpenAI compatible models
- model_response = vertex_model_garden_chat_completion.completion(
- model=model,
- messages=messages,
- model_response=model_response,
- print_verbose=print_verbose,
- optional_params=new_params,
- litellm_params=litellm_params, # type: ignore
- logger_fn=logger_fn,
- encoding=_get_encoding(),
- api_base=api_base,
- vertex_location=vertex_ai_location,
- vertex_project=vertex_ai_project,
- vertex_credentials=vertex_credentials,
- logging_obj=logging,
- acompletion=acompletion,
- headers=headers,
- custom_prompt_dict=custom_prompt_dict,
- timeout=timeout,
- client=client,
- )
- elif model_route == VertexAIModelRoute.AGENT_ENGINE:
- # Vertex AI Agent Engine (Reasoning Engines)
- from litellm.llms.vertex_ai.agent_engine.transformation import (
- VertexAgentEngineConfig,
- )
-
- vertex_agent_engine_config = VertexAgentEngineConfig()
-
- # Update litellm_params with vertex credentials
- litellm_params["vertex_project"] = vertex_ai_project
- litellm_params["vertex_location"] = vertex_ai_location
- litellm_params["vertex_credentials"] = vertex_credentials
-
- model_response = base_llm_http_handler.completion(
- model=model,
- stream=stream,
- messages=messages,
- model_response=model_response,
- optional_params=new_params,
- litellm_params=litellm_params, # type: ignore
- encoding=_get_encoding(),
- api_key=None,
- api_base=api_base,
- logging_obj=logging,
- acompletion=acompletion,
- timeout=timeout,
- client=client,
- custom_llm_provider="vertex_ai",
- provider_config=vertex_agent_engine_config,
- headers=headers or {},
- )
- else: # VertexAIModelRoute.NON_GEMINI
- model_response = vertex_ai_non_gemini.completion(
- model=model,
- messages=messages,
- model_response=model_response,
- print_verbose=print_verbose,
- optional_params=new_params,
- litellm_params=litellm_params,
- logger_fn=logger_fn,
- encoding=_get_encoding(),
- vertex_location=vertex_ai_location,
- vertex_project=vertex_ai_project,
- vertex_credentials=vertex_credentials,
- logging_obj=logging,
- acompletion=acompletion,
- )
-
- if (
- "stream" in optional_params
- and optional_params["stream"] is True
- and acompletion is False
- ):
- response = CustomStreamWrapper(
- model_response,
- model,
- custom_llm_provider="vertex_ai",
- logging_obj=logging,
- )
- return response
- response = model_response
+ response = _complete_vertex_ai(_dispatch_ctx)
elif custom_llm_provider == "predibase":
- tenant_id = (
- optional_params.pop("tenant_id", None)
- or optional_params.pop("predibase_tenant_id", None)
- or litellm.predibase_tenant_id
- or get_secret("PREDIBASE_TENANT_ID")
- )
-
- if tenant_id is None:
- raise ValueError(
- "Missing Predibase Tenant ID - Required for making the request. Set dynamically (e.g. `completion(..tenant_id=)`) or in env - `PREDIBASE_TENANT_ID`."
- )
-
- api_base = (
- api_base
- or optional_params.pop("api_base", None)
- or optional_params.pop("base_url", None)
- or litellm.api_base
- or get_secret("PREDIBASE_API_BASE")
- )
-
- api_key = (
- api_key
- or litellm.api_key
- or litellm.predibase_key
- or get_secret("PREDIBASE_API_KEY")
- )
-
- _model_response = predibase_chat_completions.completion(
- model=model,
- messages=messages,
- model_response=model_response,
- print_verbose=print_verbose,
- optional_params=optional_params,
- litellm_params=litellm_params,
- logger_fn=logger_fn,
- encoding=_get_encoding(),
- logging_obj=logging,
- acompletion=acompletion,
- api_base=api_base,
- custom_prompt_dict=custom_prompt_dict,
- api_key=api_key,
- tenant_id=tenant_id,
- timeout=timeout,
- )
-
- if (
- "stream" in optional_params
- and optional_params["stream"] is True
- and acompletion is False
- ):
- return _model_response
- response = _model_response
+ response = _complete_predibase(_dispatch_ctx)
elif custom_llm_provider == "text-completion-codestral":
- api_base = (
- api_base
- or optional_params.pop("api_base", None)
- or optional_params.pop("base_url", None)
- or litellm.api_base
- or "https://codestral.mistral.ai/v1/fim/completions"
- )
-
- api_key = api_key or litellm.api_key or get_secret("CODESTRAL_API_KEY")
-
- text_completion_model_response = litellm.TextCompletionResponse(
- stream=stream
- )
-
- _model_response = codestral_text_completions.completion( # type: ignore
- model=model,
- messages=messages,
- model_response=text_completion_model_response,
- print_verbose=print_verbose,
- optional_params=optional_params,
- litellm_params=litellm_params,
- logger_fn=logger_fn,
- encoding=_get_encoding(),
- logging_obj=logging,
- acompletion=acompletion,
- api_base=api_base,
- custom_prompt_dict=custom_prompt_dict,
- api_key=api_key,
- timeout=timeout,
- )
-
- if (
- "stream" in optional_params
- and optional_params["stream"] is True
- and acompletion is False
- ):
- return _model_response
- response = _model_response
+ response = _complete_text_completion_codestral(_dispatch_ctx)
elif custom_llm_provider == "text-completion-inception":
- passed_api_base = (
- api_base
- or optional_params.pop("api_base", None)
- or optional_params.pop("base_url", None)
- )
- api_base = (
- passed_api_base
- or get_secret_str("INCEPTION_API_BASE")
- or "https://api.inceptionlabs.ai/v1"
- )
- # FIM is served at `/v1/fim/completions`; the OpenAI client appends
- # `/completions`, so point it at the `/v1/fim` base.
- api_base = api_base.rstrip("/")
- if not api_base.endswith("/fim"):
- api_base += "/fim"
-
- # Don't forward the server-managed Inception key to a caller-supplied
- # api_base; only resolve it for the default/server base, or when the
- # caller passes their own key.
- if passed_api_base is None or api_key:
- api_key = (
- api_key
- or litellm.inception_key
- or get_secret_str("INCEPTION_API_KEY")
- )
-
- _response = openai_text_completions.completion(
- model=model,
- messages=messages,
- model_response=model_response,
- print_verbose=print_verbose,
- api_key=api_key, # type: ignore[arg-type]
- custom_llm_provider="text-completion-inception",
- api_base=api_base,
- acompletion=acompletion,
- client=client,
- logging_obj=logging,
- optional_params=optional_params,
- litellm_params=litellm_params,
- logger_fn=logger_fn,
- timeout=timeout, # type: ignore
- )
-
- if (
- optional_params.get("stream", False) is False
- and acompletion is False
- and text_completion is False
- ):
- _response = litellm.OpenAITextCompletionConfig().convert_to_chat_model_response_object(
- response_object=_response, model_response_object=model_response
- )
-
- if optional_params.get("stream", False) or acompletion is True:
- logging.post_call(
- input=messages,
- api_key=api_key,
- original_response=_response,
- additional_args={"headers": headers},
- )
- response = _response
+ response = _complete_text_completion_inception(_dispatch_ctx)
elif custom_llm_provider in ("sagemaker_chat", "sagemaker_nova"):
# boto3 reads keys from .env
# sagemaker_chat: HF Messages API endpoints
# sagemaker_nova: Nova models on SageMaker (OpenAI-compatible)
- model_response = base_llm_http_handler.completion(
- model=model,
- stream=stream,
- messages=messages,
- acompletion=acompletion,
- api_base=api_base,
- model_response=model_response,
- optional_params=optional_params,
- litellm_params=litellm_params,
- custom_llm_provider=custom_llm_provider,
- timeout=timeout,
- headers=headers,
- encoding=_get_encoding(),
- api_key=api_key,
- logging_obj=logging, # model call logging done inside the class as we make need to modify I/O to fit aleph alpha's requirements
- client=client,
- )
-
- ## RESPONSE OBJECT
- response = model_response
+ response = _complete_sagemaker_chat(_dispatch_ctx)
elif custom_llm_provider == "sagemaker":
# boto3 reads keys from .env
- model_response = sagemaker_llm.completion(
- model=model,
- messages=messages,
- model_response=model_response,
- print_verbose=print_verbose,
- optional_params=optional_params,
- litellm_params=litellm_params,
- custom_prompt_dict=custom_prompt_dict,
- hf_model_name=hf_model_name,
- logger_fn=logger_fn,
- encoding=_get_encoding(),
- logging_obj=logging,
- acompletion=acompletion,
- )
-
- ## RESPONSE OBJECT
- response = model_response
+ response = _complete_sagemaker(_dispatch_ctx)
elif custom_llm_provider == "bedrock":
# boto3 reads keys from .env
- custom_prompt_dict = custom_prompt_dict or litellm.custom_prompt_dict
-
- if "aws_bedrock_client" in optional_params:
- verbose_logger.warning(
- "'aws_bedrock_client' is a deprecated param. Please move to another auth method - https://docs.litellm.ai/docs/providers/bedrock#boto3---authentication."
- )
- # Extract credentials for legacy boto3 client and pass thru to httpx
- aws_bedrock_client = optional_params.pop("aws_bedrock_client")
- creds = aws_bedrock_client._get_credentials().get_frozen_credentials()
-
- if creds.access_key:
- optional_params["aws_access_key_id"] = creds.access_key
- if creds.secret_key:
- optional_params["aws_secret_access_key"] = creds.secret_key
- if creds.token:
- optional_params["aws_session_token"] = creds.token
- if (
- "aws_region_name" not in optional_params
- or optional_params["aws_region_name"] is None
- ):
- optional_params["aws_region_name"] = (
- aws_bedrock_client.meta.region_name
- )
-
- bedrock_route = BedrockModelInfo.get_bedrock_route(model)
- if bedrock_route == "claude_platform":
- provider_config = ProviderConfigManager.get_provider_chat_config(
- model=model,
- provider=LlmProviders.BEDROCK,
- )
- model = BedrockModelInfo.get_claude_platform_model(model)
- response = base_llm_http_handler.completion(
- model=model,
- stream=stream,
- messages=messages,
- acompletion=acompletion,
- api_base=api_base,
- model_response=model_response,
- optional_params=optional_params,
- litellm_params=litellm_params,
- shared_session=shared_session,
- custom_llm_provider="bedrock",
- timeout=timeout,
- headers=headers,
- encoding=_get_encoding(),
- api_key=api_key,
- logging_obj=logging,
- client=client,
- provider_config=provider_config,
- )
- return response
- elif bedrock_route == "converse":
- model = model.replace("converse/", "")
- response = bedrock_converse_chat_completion.completion(
- model=model,
- messages=messages,
- custom_prompt_dict=custom_prompt_dict,
- model_response=model_response,
- optional_params=optional_params,
- litellm_params=litellm_params, # type: ignore
- logger_fn=logger_fn,
- encoding=_get_encoding(),
- logging_obj=logging,
- extra_headers=headers, # Use merged headers instead of original extra_headers
- timeout=timeout,
- acompletion=acompletion,
- client=client,
- api_base=api_base,
- api_key=api_key,
- )
- elif bedrock_route == "converse_like":
- model = model.replace("converse_like/", "")
- response = base_llm_http_handler.completion(
- model=model,
- stream=stream,
- messages=messages,
- acompletion=acompletion,
- api_base=api_base,
- model_response=model_response,
- optional_params=optional_params,
- litellm_params=litellm_params,
- custom_llm_provider="bedrock",
- timeout=timeout,
- headers=headers,
- encoding=_get_encoding(),
- api_key=api_key,
- logging_obj=logging, # model call logging done inside the class as we make need to modify I/O to fit aleph alpha's requirements
- client=client,
- )
- else:
- response = base_llm_http_handler.completion(
- model=model,
- stream=stream,
- messages=messages,
- acompletion=acompletion,
- api_base=api_base,
- model_response=model_response,
- optional_params=optional_params,
- litellm_params=litellm_params,
- custom_llm_provider="bedrock",
- timeout=timeout,
- headers=headers,
- encoding=_get_encoding(),
- api_key=api_key,
- logging_obj=logging,
- client=client,
- )
+ response = _complete_bedrock(_dispatch_ctx)
elif custom_llm_provider == "watsonx":
- response = watsonx_chat_completion.completion(
- model=model,
- messages=messages,
- headers=headers,
- model_response=model_response,
- print_verbose=print_verbose,
- api_key=api_key,
- api_base=api_base,
- acompletion=acompletion,
- logging_obj=logging,
- optional_params=optional_params,
- litellm_params=litellm_params,
- logger_fn=logger_fn,
- timeout=timeout, # type: ignore
- custom_prompt_dict=custom_prompt_dict,
- client=client, # pass AsyncOpenAI, OpenAI client
- encoding=_get_encoding(),
- custom_llm_provider="watsonx",
- )
+ response = _complete_watsonx(_dispatch_ctx)
elif custom_llm_provider == "watsonx_text":
- api_key = (
- api_key
- or optional_params.pop("apikey", None)
- or get_secret_str("WATSONX_APIKEY")
- or get_secret_str("WATSONX_API_KEY")
- or get_secret_str("WX_API_KEY")
- )
-
- api_base = (
- api_base
- or optional_params.pop(
- "url",
- optional_params.pop(
- "api_base", optional_params.pop("base_url", None)
- ),
- )
- or get_secret_str("WATSONX_API_BASE")
- or get_secret_str("WATSONX_URL")
- or get_secret_str("WX_URL")
- or get_secret_str("WML_URL")
- )
-
- wx_credentials = optional_params.pop(
- "wx_credentials",
- optional_params.pop(
- "watsonx_credentials", None
- ), # follow {provider}_credentials, same as vertex ai
- )
-
- token: Optional[str] = None
- if wx_credentials is not None:
- api_base = wx_credentials.get("url", api_base)
- api_key = wx_credentials.get(
- "apikey", wx_credentials.get("api_key", api_key)
- )
- token = wx_credentials.get(
- "token",
- wx_credentials.get(
- "watsonx_token", None
- ), # follow format of {provider}_token, same as azure - e.g. 'azure_ad_token=..'
- )
-
- if token is not None:
- optional_params["token"] = token
-
- response = base_llm_http_handler.completion(
- model=model,
- stream=stream,
- messages=messages,
- acompletion=acompletion,
- api_base=api_base,
- model_response=model_response,
- optional_params=optional_params,
- litellm_params=litellm_params,
- shared_session=shared_session,
- custom_llm_provider="watsonx_text",
- timeout=timeout,
- headers=headers,
- encoding=_get_encoding(),
- api_key=api_key,
- logging_obj=logging, # model call logging done inside the class as we make need to modify I/O to fit aleph alpha's requirements
- client=client,
- )
+ response = _complete_watsonx_text(_dispatch_ctx)
elif custom_llm_provider == "vllm":
- custom_prompt_dict = custom_prompt_dict or litellm.custom_prompt_dict
- model_response = vllm_handler.completion(
- model=model,
- messages=messages,
- custom_prompt_dict=custom_prompt_dict,
- model_response=model_response,
- print_verbose=print_verbose,
- optional_params=optional_params,
- litellm_params=litellm_params,
- logger_fn=logger_fn,
- encoding=_get_encoding(),
- logging_obj=logging,
- )
-
- if (
- "stream" in optional_params and optional_params["stream"] is True
- ): ## [BETA]
- # don't try to access stream object,
- response = CustomStreamWrapper(
- model_response,
- model,
- custom_llm_provider="vllm",
- logging_obj=logging,
- )
- return response
-
- ## RESPONSE OBJECT
- response = model_response
+ response = _complete_vllm(_dispatch_ctx)
elif custom_llm_provider == "ollama":
- api_base = (
- litellm.api_base
- or api_base
- or get_secret("OLLAMA_API_BASE")
- or "http://localhost:11434"
- )
- if api_key is not None and "Authorization" not in headers:
- headers["Authorization"] = f"Bearer {api_key}"
-
- response = base_llm_http_handler.completion(
- model=model,
- stream=stream,
- messages=messages,
- acompletion=acompletion,
- api_base=api_base,
- model_response=model_response,
- optional_params=optional_params,
- litellm_params=litellm_params,
- shared_session=shared_session,
- custom_llm_provider="ollama",
- timeout=timeout,
- headers=headers,
- encoding=_get_encoding(),
- api_key=api_key,
- logging_obj=logging, # model call logging done inside the class as we make need to modify I/O to fit aleph alpha's requirements
- client=client,
- )
+ response = _complete_ollama(_dispatch_ctx)
elif custom_llm_provider == "ollama_chat":
- api_base = (
- litellm.api_base
- or api_base
- or get_secret("OLLAMA_API_BASE")
- or "http://localhost:11434"
- )
-
- api_key = (
- api_key
- or litellm.ollama_key
- or os.environ.get("OLLAMA_API_KEY")
- or litellm.api_key
- )
- if api_key is not None and "Authorization" not in headers:
- headers["Authorization"] = f"Bearer {api_key}"
-
- response = base_llm_http_handler.completion(
- model=model,
- stream=stream,
- messages=messages,
- acompletion=acompletion,
- api_base=api_base,
- model_response=model_response,
- optional_params=optional_params,
- litellm_params=litellm_params,
- shared_session=shared_session,
- custom_llm_provider="ollama_chat",
- timeout=timeout,
- headers=headers,
- encoding=_get_encoding(),
- api_key=api_key,
- logging_obj=logging, # model call logging done inside the class as we make need to modify I/O to fit aleph alpha's requirements
- client=client,
- )
+ response = _complete_ollama_chat(_dispatch_ctx)
elif custom_llm_provider == "triton":
- api_base = litellm.api_base or api_base
- response = base_llm_http_handler.completion(
- model=model,
- stream=stream,
- messages=messages,
- acompletion=acompletion,
- api_base=api_base,
- model_response=model_response,
- optional_params=optional_params,
- litellm_params=litellm_params,
- shared_session=shared_session,
- custom_llm_provider=custom_llm_provider,
- timeout=timeout,
- headers=headers,
- encoding=_get_encoding(),
- api_key=api_key,
- logging_obj=logging,
- )
+ response = _complete_triton(_dispatch_ctx)
elif custom_llm_provider == "cloudflare":
- api_key = (
- api_key
- or litellm.cloudflare_api_key
- or litellm.api_key
- or get_secret("CLOUDFLARE_API_KEY")
- )
- account_id = get_secret("CLOUDFLARE_ACCOUNT_ID")
- api_base = (
- api_base
- or litellm.api_base
- or get_secret("CLOUDFLARE_API_BASE")
- or f"https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run/"
- )
-
- custom_prompt_dict = custom_prompt_dict or litellm.custom_prompt_dict
- response = base_llm_http_handler.completion(
- model=model,
- stream=stream,
- messages=messages,
- acompletion=acompletion,
- api_base=api_base,
- model_response=model_response,
- optional_params=optional_params,
- litellm_params=litellm_params,
- shared_session=shared_session,
- custom_llm_provider="cloudflare",
- timeout=timeout,
- headers=headers,
- encoding=_get_encoding(),
- api_key=api_key,
- logging_obj=logging, # model call logging done inside the class as we make need to modify I/O to fit aleph alpha's requirements
- )
+ response = _complete_cloudflare(_dispatch_ctx)
elif custom_llm_provider == "petals" or model in litellm.petals_models:
- api_base = api_base or litellm.api_base
-
- custom_llm_provider = "petals"
- stream = optional_params.pop("stream", False)
- model_response = petals_handler.completion(
- model=model,
- messages=messages,
- api_base=api_base,
- model_response=model_response,
- print_verbose=print_verbose,
- optional_params=optional_params,
- litellm_params=litellm_params,
- logger_fn=logger_fn,
- encoding=_get_encoding(),
- logging_obj=logging,
- client=client,
- )
- if stream is True: ## [BETA]
- # Fake streaming for petals
- resp_string = model_response["choices"][0]["message"]["content"]
- response = CustomStreamWrapper(
- resp_string,
- model,
- custom_llm_provider="petals",
- logging_obj=logging,
- )
- return response
- response = model_response
+ response = _complete_petals(_dispatch_ctx)
elif custom_llm_provider == "snowflake" or model in litellm.snowflake_models:
- try:
- client = (
- HTTPHandler(timeout=timeout) if stream is False else None
- ) # Keep this here, otherwise, the httpx.client closes and streaming is impossible
- response = base_llm_http_handler.completion(
- model=model,
- messages=messages,
- headers=headers,
- model_response=model_response,
- api_key=api_key,
- api_base=api_base,
- acompletion=acompletion,
- logging_obj=logging,
- optional_params=optional_params,
- litellm_params=litellm_params,
- shared_session=shared_session,
- timeout=timeout, # type: ignore
- client=client,
- custom_llm_provider=custom_llm_provider,
- encoding=_get_encoding(),
- stream=stream,
- )
-
- except Exception as e:
- ## LOGGING - log the original exception returned
- logging.post_call(
- input=messages,
- api_key=api_key,
- original_response=str(e),
- additional_args={"headers": headers},
- )
- raise e
+ response = _complete_snowflake(_dispatch_ctx)
elif custom_llm_provider == "gradient_ai":
- api_base = litellm.api_base or api_base
- response = base_llm_http_handler.completion(
- model=model,
- stream=stream,
- messages=messages,
- acompletion=acompletion,
- api_base=api_base,
- model_response=model_response,
- optional_params=optional_params,
- litellm_params=litellm_params,
- shared_session=shared_session,
- custom_llm_provider="gradient_ai",
- timeout=timeout,
- headers=headers,
- encoding=_get_encoding(),
- api_key=api_key,
- logging_obj=logging,
- )
+ response = _complete_gradient_ai(_dispatch_ctx)
elif custom_llm_provider == "bytez":
- api_key = (
- api_key
- or litellm.bytez_key
- or get_secret_str("BYTEZ_API_KEY")
- or litellm.api_key
- )
-
- response = base_llm_http_handler.completion(
- model=model,
- messages=messages,
- headers=headers,
- model_response=model_response,
- api_key=api_key,
- api_base=api_base,
- acompletion=acompletion,
- logging_obj=logging,
- optional_params=optional_params,
- litellm_params=litellm_params,
- timeout=timeout, # type: ignore
- client=client,
- custom_llm_provider=custom_llm_provider,
- encoding=_get_encoding(),
- stream=stream,
- provider_config=bytez_transformation,
- )
-
- pass
+ response = _complete_bytez(_dispatch_ctx)
elif custom_llm_provider == "lemonade":
- api_key = (
- api_key
- or litellm.lemonade_key
- or get_secret_str("LEMONADE_API_KEY")
- or litellm.api_key
- )
-
- response = base_llm_http_handler.completion(
- model=model,
- messages=messages,
- headers=headers,
- model_response=model_response,
- api_key=api_key,
- api_base=api_base,
- acompletion=acompletion,
- logging_obj=logging,
- optional_params=optional_params,
- litellm_params=litellm_params,
- timeout=timeout, # type: ignore
- client=client,
- custom_llm_provider=custom_llm_provider,
- encoding=_get_encoding(),
- stream=stream,
- provider_config=lemonade_transformation,
- )
-
- pass
+ response = _complete_lemonade(_dispatch_ctx)
elif custom_llm_provider == "ovhcloud" or model in litellm.ovhcloud_models:
- api_key = (
- api_key
- or litellm.ovhcloud_key
- or get_secret_str("OVHCLOUD_API_KEY")
- or litellm.api_key
- )
-
- api_base = (
- api_base
- or litellm.api_base
- or get_secret_str("OVHCLOUD_API_BASE")
- or "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1"
- )
-
- response = base_llm_http_handler.completion(
- model=model,
- messages=messages,
- headers=headers,
- model_response=model_response,
- api_key=api_key,
- api_base=api_base,
- acompletion=acompletion,
- logging_obj=logging,
- optional_params=optional_params,
- litellm_params=litellm_params,
- timeout=timeout, # type: ignore
- client=client,
- custom_llm_provider=custom_llm_provider,
- encoding=_get_encoding(),
- stream=stream,
- provider_config=ovhcloud_transformation,
- )
-
- pass
+ response = _complete_ovhcloud(_dispatch_ctx)
elif custom_llm_provider == "custom":
- url = litellm.api_base or api_base or ""
- if url is None or url == "":
- raise ValueError(
- "api_base not set. Set api_base or litellm.api_base for custom endpoints"
- )
-
- """
- assume input to custom LLM api bases follow this format:
- resp = litellm.module_level_client.post(
- api_base,
- json={
- 'model': 'meta-llama/Llama-2-13b-hf', # model name
- 'params': {
- 'prompt': ["The capital of France is P"],
- 'max_tokens': 32,
- 'temperature': 0.7,
- 'top_p': 1.0,
- 'top_k': 40,
- }
- }
- )
-
- """
- prompt = " ".join([message["content"] for message in messages]) # type: ignore
- resp = litellm.module_level_client.post(
- url,
- headers=headers,
- json={
- "model": model,
- "params": {
- "prompt": [prompt],
- "max_tokens": max_tokens,
- "temperature": temperature,
- "top_p": top_p,
- "top_k": kwargs.get("top_k"),
- },
- **kwargs.get("extra_body", {}),
- },
- )
- response_json = resp.json()
- """
- assume all responses from custom api_bases of this format:
- {
- 'data': [
- {
- 'prompt': 'The capital of France is P',
- 'output': ['The capital of France is PARIS.\nThe capital of France is PARIS.\nThe capital of France is PARIS.\nThe capital of France is PARIS.\nThe capital of France is PARIS.\nThe capital of France is PARIS.\nThe capital of France is PARIS.\nThe capital of France is PARIS.\nThe capital of France is PARIS.\nThe capital of France is PARIS.\nThe capital of France is PARIS.\nThe capital of France is PARIS.\nThe capital of France is PARIS.\nThe capital of France'],
- 'params': {'temperature': 0.7, 'top_k': 40, 'top_p': 1}}],
- 'message': 'ok'
- }
- ]
- }
- """
- string_response = response_json["data"][0]["output"][0]
- ## RESPONSE OBJECT
- model_response.choices[0].message.content = string_response # type: ignore
- model_response.created = int(time.time())
- model_response.model = model
- response = model_response
+ response = _complete_custom(_dispatch_ctx)
elif (
custom_llm_provider in litellm._custom_providers
): # Assume custom LLM provider
# Get the Custom Handler
- custom_handler: Optional[CustomLLM] = None
- for item in litellm.custom_provider_map:
- if item["provider"] == custom_llm_provider:
- custom_handler = item["custom_handler"]
-
- if custom_handler is None:
- raise LiteLLMUnknownProvider(
- model=model, custom_llm_provider=custom_llm_provider
- )
-
- ## ROUTE LLM CALL ##
- handler_fn = custom_chat_llm_router(
- async_fn=acompletion, stream=stream, custom_llm=custom_handler
- )
-
- headers = headers or litellm.headers or {}
-
- ## CALL FUNCTION
- response = handler_fn(
- model=model,
- messages=messages,
- headers=headers,
- model_response=model_response,
- print_verbose=print_verbose,
- api_key=api_key,
- api_base=api_base,
- acompletion=acompletion,
- logging_obj=logging,
- optional_params=optional_params,
- litellm_params=litellm_params,
- logger_fn=logger_fn,
- timeout=timeout, # type: ignore
- custom_prompt_dict=custom_prompt_dict,
- client=client, # pass AsyncOpenAI, OpenAI client
- encoding=_get_encoding(),
- )
- if stream is True:
- return CustomStreamWrapper(
- completion_stream=response,
- model=model,
- custom_llm_provider=custom_llm_provider,
- logging_obj=logging,
- )
+ response = _complete_custom_providers(_dispatch_ctx)
elif custom_llm_provider == "langgraph":
# LangGraph - Agent Runtime Provider
- from litellm.llms.langgraph.chat.transformation import LangGraphConfig
-
- (
- api_base,
- api_key,
- ) = LangGraphConfig()._get_openai_compatible_provider_info(
- api_base=api_base or litellm.api_base,
- api_key=api_key or litellm.api_key,
- )
-
- headers = headers or litellm.headers
-
- response = base_llm_http_handler.completion(
- model=model,
- stream=stream,
- messages=messages,
- acompletion=acompletion,
- api_base=api_base,
- model_response=model_response,
- optional_params=optional_params,
- litellm_params=litellm_params,
- shared_session=shared_session,
- custom_llm_provider=custom_llm_provider,
- timeout=timeout,
- headers=headers,
- encoding=_get_encoding(),
- api_key=api_key,
- logging_obj=logging,
- client=client,
- )
+ response = _complete_langgraph(_dispatch_ctx)
elif custom_llm_provider == "langflow":
# LangFlow - Visual AI Agent Platform
- from litellm.llms.langflow.chat.transformation import LangFlowConfig
-
- (
- api_base,
- api_key,
- ) = LangFlowConfig()._get_openai_compatible_provider_info(
- api_base=api_base or litellm.api_base,
- api_key=api_key or litellm.api_key,
- )
-
- headers = headers or litellm.headers
-
- response = base_llm_http_handler.completion(
- model=model,
- stream=stream,
- messages=messages,
- acompletion=acompletion,
- api_base=api_base,
- model_response=model_response,
- optional_params=optional_params,
- litellm_params=litellm_params,
- shared_session=shared_session,
- custom_llm_provider=custom_llm_provider,
- timeout=timeout,
- headers=headers,
- encoding=_get_encoding(),
- api_key=api_key,
- logging_obj=logging,
- client=client,
- )
+ response = _complete_langflow(_dispatch_ctx)
else:
raise LiteLLMUnknownProvider(
@@ -4869,7 +6121,7 @@ def embedding(
@client
-def embedding( # noqa: PLR0915
+def embedding(
model,
input=[],
# Optional params
@@ -6116,7 +7368,7 @@ async def atext_completion(
@client
-def text_completion( # noqa: PLR0915
+def text_completion(
prompt: Union[
str, List[Union[str, List[Union[str, List[int]]]]]
], # Required: The prompt(s) to generate completions for.
@@ -6655,7 +7907,7 @@ async def atranscription(*args, **kwargs) -> TranscriptionResponse:
@client
-def transcription( # noqa: PLR0915
+def transcription(
model: str,
file: FileTypes,
## OPTIONAL OPENAI PARAMS ##
@@ -6962,7 +8214,7 @@ async def aspeech(*args, **kwargs) -> HttpxBinaryResponseContent:
@client
-def speech( # noqa: PLR0915
+def speech(
model: str,
input: str,
voice: Optional[Union[str, dict]] = None,
@@ -7425,22 +8677,7 @@ def speech( # noqa: PLR0915
async def ahealth_check(
model_params: dict,
- mode: Optional[
- Literal[
- "chat",
- "completion",
- "embedding",
- "audio_speech",
- "audio_transcription",
- "image_generation",
- "video_generation",
- "batch",
- "rerank",
- "realtime",
- "responses",
- "ocr",
- ]
- ] = "chat",
+ mode: str | None = "chat",
prompt: Optional[str] = None,
input: Optional[List] = None,
):
@@ -7563,7 +8800,7 @@ def print_verbose(print_statement):
try:
verbose_logger.debug(print_statement)
if litellm.set_verbose:
- print(print_statement) # noqa
+ print(print_statement) # noqa: T201
except Exception:
pass
@@ -7653,7 +8890,7 @@ def stream_chunk_builder_text_completion(
return TextCompletionResponse(**response)
-def stream_chunk_builder( # noqa: PLR0915
+def stream_chunk_builder(
chunks: list,
messages: Optional[list] = None,
start_time=None,
diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json
index 282a292ab17..6ebac7efc8d 100644
--- a/litellm/model_prices_and_context_window_backup.json
+++ b/litellm/model_prices_and_context_window_backup.json
@@ -571,7 +571,7 @@
"output_vector_size": 1536
},
"amazon.titan-embed-text-v2:0": {
- "input_cost_per_token": 2e-07,
+ "input_cost_per_token": 2e-08,
"litellm_provider": "bedrock",
"max_input_tokens": 8192,
"max_tokens": 8192,
@@ -1156,6 +1156,7 @@
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
+ "supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
@@ -1202,6 +1203,7 @@
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
+ "supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
@@ -1233,6 +1235,7 @@
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
+ "supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
@@ -1264,6 +1267,7 @@
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
+ "supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
@@ -1295,6 +1299,139 @@
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
+ "supports_sampling_params": false,
+ "supports_tool_choice": true,
+ "supports_vision": true,
+ "supports_xhigh_reasoning_effort": true,
+ "supports_native_structured_output": true,
+ "supports_max_reasoning_effort": true,
+ "supports_output_config": true,
+ "bedrock_output_config_effort_ceiling": "xhigh"
+ },
+ "anthropic.claude-fable-5": {
+ "cache_creation_input_token_cost": 1.25e-05,
+ "cache_creation_input_token_cost_above_1hr": 2e-05,
+ "cache_read_input_token_cost": 1e-06,
+ "input_cost_per_token": 1e-05,
+ "litellm_provider": "bedrock_converse",
+ "max_input_tokens": 1000000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "chat",
+ "output_cost_per_token": 5e-05,
+ "search_context_cost_per_query": {
+ "search_context_size_high": 0.01,
+ "search_context_size_low": 0.01,
+ "search_context_size_medium": 0.01
+ },
+ "supports_adaptive_thinking": true,
+ "supports_assistant_prefill": false,
+ "supports_computer_use": true,
+ "supports_function_calling": true,
+ "supports_pdf_input": true,
+ "supports_prompt_caching": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_sampling_params": false,
+ "supports_tool_choice": true,
+ "supports_vision": true,
+ "supports_xhigh_reasoning_effort": true,
+ "supports_native_structured_output": true,
+ "supports_max_reasoning_effort": true,
+ "supports_output_config": true,
+ "bedrock_output_config_effort_ceiling": "xhigh"
+ },
+ "global.anthropic.claude-fable-5": {
+ "cache_creation_input_token_cost": 1.25e-05,
+ "cache_creation_input_token_cost_above_1hr": 2e-05,
+ "cache_read_input_token_cost": 1e-06,
+ "input_cost_per_token": 1e-05,
+ "litellm_provider": "bedrock_converse",
+ "max_input_tokens": 1000000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "chat",
+ "output_cost_per_token": 5e-05,
+ "search_context_cost_per_query": {
+ "search_context_size_high": 0.01,
+ "search_context_size_low": 0.01,
+ "search_context_size_medium": 0.01
+ },
+ "supports_adaptive_thinking": true,
+ "supports_assistant_prefill": false,
+ "supports_computer_use": true,
+ "supports_function_calling": true,
+ "supports_pdf_input": true,
+ "supports_prompt_caching": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_sampling_params": false,
+ "supports_tool_choice": true,
+ "supports_vision": true,
+ "supports_xhigh_reasoning_effort": true,
+ "supports_native_structured_output": true,
+ "supports_max_reasoning_effort": true,
+ "supports_output_config": true,
+ "bedrock_output_config_effort_ceiling": "xhigh"
+ },
+ "us.anthropic.claude-fable-5": {
+ "cache_creation_input_token_cost": 1.375e-05,
+ "cache_creation_input_token_cost_above_1hr": 2.2e-05,
+ "cache_read_input_token_cost": 1.1e-06,
+ "input_cost_per_token": 1.1e-05,
+ "litellm_provider": "bedrock_converse",
+ "max_input_tokens": 1000000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "chat",
+ "output_cost_per_token": 5.5e-05,
+ "search_context_cost_per_query": {
+ "search_context_size_high": 0.01,
+ "search_context_size_low": 0.01,
+ "search_context_size_medium": 0.01
+ },
+ "supports_adaptive_thinking": true,
+ "supports_assistant_prefill": false,
+ "supports_computer_use": true,
+ "supports_function_calling": true,
+ "supports_pdf_input": true,
+ "supports_prompt_caching": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_sampling_params": false,
+ "supports_tool_choice": true,
+ "supports_vision": true,
+ "supports_xhigh_reasoning_effort": true,
+ "supports_native_structured_output": true,
+ "supports_max_reasoning_effort": true,
+ "supports_output_config": true,
+ "bedrock_output_config_effort_ceiling": "xhigh"
+ },
+ "eu.anthropic.claude-fable-5": {
+ "cache_creation_input_token_cost": 1.375e-05,
+ "cache_creation_input_token_cost_above_1hr": 2.2e-05,
+ "cache_read_input_token_cost": 1.1e-06,
+ "input_cost_per_token": 1.1e-05,
+ "litellm_provider": "bedrock_converse",
+ "max_input_tokens": 1000000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "chat",
+ "output_cost_per_token": 5.5e-05,
+ "search_context_cost_per_query": {
+ "search_context_size_high": 0.01,
+ "search_context_size_low": 0.01,
+ "search_context_size_medium": 0.01
+ },
+ "supports_adaptive_thinking": true,
+ "supports_assistant_prefill": false,
+ "supports_computer_use": true,
+ "supports_function_calling": true,
+ "supports_pdf_input": true,
+ "supports_prompt_caching": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
@@ -1327,6 +1464,7 @@
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
+ "supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
@@ -1359,6 +1497,7 @@
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
+ "supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
@@ -1391,6 +1530,7 @@
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
+ "supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
@@ -1423,6 +1563,7 @@
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
+ "supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
@@ -1455,6 +1596,7 @@
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
+ "supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
@@ -1485,6 +1627,7 @@
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
+ "supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
@@ -2208,6 +2351,37 @@
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
+ "supports_sampling_params": false,
+ "supports_tool_choice": true,
+ "supports_vision": true,
+ "supports_xhigh_reasoning_effort": true,
+ "supports_max_reasoning_effort": true
+ },
+ "azure_ai/claude-fable-5": {
+ "input_cost_per_token": 1e-05,
+ "output_cost_per_token": 5e-05,
+ "litellm_provider": "azure_ai",
+ "max_input_tokens": 1000000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "chat",
+ "search_context_cost_per_query": {
+ "search_context_size_high": 0.01,
+ "search_context_size_low": 0.01,
+ "search_context_size_medium": 0.01
+ },
+ "cache_creation_input_token_cost": 1.25e-05,
+ "cache_creation_input_token_cost_above_1hr": 2e-05,
+ "cache_read_input_token_cost": 1e-06,
+ "supports_adaptive_thinking": true,
+ "supports_assistant_prefill": false,
+ "supports_computer_use": true,
+ "supports_function_calling": true,
+ "supports_pdf_input": true,
+ "supports_prompt_caching": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
@@ -2237,6 +2411,7 @@
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
+ "supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
@@ -2353,6 +2528,100 @@
"supports_response_schema": true,
"supports_tool_choice": true
},
+ "azure_ai/gpt-5.5": {
+ "cache_read_input_token_cost": 5e-07,
+ "cache_read_input_token_cost_above_272k_tokens": 1e-06,
+ "cache_read_input_token_cost_priority": 1e-06,
+ "cache_read_input_token_cost_above_272k_tokens_priority": 2e-06,
+ "input_cost_per_token": 5e-06,
+ "input_cost_per_token_above_272k_tokens": 1e-05,
+ "input_cost_per_token_priority": 1e-05,
+ "input_cost_per_token_above_272k_tokens_priority": 2e-05,
+ "litellm_provider": "azure_ai",
+ "max_input_tokens": 1050000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "chat",
+ "output_cost_per_token": 3e-05,
+ "output_cost_per_token_above_272k_tokens": 4.5e-05,
+ "output_cost_per_token_priority": 6e-05,
+ "output_cost_per_token_above_272k_tokens_priority": 9e-05,
+ "source": "https://ai.azure.com/catalog/models/gpt-5.5",
+ "supported_endpoints": [
+ "/v1/chat/completions",
+ "/v1/batch",
+ "/v1/responses"
+ ],
+ "supported_modalities": [
+ "text",
+ "image"
+ ],
+ "supported_output_modalities": [
+ "text"
+ ],
+ "supports_function_calling": true,
+ "supports_native_streaming": true,
+ "supports_parallel_function_calling": true,
+ "supports_pdf_input": true,
+ "supports_prompt_caching": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_system_messages": true,
+ "supports_tool_choice": true,
+ "supports_service_tier": true,
+ "supports_vision": true,
+ "supports_web_search": true,
+ "supports_none_reasoning_effort": true,
+ "supports_xhigh_reasoning_effort": true,
+ "supports_minimal_reasoning_effort": false
+ },
+ "azure_ai/gpt-5.5-2026-04-23": {
+ "cache_read_input_token_cost": 5e-07,
+ "cache_read_input_token_cost_above_272k_tokens": 1e-06,
+ "cache_read_input_token_cost_priority": 1e-06,
+ "cache_read_input_token_cost_above_272k_tokens_priority": 2e-06,
+ "input_cost_per_token": 5e-06,
+ "input_cost_per_token_above_272k_tokens": 1e-05,
+ "input_cost_per_token_priority": 1e-05,
+ "input_cost_per_token_above_272k_tokens_priority": 2e-05,
+ "litellm_provider": "azure_ai",
+ "max_input_tokens": 1050000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "chat",
+ "output_cost_per_token": 3e-05,
+ "output_cost_per_token_above_272k_tokens": 4.5e-05,
+ "output_cost_per_token_priority": 6e-05,
+ "output_cost_per_token_above_272k_tokens_priority": 9e-05,
+ "source": "https://ai.azure.com/catalog/models/gpt-5.5",
+ "supported_endpoints": [
+ "/v1/chat/completions",
+ "/v1/batch",
+ "/v1/responses"
+ ],
+ "supported_modalities": [
+ "text",
+ "image"
+ ],
+ "supported_output_modalities": [
+ "text"
+ ],
+ "supports_function_calling": true,
+ "supports_native_streaming": true,
+ "supports_parallel_function_calling": true,
+ "supports_pdf_input": true,
+ "supports_prompt_caching": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_system_messages": true,
+ "supports_tool_choice": true,
+ "supports_service_tier": true,
+ "supports_vision": true,
+ "supports_web_search": true,
+ "supports_none_reasoning_effort": true,
+ "supports_xhigh_reasoning_effort": true,
+ "supports_minimal_reasoning_effort": false
+ },
"azure_ai/gpt-5.4": {
"cache_read_input_token_cost": 2.5e-07,
"cache_read_input_token_cost_above_272k_tokens": 5e-07,
@@ -4234,6 +4503,23 @@
"/v1/audio/transcriptions"
]
},
+ "azure/gpt-realtime-whisper": {
+ "input_cost_per_second": 0.0002833333333333333,
+ "litellm_provider": "azure",
+ "mode": "audio_transcription",
+ "source": "https://learn.microsoft.com/en-us/azure/ai-foundry/openai/concepts/gpt-realtime-whisper",
+ "supported_endpoints": [
+ "/v1/realtime",
+ "/v1/realtime/transcription_sessions"
+ ],
+ "supported_modalities": [
+ "audio"
+ ],
+ "supported_output_modalities": [
+ "text"
+ ],
+ "supports_audio_input": true
+ },
"azure/gpt-5.1-2025-11-13": {
"cache_read_input_token_cost": 1.25e-07,
"cache_read_input_token_cost_priority": 2.5e-07,
@@ -7382,6 +7668,45 @@
"supports_function_calling": true,
"supports_tool_choice": true
},
+ "azure_ai/deepseek-v3.1": {
+ "input_cost_per_token": 1.23e-06,
+ "litellm_provider": "azure_ai",
+ "max_input_tokens": 131072,
+ "max_output_tokens": 131072,
+ "max_tokens": 131072,
+ "mode": "chat",
+ "output_cost_per_token": 4.94e-06,
+ "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/deepseek/",
+ "supports_function_calling": true,
+ "supports_reasoning": true,
+ "supports_tool_choice": true
+ },
+ "azure_ai/deepseek-v4-pro": {
+ "input_cost_per_token": 1.74e-06,
+ "litellm_provider": "azure_ai",
+ "max_input_tokens": 1000000,
+ "max_output_tokens": 384000,
+ "max_tokens": 384000,
+ "mode": "chat",
+ "output_cost_per_token": 3.48e-06,
+ "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/deepseek/",
+ "supports_function_calling": true,
+ "supports_reasoning": true,
+ "supports_tool_choice": true
+ },
+ "azure_ai/deepseek-v4-flash": {
+ "input_cost_per_token": 1.9e-07,
+ "litellm_provider": "azure_ai",
+ "max_input_tokens": 1000000,
+ "max_output_tokens": 384000,
+ "max_tokens": 384000,
+ "mode": "chat",
+ "output_cost_per_token": 5.1e-07,
+ "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/deepseek/",
+ "supports_function_calling": true,
+ "supports_reasoning": true,
+ "supports_tool_choice": true
+ },
"azure_ai/embed-v-4-0": {
"input_cost_per_token": 1.2e-07,
"litellm_provider": "azure_ai",
@@ -9837,6 +10162,8 @@
},
"claude-sonnet-4-5": {
"cache_creation_input_token_cost": 3.75e-06,
+ "cache_creation_input_token_cost_above_1hr": 6e-06,
+ "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 1.2e-05,
"cache_read_input_token_cost": 3e-07,
"input_cost_per_token": 3e-06,
"input_cost_per_token_above_200k_tokens": 6e-06,
@@ -9866,6 +10193,8 @@
},
"claude-sonnet-4-5-20250929": {
"cache_creation_input_token_cost": 3.75e-06,
+ "cache_creation_input_token_cost_above_1hr": 6e-06,
+ "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 1.2e-05,
"cache_read_input_token_cost": 3e-07,
"input_cost_per_token": 3e-06,
"input_cost_per_token_above_200k_tokens": 6e-06,
@@ -9896,6 +10225,7 @@
},
"claude-sonnet-4-6": {
"cache_creation_input_token_cost": 3.75e-06,
+ "cache_creation_input_token_cost_above_1hr": 6e-06,
"cache_read_input_token_cost": 3e-07,
"input_cost_per_token": 3e-06,
"litellm_provider": "anthropic",
@@ -9924,6 +10254,8 @@
},
"claude-sonnet-4-5-20250929-v1:0": {
"cache_creation_input_token_cost": 3.75e-06,
+ "cache_creation_input_token_cost_above_1hr": 6e-06,
+ "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 1.2e-05,
"cache_read_input_token_cost": 3e-07,
"input_cost_per_token": 3e-06,
"input_cost_per_token_above_200k_tokens": 6e-06,
@@ -10111,7 +10443,8 @@
"fast": 6.0
},
"supports_output_config": true,
- "supports_max_reasoning_effort": true
+ "supports_max_reasoning_effort": true,
+ "supports_speed": true
},
"claude-opus-4-6-20260205": {
"cache_creation_input_token_cost": 6.25e-06,
@@ -10144,7 +10477,8 @@
"fast": 6.0
},
"supports_max_reasoning_effort": true,
- "supports_output_config": true
+ "supports_output_config": true,
+ "supports_speed": true
},
"claude-opus-4-7": {
"cache_creation_input_token_cost": 6.25e-06,
@@ -10170,6 +10504,7 @@
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
+ "supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
@@ -10178,7 +10513,8 @@
"us": 1.1,
"fast": 6.0
},
- "supports_output_config": true
+ "supports_output_config": true,
+ "supports_speed": true
},
"claude-opus-4-7-20260416": {
"cache_creation_input_token_cost": 6.25e-06,
@@ -10204,6 +10540,7 @@
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
+ "supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
@@ -10212,6 +10549,41 @@
"us": 1.1,
"fast": 6.0
},
+ "supports_output_config": true,
+ "supports_speed": true
+ },
+ "claude-fable-5": {
+ "cache_creation_input_token_cost": 1.25e-05,
+ "cache_creation_input_token_cost_above_1hr": 2e-05,
+ "cache_read_input_token_cost": 1e-06,
+ "input_cost_per_token": 1e-05,
+ "litellm_provider": "anthropic",
+ "max_input_tokens": 1000000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "chat",
+ "output_cost_per_token": 5e-05,
+ "search_context_cost_per_query": {
+ "search_context_size_high": 0.01,
+ "search_context_size_low": 0.01,
+ "search_context_size_medium": 0.01
+ },
+ "supports_adaptive_thinking": true,
+ "supports_assistant_prefill": false,
+ "supports_computer_use": true,
+ "supports_function_calling": true,
+ "supports_pdf_input": true,
+ "supports_prompt_caching": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_sampling_params": false,
+ "supports_tool_choice": true,
+ "supports_vision": true,
+ "supports_xhigh_reasoning_effort": true,
+ "supports_max_reasoning_effort": true,
+ "provider_specific_entry": {
+ "us": 1.1
+ },
"supports_output_config": true
},
"claude-opus-4-8": {
@@ -10238,6 +10610,7 @@
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
+ "supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
@@ -10246,7 +10619,8 @@
"us": 1.1,
"fast": 2.0
},
- "supports_output_config": true
+ "supports_output_config": true,
+ "supports_speed": true
},
"claude-sonnet-4-20250514": {
"deprecation_date": "2026-05-14",
@@ -10315,6 +10689,268 @@
"mode": "chat",
"output_cost_per_token": 1.923e-06
},
+ "cloudflare/@cf/openai/gpt-oss-120b": {
+ "input_cost_per_token": 3.5e-07,
+ "litellm_provider": "cloudflare",
+ "max_input_tokens": 128000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "chat",
+ "output_cost_per_token": 7.5e-07,
+ "supports_function_calling": true,
+ "supports_reasoning": true
+ },
+ "cloudflare/@cf/google/gemma-2b-it-lora": {
+ "input_cost_per_token": 0.0,
+ "litellm_provider": "cloudflare",
+ "max_input_tokens": 8192,
+ "max_output_tokens": 8192,
+ "max_tokens": 8192,
+ "mode": "chat",
+ "output_cost_per_token": 0.0
+ },
+ "cloudflare/@cf/meta/llama-3.2-3b-instruct": {
+ "input_cost_per_token": 5.09e-08,
+ "litellm_provider": "cloudflare",
+ "max_input_tokens": 80000,
+ "max_output_tokens": 80000,
+ "max_tokens": 80000,
+ "mode": "chat",
+ "output_cost_per_token": 3.35e-07
+ },
+ "cloudflare/@cf/meta/llama-guard-3-8b": {
+ "input_cost_per_token": 4.84e-07,
+ "litellm_provider": "cloudflare",
+ "max_input_tokens": 131072,
+ "max_output_tokens": 131072,
+ "max_tokens": 131072,
+ "mode": "chat",
+ "output_cost_per_token": 3e-08
+ },
+ "cloudflare/@cf/mistral/mistral-7b-instruct-v0.2-lora": {
+ "input_cost_per_token": 0.0,
+ "litellm_provider": "cloudflare",
+ "max_input_tokens": 15000,
+ "max_output_tokens": 15000,
+ "max_tokens": 15000,
+ "mode": "chat",
+ "output_cost_per_token": 0.0
+ },
+ "cloudflare/@cf/moonshotai/kimi-k2.7-code": {
+ "cache_read_input_token_cost": 1.9e-07,
+ "input_cost_per_token": 9.5e-07,
+ "litellm_provider": "cloudflare",
+ "max_input_tokens": 262144,
+ "max_output_tokens": 262144,
+ "max_tokens": 262144,
+ "mode": "chat",
+ "output_cost_per_token": 4e-06,
+ "supports_function_calling": true,
+ "supports_reasoning": true
+ },
+ "cloudflare/@cf/deepseek-ai/deepseek-r1-distill-qwen-32b": {
+ "input_cost_per_token": 4.97e-07,
+ "litellm_provider": "cloudflare",
+ "max_input_tokens": 80000,
+ "max_output_tokens": 80000,
+ "max_tokens": 80000,
+ "mode": "chat",
+ "output_cost_per_token": 4.881e-06,
+ "supports_reasoning": true
+ },
+ "cloudflare/@cf/meta/llama-3.1-8b-instruct-fp8": {
+ "input_cost_per_token": 1.52e-07,
+ "litellm_provider": "cloudflare",
+ "max_input_tokens": 32000,
+ "max_output_tokens": 32000,
+ "max_tokens": 32000,
+ "mode": "chat",
+ "output_cost_per_token": 2.87e-07
+ },
+ "cloudflare/@cf/meta/llama-3.2-1b-instruct": {
+ "input_cost_per_token": 2.7e-08,
+ "litellm_provider": "cloudflare",
+ "max_input_tokens": 60000,
+ "max_output_tokens": 60000,
+ "max_tokens": 60000,
+ "mode": "chat",
+ "output_cost_per_token": 2.01e-07
+ },
+ "cloudflare/@cf/moonshotai/kimi-k2.6": {
+ "cache_read_input_token_cost": 1.6e-07,
+ "input_cost_per_token": 9.5e-07,
+ "litellm_provider": "cloudflare",
+ "max_input_tokens": 262144,
+ "max_output_tokens": 262144,
+ "max_tokens": 262144,
+ "mode": "chat",
+ "output_cost_per_token": 4e-06,
+ "supports_function_calling": true,
+ "supports_reasoning": true
+ },
+ "cloudflare/@cf/zai-org/glm-4.7-flash": {
+ "input_cost_per_token": 6.05e-08,
+ "litellm_provider": "cloudflare",
+ "max_input_tokens": 131072,
+ "max_output_tokens": 131072,
+ "max_tokens": 131072,
+ "mode": "chat",
+ "output_cost_per_token": 4e-07,
+ "supports_function_calling": true,
+ "supports_reasoning": true
+ },
+ "cloudflare/@cf/meta-llama/llama-2-7b-chat-hf-lora": {
+ "input_cost_per_token": 0.0,
+ "litellm_provider": "cloudflare",
+ "max_input_tokens": 8192,
+ "max_output_tokens": 8192,
+ "max_tokens": 8192,
+ "mode": "chat",
+ "output_cost_per_token": 0.0
+ },
+ "cloudflare/@cf/meta/llama-3.3-70b-instruct-fp8-fast": {
+ "input_cost_per_token": 2.93e-07,
+ "litellm_provider": "cloudflare",
+ "max_input_tokens": 24000,
+ "max_output_tokens": 24000,
+ "max_tokens": 24000,
+ "mode": "chat",
+ "output_cost_per_token": 2.253e-06,
+ "supports_function_calling": true
+ },
+ "cloudflare/@cf/ibm-granite/granite-4.0-h-micro": {
+ "input_cost_per_token": 1.7e-08,
+ "litellm_provider": "cloudflare",
+ "max_input_tokens": 131000,
+ "max_output_tokens": 131000,
+ "max_tokens": 131000,
+ "mode": "chat",
+ "output_cost_per_token": 1.12e-07,
+ "supports_function_calling": true
+ },
+ "cloudflare/@cf/qwen/qwen2.5-coder-32b-instruct": {
+ "input_cost_per_token": 6.6e-07,
+ "litellm_provider": "cloudflare",
+ "max_input_tokens": 32768,
+ "max_output_tokens": 32768,
+ "max_tokens": 32768,
+ "mode": "chat",
+ "output_cost_per_token": 1e-06
+ },
+ "cloudflare/@cf/zai-org/glm-5.2": {
+ "cache_read_input_token_cost": 2.6e-07,
+ "input_cost_per_token": 1.4e-06,
+ "litellm_provider": "cloudflare",
+ "max_input_tokens": 262144,
+ "max_output_tokens": 262144,
+ "max_tokens": 262144,
+ "mode": "chat",
+ "output_cost_per_token": 4.4e-06,
+ "supports_function_calling": true,
+ "supports_reasoning": true
+ },
+ "cloudflare/@cf/nvidia/nemotron-3-120b-a12b": {
+ "input_cost_per_token": 5e-07,
+ "litellm_provider": "cloudflare",
+ "max_input_tokens": 256000,
+ "max_output_tokens": 256000,
+ "max_tokens": 256000,
+ "mode": "chat",
+ "output_cost_per_token": 1.5e-06,
+ "supports_function_calling": true,
+ "supports_reasoning": true
+ },
+ "cloudflare/@cf/aisingapore/gemma-sea-lion-v4-27b-it": {
+ "input_cost_per_token": 3.51e-07,
+ "litellm_provider": "cloudflare",
+ "max_input_tokens": 128000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "chat",
+ "output_cost_per_token": 5.55e-07
+ },
+ "cloudflare/@cf/qwen/qwen3-30b-a3b-fp8": {
+ "input_cost_per_token": 5.09e-08,
+ "litellm_provider": "cloudflare",
+ "max_input_tokens": 32768,
+ "max_output_tokens": 32768,
+ "max_tokens": 32768,
+ "mode": "chat",
+ "output_cost_per_token": 3.35e-07,
+ "supports_function_calling": true,
+ "supports_reasoning": true
+ },
+ "cloudflare/@cf/google/gemma-7b-it-lora": {
+ "input_cost_per_token": 0.0,
+ "litellm_provider": "cloudflare",
+ "max_input_tokens": 3500,
+ "max_output_tokens": 3500,
+ "max_tokens": 3500,
+ "mode": "chat",
+ "output_cost_per_token": 0.0
+ },
+ "cloudflare/@cf/google/gemma-4-26b-a4b-it": {
+ "input_cost_per_token": 1e-07,
+ "litellm_provider": "cloudflare",
+ "max_input_tokens": 256000,
+ "max_output_tokens": 256000,
+ "max_tokens": 256000,
+ "mode": "chat",
+ "output_cost_per_token": 3e-07,
+ "supports_function_calling": true,
+ "supports_reasoning": true
+ },
+ "cloudflare/@cf/mistralai/mistral-small-3.1-24b-instruct": {
+ "input_cost_per_token": 3.51e-07,
+ "litellm_provider": "cloudflare",
+ "max_input_tokens": 128000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "chat",
+ "output_cost_per_token": 5.55e-07,
+ "supports_function_calling": true
+ },
+ "cloudflare/@cf/meta/llama-3.2-11b-vision-instruct": {
+ "input_cost_per_token": 4.85e-08,
+ "litellm_provider": "cloudflare",
+ "max_input_tokens": 128000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "chat",
+ "output_cost_per_token": 6.76e-07,
+ "supports_vision": true
+ },
+ "cloudflare/@cf/openai/gpt-oss-20b": {
+ "input_cost_per_token": 2e-07,
+ "litellm_provider": "cloudflare",
+ "max_input_tokens": 128000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "chat",
+ "output_cost_per_token": 3e-07,
+ "supports_function_calling": true,
+ "supports_reasoning": true
+ },
+ "cloudflare/@cf/meta/llama-4-scout-17b-16e-instruct": {
+ "input_cost_per_token": 2.7e-07,
+ "litellm_provider": "cloudflare",
+ "max_input_tokens": 131000,
+ "max_output_tokens": 131000,
+ "max_tokens": 131000,
+ "mode": "chat",
+ "output_cost_per_token": 8.5e-07,
+ "supports_function_calling": true
+ },
+ "cloudflare/@cf/qwen/qwq-32b": {
+ "input_cost_per_token": 6.6e-07,
+ "litellm_provider": "cloudflare",
+ "max_input_tokens": 24000,
+ "max_output_tokens": 24000,
+ "max_tokens": 24000,
+ "mode": "chat",
+ "output_cost_per_token": 1e-06,
+ "supports_reasoning": true
+ },
"codestral/codestral-2405": {
"input_cost_per_token": 0.0,
"litellm_provider": "codestral",
@@ -10543,13 +11179,13 @@
"supports_tool_choice": true
},
"command-r7b-12-2024": {
- "input_cost_per_token": 1.5e-07,
+ "input_cost_per_token": 3.75e-08,
"litellm_provider": "cohere_chat",
"max_input_tokens": 128000,
"max_output_tokens": 4096,
"max_tokens": 4096,
"mode": "chat",
- "output_cost_per_token": 3.75e-08,
+ "output_cost_per_token": 1.5e-07,
"source": "https://docs.cohere.com/v2/docs/command-r7b",
"supports_function_calling": true,
"supports_tool_choice": true
@@ -14243,6 +14879,38 @@
"supports_response_schema": true,
"supports_tool_choice": true
},
+ "fireworks_ai/accounts/fireworks/models/deepseek-v4-flash": {
+ "cache_read_input_token_cost": 2.8e-08,
+ "input_cost_per_token": 1.4e-07,
+ "litellm_provider": "fireworks_ai",
+ "max_input_tokens": 1048576,
+ "max_output_tokens": 384000,
+ "max_tokens": 384000,
+ "mode": "chat",
+ "output_cost_per_token": 2.8e-07,
+ "source": "https://docs.fireworks.ai/serverless/pricing",
+ "supports_function_calling": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_tool_choice": true,
+ "supports_vision": false
+ },
+ "fireworks_ai/accounts/fireworks/models/deepseek-v4-pro": {
+ "cache_read_input_token_cost": 1.45e-07,
+ "input_cost_per_token": 1.74e-06,
+ "litellm_provider": "fireworks_ai",
+ "max_input_tokens": 1048576,
+ "max_output_tokens": 384000,
+ "max_tokens": 384000,
+ "mode": "chat",
+ "output_cost_per_token": 3.48e-06,
+ "source": "https://docs.fireworks.ai/serverless/pricing",
+ "supports_function_calling": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_tool_choice": true,
+ "supports_vision": false
+ },
"fireworks_ai/accounts/fireworks/models/firefunction-v2": {
"input_cost_per_token": 9e-07,
"litellm_provider": "fireworks_ai",
@@ -14318,43 +14986,64 @@
"input_cost_per_token": 1.4e-06,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 202800,
- "max_output_tokens": 202800,
- "max_tokens": 202800,
+ "max_output_tokens": 131072,
+ "max_tokens": 131072,
"mode": "chat",
"output_cost_per_token": 4.4e-06,
- "source": "https://fireworks.ai/models/fireworks/glm-5p1",
+ "source": "https://docs.fireworks.ai/serverless/pricing",
"supports_function_calling": true,
"supports_reasoning": true,
"supports_response_schema": true,
- "supports_tool_choice": true
+ "supports_tool_choice": true,
+ "supports_vision": false
+ },
+ "fireworks_ai/accounts/fireworks/models/glm-5p2": {
+ "cache_read_input_token_cost": 2.6e-07,
+ "input_cost_per_token": 1.4e-06,
+ "litellm_provider": "fireworks_ai",
+ "max_input_tokens": 1048576,
+ "max_output_tokens": 131072,
+ "max_tokens": 131072,
+ "mode": "chat",
+ "output_cost_per_token": 4.4e-06,
+ "source": "https://docs.fireworks.ai/serverless/pricing",
+ "supports_function_calling": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_tool_choice": true,
+ "supports_vision": false
},
"fireworks_ai/accounts/fireworks/models/gpt-oss-120b": {
+ "cache_read_input_token_cost": 1.5e-08,
"input_cost_per_token": 1.5e-07,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 131072,
- "max_output_tokens": 131072,
- "max_tokens": 131072,
+ "max_output_tokens": 32768,
+ "max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 6e-07,
- "source": "https://fireworks.ai/pricing",
+ "source": "https://docs.fireworks.ai/serverless/pricing",
"supports_function_calling": true,
"supports_reasoning": true,
"supports_response_schema": true,
- "supports_tool_choice": true
+ "supports_tool_choice": true,
+ "supports_vision": false
},
"fireworks_ai/accounts/fireworks/models/gpt-oss-20b": {
- "input_cost_per_token": 5e-08,
+ "cache_read_input_token_cost": 3.5e-08,
+ "input_cost_per_token": 7e-08,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 131072,
- "max_output_tokens": 131072,
- "max_tokens": 131072,
+ "max_output_tokens": 32768,
+ "max_tokens": 32768,
"mode": "chat",
- "output_cost_per_token": 2e-07,
- "source": "https://fireworks.ai/pricing",
+ "output_cost_per_token": 3e-07,
+ "source": "https://docs.fireworks.ai/serverless/pricing",
"supports_function_calling": true,
"supports_reasoning": true,
"supports_response_schema": true,
- "supports_tool_choice": true
+ "supports_tool_choice": true,
+ "supports_vision": false
},
"fireworks_ai/accounts/fireworks/models/kimi-k2-instruct": {
"input_cost_per_token": 6e-07,
@@ -14410,6 +15099,38 @@
"supports_response_schema": true,
"supports_tool_choice": true
},
+ "fireworks_ai/accounts/fireworks/models/kimi-k2p6": {
+ "cache_read_input_token_cost": 1.6e-07,
+ "input_cost_per_token": 9.5e-07,
+ "litellm_provider": "fireworks_ai",
+ "max_input_tokens": 262144,
+ "max_output_tokens": 262144,
+ "max_tokens": 262144,
+ "mode": "chat",
+ "output_cost_per_token": 4e-06,
+ "source": "https://docs.fireworks.ai/serverless/pricing",
+ "supports_function_calling": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_tool_choice": true,
+ "supports_vision": true
+ },
+ "fireworks_ai/accounts/fireworks/models/kimi-k2p7-code": {
+ "cache_read_input_token_cost": 1.9e-07,
+ "input_cost_per_token": 9.5e-07,
+ "litellm_provider": "fireworks_ai",
+ "max_input_tokens": 262144,
+ "max_output_tokens": 262144,
+ "max_tokens": 262144,
+ "mode": "chat",
+ "output_cost_per_token": 4e-06,
+ "source": "https://docs.fireworks.ai/serverless/pricing",
+ "supports_function_calling": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_tool_choice": true,
+ "supports_vision": true
+ },
"fireworks_ai/accounts/fireworks/models/llama-v3p1-405b-instruct": {
"input_cost_per_token": 3e-06,
"litellm_provider": "fireworks_ai",
@@ -14527,6 +15248,38 @@
"supports_response_schema": true,
"supports_tool_choice": true
},
+ "fireworks_ai/accounts/fireworks/models/minimax-m2p7": {
+ "cache_read_input_token_cost": 6e-08,
+ "input_cost_per_token": 3e-07,
+ "litellm_provider": "fireworks_ai",
+ "max_input_tokens": 196608,
+ "max_output_tokens": 196608,
+ "max_tokens": 196608,
+ "mode": "chat",
+ "output_cost_per_token": 1.2e-06,
+ "source": "https://docs.fireworks.ai/serverless/pricing",
+ "supports_function_calling": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_tool_choice": true,
+ "supports_vision": false
+ },
+ "fireworks_ai/accounts/fireworks/models/minimax-m3": {
+ "cache_read_input_token_cost": 6e-08,
+ "input_cost_per_token": 3e-07,
+ "litellm_provider": "fireworks_ai",
+ "max_input_tokens": 512000,
+ "max_output_tokens": 512000,
+ "max_tokens": 512000,
+ "mode": "chat",
+ "output_cost_per_token": 1.2e-06,
+ "source": "https://docs.fireworks.ai/serverless/pricing",
+ "supports_function_calling": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_tool_choice": true,
+ "supports_vision": true
+ },
"fireworks_ai/accounts/fireworks/models/mixtral-8x22b-instruct-hf": {
"input_cost_per_token": 1.2e-06,
"litellm_provider": "fireworks_ai",
@@ -14579,6 +15332,38 @@
"supports_response_schema": true,
"supports_tool_choice": false
},
+ "fireworks_ai/deepseek-v4-flash": {
+ "cache_read_input_token_cost": 2.8e-08,
+ "input_cost_per_token": 1.4e-07,
+ "litellm_provider": "fireworks_ai",
+ "max_input_tokens": 1048576,
+ "max_output_tokens": 384000,
+ "max_tokens": 384000,
+ "mode": "chat",
+ "output_cost_per_token": 2.8e-07,
+ "source": "https://docs.fireworks.ai/serverless/pricing",
+ "supports_function_calling": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_tool_choice": true,
+ "supports_vision": false
+ },
+ "fireworks_ai/deepseek-v4-pro": {
+ "cache_read_input_token_cost": 1.45e-07,
+ "input_cost_per_token": 1.74e-06,
+ "litellm_provider": "fireworks_ai",
+ "max_input_tokens": 1048576,
+ "max_output_tokens": 384000,
+ "max_tokens": 384000,
+ "mode": "chat",
+ "output_cost_per_token": 3.48e-06,
+ "source": "https://docs.fireworks.ai/serverless/pricing",
+ "supports_function_calling": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_tool_choice": true,
+ "supports_vision": false
+ },
"fireworks_ai/glm-4p7": {
"cache_read_input_token_cost": 3e-07,
"input_cost_per_token": 6e-07,
@@ -14599,15 +15384,80 @@
"input_cost_per_token": 1.4e-06,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 202800,
- "max_output_tokens": 202800,
- "max_tokens": 202800,
+ "max_output_tokens": 131072,
+ "max_tokens": 131072,
"mode": "chat",
"output_cost_per_token": 4.4e-06,
- "source": "https://fireworks.ai/models/fireworks/glm-5p1",
+ "source": "https://docs.fireworks.ai/serverless/pricing",
"supports_function_calling": true,
"supports_reasoning": true,
"supports_response_schema": true,
- "supports_tool_choice": true
+ "supports_tool_choice": true,
+ "supports_vision": false
+ },
+ "fireworks_ai/glm-5p1-fast": {
+ "cache_read_input_token_cost": 5.2e-07,
+ "input_cost_per_token": 2.8e-06,
+ "litellm_provider": "fireworks_ai",
+ "max_input_tokens": 202800,
+ "max_output_tokens": 131072,
+ "max_tokens": 131072,
+ "mode": "chat",
+ "output_cost_per_token": 8.8e-06,
+ "source": "https://docs.fireworks.ai/serverless/pricing",
+ "supports_function_calling": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_tool_choice": true,
+ "supports_vision": false
+ },
+ "fireworks_ai/glm-5p2": {
+ "cache_read_input_token_cost": 2.6e-07,
+ "input_cost_per_token": 1.4e-06,
+ "litellm_provider": "fireworks_ai",
+ "max_input_tokens": 1048576,
+ "max_output_tokens": 131072,
+ "max_tokens": 131072,
+ "mode": "chat",
+ "output_cost_per_token": 4.4e-06,
+ "source": "https://docs.fireworks.ai/serverless/pricing",
+ "supports_function_calling": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_tool_choice": true,
+ "supports_vision": false
+ },
+ "fireworks_ai/gpt-oss-120b": {
+ "cache_read_input_token_cost": 1.5e-08,
+ "input_cost_per_token": 1.5e-07,
+ "litellm_provider": "fireworks_ai",
+ "max_input_tokens": 131072,
+ "max_output_tokens": 32768,
+ "max_tokens": 32768,
+ "mode": "chat",
+ "output_cost_per_token": 6e-07,
+ "source": "https://docs.fireworks.ai/serverless/pricing",
+ "supports_function_calling": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_tool_choice": true,
+ "supports_vision": false
+ },
+ "fireworks_ai/gpt-oss-20b": {
+ "cache_read_input_token_cost": 3.5e-08,
+ "input_cost_per_token": 7e-08,
+ "litellm_provider": "fireworks_ai",
+ "max_input_tokens": 131072,
+ "max_output_tokens": 32768,
+ "max_tokens": 32768,
+ "mode": "chat",
+ "output_cost_per_token": 3e-07,
+ "source": "https://docs.fireworks.ai/serverless/pricing",
+ "supports_function_calling": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_tool_choice": true,
+ "supports_vision": false
},
"fireworks_ai/kimi-k2p5": {
"cache_read_input_token_cost": 1e-07,
@@ -14623,6 +15473,70 @@
"supports_response_schema": true,
"supports_tool_choice": true
},
+ "fireworks_ai/kimi-k2p6": {
+ "cache_read_input_token_cost": 1.6e-07,
+ "input_cost_per_token": 9.5e-07,
+ "litellm_provider": "fireworks_ai",
+ "max_input_tokens": 262144,
+ "max_output_tokens": 262144,
+ "max_tokens": 262144,
+ "mode": "chat",
+ "output_cost_per_token": 4e-06,
+ "source": "https://docs.fireworks.ai/serverless/pricing",
+ "supports_function_calling": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_tool_choice": true,
+ "supports_vision": true
+ },
+ "fireworks_ai/kimi-k2p6-fast": {
+ "cache_read_input_token_cost": 3e-07,
+ "input_cost_per_token": 2e-06,
+ "litellm_provider": "fireworks_ai",
+ "max_input_tokens": 262144,
+ "max_output_tokens": 262144,
+ "max_tokens": 262144,
+ "mode": "chat",
+ "output_cost_per_token": 8e-06,
+ "source": "https://docs.fireworks.ai/serverless/pricing",
+ "supports_function_calling": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_tool_choice": true,
+ "supports_vision": true
+ },
+ "fireworks_ai/kimi-k2p7-code": {
+ "cache_read_input_token_cost": 1.9e-07,
+ "input_cost_per_token": 9.5e-07,
+ "litellm_provider": "fireworks_ai",
+ "max_input_tokens": 262144,
+ "max_output_tokens": 262144,
+ "max_tokens": 262144,
+ "mode": "chat",
+ "output_cost_per_token": 4e-06,
+ "source": "https://docs.fireworks.ai/serverless/pricing",
+ "supports_function_calling": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_tool_choice": true,
+ "supports_vision": true
+ },
+ "fireworks_ai/kimi-k2p7-code-fast": {
+ "cache_read_input_token_cost": 3.8e-07,
+ "input_cost_per_token": 1.9e-06,
+ "litellm_provider": "fireworks_ai",
+ "max_input_tokens": 262144,
+ "max_output_tokens": 262144,
+ "max_tokens": 262144,
+ "mode": "chat",
+ "output_cost_per_token": 8e-06,
+ "source": "https://docs.fireworks.ai/serverless/pricing",
+ "supports_function_calling": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_tool_choice": true,
+ "supports_vision": true
+ },
"fireworks_ai/minimax-m2p1": {
"cache_read_input_token_cost": 3e-08,
"input_cost_per_token": 3e-07,
@@ -14637,6 +15551,54 @@
"supports_response_schema": true,
"supports_tool_choice": true
},
+ "fireworks_ai/minimax-m2p7": {
+ "cache_read_input_token_cost": 6e-08,
+ "input_cost_per_token": 3e-07,
+ "litellm_provider": "fireworks_ai",
+ "max_input_tokens": 196608,
+ "max_output_tokens": 196608,
+ "max_tokens": 196608,
+ "mode": "chat",
+ "output_cost_per_token": 1.2e-06,
+ "source": "https://docs.fireworks.ai/serverless/pricing",
+ "supports_function_calling": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_tool_choice": true,
+ "supports_vision": false
+ },
+ "fireworks_ai/minimax-m3": {
+ "cache_read_input_token_cost": 6e-08,
+ "input_cost_per_token": 3e-07,
+ "litellm_provider": "fireworks_ai",
+ "max_input_tokens": 512000,
+ "max_output_tokens": 512000,
+ "max_tokens": 512000,
+ "mode": "chat",
+ "output_cost_per_token": 1.2e-06,
+ "source": "https://docs.fireworks.ai/serverless/pricing",
+ "supports_function_calling": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_tool_choice": true,
+ "supports_vision": true
+ },
+ "fireworks_ai/qwen3p7-plus": {
+ "cache_read_input_token_cost": 8e-08,
+ "input_cost_per_token": 4e-07,
+ "litellm_provider": "fireworks_ai",
+ "max_input_tokens": 262144,
+ "max_output_tokens": 65536,
+ "max_tokens": 65536,
+ "mode": "chat",
+ "output_cost_per_token": 1.6e-06,
+ "source": "https://docs.fireworks.ai/serverless/pricing",
+ "supports_function_calling": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_tool_choice": true,
+ "supports_vision": true
+ },
"fireworks_ai/nomic-ai/nomic-embed-text-v1": {
"input_cost_per_token": 8e-09,
"litellm_provider": "fireworks_ai-embedding-models",
@@ -18554,6 +19516,38 @@
"supports_response_schema": true,
"supports_vision": true
},
+ "github_copilot/mai-code-1-flash": {
+ "cache_read_input_token_cost": 7.5e-08,
+ "input_cost_per_token": 7.5e-07,
+ "litellm_provider": "github_copilot",
+ "max_input_tokens": 128000,
+ "max_output_tokens": 64000,
+ "max_tokens": 64000,
+ "mode": "chat",
+ "output_cost_per_token": 4.5e-06,
+ "supported_endpoints": [
+ "/v1/chat/completions"
+ ],
+ "supports_function_calling": true,
+ "supports_parallel_function_calling": true,
+ "supports_response_schema": true
+ },
+ "github_copilot/mai-code-1-flash-internal": {
+ "cache_read_input_token_cost": 7.5e-08,
+ "input_cost_per_token": 7.5e-07,
+ "litellm_provider": "github_copilot",
+ "max_input_tokens": 128000,
+ "max_output_tokens": 64000,
+ "max_tokens": 64000,
+ "mode": "chat",
+ "output_cost_per_token": 4.5e-06,
+ "supported_endpoints": [
+ "/v1/chat/completions"
+ ],
+ "supports_function_calling": true,
+ "supports_parallel_function_calling": true,
+ "supports_response_schema": true
+ },
"github_copilot/text-embedding-3-small": {
"litellm_provider": "github_copilot",
"max_input_tokens": 8191,
@@ -19361,8 +20355,6 @@
"output_cost_per_token": 8e-06,
"output_cost_per_token_batches": 4e-06,
"output_cost_per_token_priority": 1.4e-05,
- "regional_processing_uplift_multiplier_eu": 1.10,
- "regional_processing_uplift_multiplier_us": 1.10,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
@@ -19436,8 +20428,6 @@
"output_cost_per_token": 1.6e-06,
"output_cost_per_token_batches": 8e-07,
"output_cost_per_token_priority": 2.8e-06,
- "regional_processing_uplift_multiplier_eu": 1.10,
- "regional_processing_uplift_multiplier_us": 1.10,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
@@ -19511,8 +20501,6 @@
"output_cost_per_token": 4e-07,
"output_cost_per_token_batches": 2e-07,
"output_cost_per_token_priority": 8e-07,
- "regional_processing_uplift_multiplier_eu": 1.10,
- "regional_processing_uplift_multiplier_us": 1.10,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
@@ -19584,8 +20572,6 @@
"output_cost_per_token": 1e-05,
"output_cost_per_token_batches": 5e-06,
"output_cost_per_token_priority": 1.7e-05,
- "regional_processing_uplift_multiplier_eu": 1.10,
- "regional_processing_uplift_multiplier_us": 1.10,
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
@@ -19627,8 +20613,6 @@
"mode": "chat",
"output_cost_per_token": 1e-05,
"output_cost_per_token_batches": 5e-06,
- "regional_processing_uplift_multiplier_eu": 1.10,
- "regional_processing_uplift_multiplier_us": 1.10,
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
@@ -19650,8 +20634,6 @@
"mode": "chat",
"output_cost_per_token": 1e-05,
"output_cost_per_token_batches": 5e-06,
- "regional_processing_uplift_multiplier_eu": 1.10,
- "regional_processing_uplift_multiplier_us": 1.10,
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
@@ -19940,8 +20922,6 @@
"output_cost_per_token": 6e-07,
"output_cost_per_token_batches": 3e-07,
"output_cost_per_token_priority": 1e-06,
- "regional_processing_uplift_multiplier_eu": 1.10,
- "regional_processing_uplift_multiplier_us": 1.10,
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
@@ -20645,8 +21625,6 @@
"output_cost_per_token": 1e-05,
"output_cost_per_token_flex": 5e-06,
"output_cost_per_token_priority": 2e-05,
- "regional_processing_uplift_multiplier_eu": 1.10,
- "regional_processing_uplift_multiplier_us": 1.10,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
@@ -21040,6 +22018,8 @@
"output_cost_per_token_flex": 1.5e-05,
"output_cost_per_token_batches": 1.5e-05,
"output_cost_per_token_priority": 6e-05,
+ "regional_processing_uplift_multiplier_eu": 1.10,
+ "regional_processing_uplift_multiplier_us": 1.10,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
@@ -21088,6 +22068,8 @@
"output_cost_per_token_flex": 1.5e-05,
"output_cost_per_token_batches": 1.5e-05,
"output_cost_per_token_priority": 6e-05,
+ "regional_processing_uplift_multiplier_eu": 1.10,
+ "regional_processing_uplift_multiplier_us": 1.10,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
@@ -21132,6 +22114,8 @@
"output_cost_per_token_above_272k_tokens": 0.00027,
"output_cost_per_token_flex": 9e-05,
"output_cost_per_token_batches": 9e-05,
+ "regional_processing_uplift_multiplier_eu": 1.10,
+ "regional_processing_uplift_multiplier_us": 1.10,
"supported_endpoints": [
"/v1/responses",
"/v1/batch"
@@ -21176,6 +22160,8 @@
"output_cost_per_token_above_272k_tokens": 0.00027,
"output_cost_per_token_flex": 9e-05,
"output_cost_per_token_batches": 9e-05,
+ "regional_processing_uplift_multiplier_eu": 1.10,
+ "regional_processing_uplift_multiplier_us": 1.10,
"supported_endpoints": [
"/v1/responses",
"/v1/batch"
@@ -21224,6 +22210,8 @@
"output_cost_per_token_flex": 7.5e-06,
"output_cost_per_token_batches": 7.5e-06,
"output_cost_per_token_priority": 3e-05,
+ "regional_processing_uplift_multiplier_eu": 1.10,
+ "regional_processing_uplift_multiplier_us": 1.10,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
@@ -21271,6 +22259,8 @@
"output_cost_per_token_flex": 7.5e-06,
"output_cost_per_token_batches": 7.5e-06,
"output_cost_per_token_priority": 3e-05,
+ "regional_processing_uplift_multiplier_eu": 1.10,
+ "regional_processing_uplift_multiplier_us": 1.10,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
@@ -21311,6 +22301,8 @@
"output_cost_per_token_above_272k_tokens": 0.00027,
"output_cost_per_token_flex": 9e-05,
"output_cost_per_token_batches": 9e-05,
+ "regional_processing_uplift_multiplier_eu": 1.10,
+ "regional_processing_uplift_multiplier_us": 1.10,
"supported_endpoints": [
"/v1/responses",
"/v1/batch"
@@ -21354,6 +22346,8 @@
"output_cost_per_token_above_272k_tokens": 0.00027,
"output_cost_per_token_flex": 9e-05,
"output_cost_per_token_batches": 9e-05,
+ "regional_processing_uplift_multiplier_eu": 1.10,
+ "regional_processing_uplift_multiplier_us": 1.10,
"supported_endpoints": [
"/v1/responses",
"/v1/batch"
@@ -21399,6 +22393,8 @@
"output_cost_per_token_flex": 2.25e-06,
"output_cost_per_token_batches": 2.25e-06,
"output_cost_per_token_priority": 9e-06,
+ "regional_processing_uplift_multiplier_eu": 1.10,
+ "regional_processing_uplift_multiplier_us": 1.10,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
@@ -21445,6 +22441,8 @@
"output_cost_per_token_flex": 2.25e-06,
"output_cost_per_token_batches": 2.25e-06,
"output_cost_per_token_priority": 9e-06,
+ "regional_processing_uplift_multiplier_eu": 1.10,
+ "regional_processing_uplift_multiplier_us": 1.10,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
@@ -21488,6 +22486,8 @@
"output_cost_per_token": 1.25e-06,
"output_cost_per_token_flex": 6.25e-07,
"output_cost_per_token_batches": 6.25e-07,
+ "regional_processing_uplift_multiplier_eu": 1.10,
+ "regional_processing_uplift_multiplier_us": 1.10,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
@@ -21531,6 +22531,8 @@
"output_cost_per_token": 1.25e-06,
"output_cost_per_token_flex": 6.25e-07,
"output_cost_per_token_batches": 6.25e-07,
+ "regional_processing_uplift_multiplier_eu": 1.10,
+ "regional_processing_uplift_multiplier_us": 1.10,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
@@ -21569,8 +22571,6 @@
"mode": "responses",
"output_cost_per_token": 0.00012,
"output_cost_per_token_batches": 6e-05,
- "regional_processing_uplift_multiplier_eu": 1.10,
- "regional_processing_uplift_multiplier_us": 1.10,
"supported_endpoints": [
"/v1/batch",
"/v1/responses"
@@ -21977,8 +22977,6 @@
"output_cost_per_token": 2e-06,
"output_cost_per_token_flex": 1e-06,
"output_cost_per_token_priority": 3.6e-06,
- "regional_processing_uplift_multiplier_eu": 1.10,
- "regional_processing_uplift_multiplier_us": 1.10,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
@@ -22060,8 +23058,6 @@
"max_input_tokens": 272000,
"max_output_tokens": 128000,
"max_tokens": 128000,
- "regional_processing_uplift_multiplier_eu": 1.10,
- "regional_processing_uplift_multiplier_us": 1.10,
"mode": "chat",
"output_cost_per_token": 4e-07,
"output_cost_per_token_flex": 2e-07,
@@ -24180,9 +25176,12 @@
"max_output_tokens": 8192
},
"minimax/MiniMax-M3": {
- "input_cost_per_token": 6e-07,
- "output_cost_per_token": 2.4e-06,
- "cache_read_input_token_cost": 1.2e-07,
+ "input_cost_per_token": 3e-07,
+ "input_cost_per_token_above_512k_tokens": 6e-07,
+ "output_cost_per_token": 1.2e-06,
+ "output_cost_per_token_above_512k_tokens": 2.4e-06,
+ "cache_read_input_token_cost": 6e-08,
+ "cache_read_input_token_cost_above_512k_tokens": 1.2e-07,
"litellm_provider": "minimax",
"mode": "chat",
"supports_function_calling": true,
@@ -24191,7 +25190,7 @@
"supports_reasoning": true,
"supports_system_messages": true,
"supports_vision": true,
- "max_input_tokens": 512000,
+ "max_input_tokens": 1000000,
"max_output_tokens": 128000
},
"mistral.devstral-2-123b": {
@@ -24800,6 +25799,21 @@
"supports_tool_choice": true,
"supports_vision": true
},
+ "mistral/mistral-medium-3-5": {
+ "input_cost_per_token": 1.5e-06,
+ "litellm_provider": "mistral",
+ "max_input_tokens": 262144,
+ "max_output_tokens": 262144,
+ "max_tokens": 262144,
+ "mode": "chat",
+ "output_cost_per_token": 7.5e-06,
+ "source": "https://docs.mistral.ai/models/model-cards/mistral-medium-3-5-26-04",
+ "supports_assistant_prefill": true,
+ "supports_function_calling": true,
+ "supports_response_schema": true,
+ "supports_tool_choice": true,
+ "supports_vision": true
+ },
"mistral/mistral-small": {
"input_cost_per_token": 1e-07,
"litellm_provider": "mistral",
@@ -34004,6 +35018,7 @@
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
+ "supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
@@ -34032,6 +35047,67 @@
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
+ "supports_sampling_params": false,
+ "supports_tool_choice": true,
+ "supports_vision": true,
+ "supports_xhigh_reasoning_effort": true,
+ "supports_max_reasoning_effort": true
+ },
+ "vertex_ai/claude-fable-5": {
+ "cache_creation_input_token_cost": 1.25e-05,
+ "cache_creation_input_token_cost_above_1hr": 2e-05,
+ "cache_read_input_token_cost": 1e-06,
+ "input_cost_per_token": 1e-05,
+ "litellm_provider": "vertex_ai-anthropic_models",
+ "max_input_tokens": 1000000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "chat",
+ "output_cost_per_token": 5e-05,
+ "search_context_cost_per_query": {
+ "search_context_size_high": 0.01,
+ "search_context_size_low": 0.01,
+ "search_context_size_medium": 0.01
+ },
+ "supports_adaptive_thinking": true,
+ "supports_assistant_prefill": false,
+ "supports_computer_use": true,
+ "supports_function_calling": true,
+ "supports_pdf_input": true,
+ "supports_prompt_caching": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_sampling_params": false,
+ "supports_tool_choice": true,
+ "supports_vision": true,
+ "supports_xhigh_reasoning_effort": true,
+ "supports_max_reasoning_effort": true
+ },
+ "vertex_ai/claude-fable-5@default": {
+ "cache_creation_input_token_cost": 1.25e-05,
+ "cache_creation_input_token_cost_above_1hr": 2e-05,
+ "cache_read_input_token_cost": 1e-06,
+ "input_cost_per_token": 1e-05,
+ "litellm_provider": "vertex_ai-anthropic_models",
+ "max_input_tokens": 1000000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "chat",
+ "output_cost_per_token": 5e-05,
+ "search_context_cost_per_query": {
+ "search_context_size_high": 0.01,
+ "search_context_size_low": 0.01,
+ "search_context_size_medium": 0.01
+ },
+ "supports_adaptive_thinking": true,
+ "supports_assistant_prefill": false,
+ "supports_computer_use": true,
+ "supports_function_calling": true,
+ "supports_pdf_input": true,
+ "supports_prompt_caching": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
@@ -34061,6 +35137,7 @@
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
+ "supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
@@ -34090,6 +35167,7 @@
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
+ "supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
@@ -35517,7 +36595,17 @@
"max_input_tokens": 32000,
"max_tokens": 32000,
"mode": "embedding",
- "output_cost_per_token": 0.0
+ "output_cost_per_token": 0.0,
+ "supports_vision": true
+ },
+ "voyage/voyage-multimodal-3.5": {
+ "input_cost_per_token": 1.2e-07,
+ "litellm_provider": "voyage",
+ "max_input_tokens": 32000,
+ "max_tokens": 32000,
+ "mode": "embedding",
+ "output_cost_per_token": 0.0,
+ "supports_vision": true
},
"wandb/openai/gpt-oss-120b": {
"max_tokens": 131072,
@@ -38974,6 +40062,22 @@
"litellm_provider": "fireworks_ai",
"mode": "chat"
},
+ "fireworks_ai/accounts/fireworks/models/qwen3p7-plus": {
+ "cache_read_input_token_cost": 8e-08,
+ "input_cost_per_token": 4e-07,
+ "litellm_provider": "fireworks_ai",
+ "max_input_tokens": 262144,
+ "max_output_tokens": 65536,
+ "max_tokens": 65536,
+ "mode": "chat",
+ "output_cost_per_token": 1.6e-06,
+ "source": "https://docs.fireworks.ai/serverless/pricing",
+ "supports_function_calling": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_tool_choice": true,
+ "supports_vision": true
+ },
"fireworks_ai/accounts/fireworks/models/qwq-32b": {
"max_tokens": 131072,
"max_input_tokens": 131072,
@@ -39073,24 +40177,6 @@
"litellm_provider": "fireworks_ai",
"mode": "chat"
},
- "fireworks_ai/accounts/fireworks/models/whisper-v3": {
- "max_tokens": 4096,
- "max_input_tokens": 4096,
- "max_output_tokens": 4096,
- "input_cost_per_token": 0.0,
- "output_cost_per_token": 0.0,
- "litellm_provider": "fireworks_ai",
- "mode": "audio_transcription"
- },
- "fireworks_ai/accounts/fireworks/models/whisper-v3-turbo": {
- "max_tokens": 4096,
- "max_input_tokens": 4096,
- "max_output_tokens": 4096,
- "input_cost_per_token": 0.0,
- "output_cost_per_token": 0.0,
- "litellm_provider": "fireworks_ai",
- "mode": "audio_transcription"
- },
"fireworks_ai/accounts/fireworks/models/yi-34b": {
"max_tokens": 4096,
"max_input_tokens": 4096,
@@ -39136,6 +40222,54 @@
"litellm_provider": "fireworks_ai",
"mode": "chat"
},
+ "fireworks_ai/accounts/fireworks/routers/glm-5p1-fast": {
+ "cache_read_input_token_cost": 5.2e-07,
+ "input_cost_per_token": 2.8e-06,
+ "litellm_provider": "fireworks_ai",
+ "max_input_tokens": 202800,
+ "max_output_tokens": 131072,
+ "max_tokens": 131072,
+ "mode": "chat",
+ "output_cost_per_token": 8.8e-06,
+ "source": "https://docs.fireworks.ai/serverless/pricing",
+ "supports_function_calling": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_tool_choice": true,
+ "supports_vision": false
+ },
+ "fireworks_ai/accounts/fireworks/routers/kimi-k2p6-fast": {
+ "cache_read_input_token_cost": 3e-07,
+ "input_cost_per_token": 2e-06,
+ "litellm_provider": "fireworks_ai",
+ "max_input_tokens": 262144,
+ "max_output_tokens": 262144,
+ "max_tokens": 262144,
+ "mode": "chat",
+ "output_cost_per_token": 8e-06,
+ "source": "https://docs.fireworks.ai/serverless/pricing",
+ "supports_function_calling": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_tool_choice": true,
+ "supports_vision": true
+ },
+ "fireworks_ai/accounts/fireworks/routers/kimi-k2p7-code-fast": {
+ "cache_read_input_token_cost": 3.8e-07,
+ "input_cost_per_token": 1.9e-06,
+ "litellm_provider": "fireworks_ai",
+ "max_input_tokens": 262144,
+ "max_output_tokens": 262144,
+ "max_tokens": 262144,
+ "mode": "chat",
+ "output_cost_per_token": 8e-06,
+ "source": "https://docs.fireworks.ai/serverless/pricing",
+ "supports_function_calling": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_tool_choice": true,
+ "supports_vision": true
+ },
"novita/deepseek/deepseek-v3.2": {
"litellm_provider": "novita",
"mode": "chat",
@@ -40439,6 +41573,174 @@
"litellm_provider": "llamagate",
"mode": "embedding"
},
+ "libertai/hermes-3-8b-tee": {
+ "max_tokens": 16000,
+ "max_input_tokens": 16000,
+ "max_output_tokens": 16000,
+ "input_cost_per_token": 1.5e-07,
+ "output_cost_per_token": 6e-07,
+ "litellm_provider": "libertai",
+ "mode": "chat",
+ "supports_function_calling": true,
+ "supports_tool_choice": true,
+ "supports_system_messages": true,
+ "supports_vision": false,
+ "source": "https://docs.libertai.io/apis/text/"
+ },
+ "libertai/gemma-4-31b-it": {
+ "max_tokens": 262144,
+ "max_input_tokens": 262144,
+ "max_output_tokens": 262144,
+ "input_cost_per_token": 1.5e-07,
+ "output_cost_per_token": 4e-07,
+ "litellm_provider": "libertai",
+ "mode": "chat",
+ "supports_function_calling": true,
+ "supports_tool_choice": true,
+ "supports_system_messages": true,
+ "supports_vision": true,
+ "source": "https://docs.libertai.io/apis/text/"
+ },
+ "libertai/gemma-4-31b-it-thinking": {
+ "max_tokens": 262144,
+ "max_input_tokens": 262144,
+ "max_output_tokens": 262144,
+ "input_cost_per_token": 1.5e-07,
+ "output_cost_per_token": 4e-07,
+ "litellm_provider": "libertai",
+ "mode": "chat",
+ "supports_function_calling": true,
+ "supports_tool_choice": true,
+ "supports_system_messages": true,
+ "supports_vision": true,
+ "supports_reasoning": true,
+ "source": "https://docs.libertai.io/apis/text/"
+ },
+ "libertai/qwen3.6-27b": {
+ "max_tokens": 262144,
+ "max_input_tokens": 262144,
+ "max_output_tokens": 262144,
+ "input_cost_per_token": 1.5e-07,
+ "output_cost_per_token": 5e-07,
+ "litellm_provider": "libertai",
+ "mode": "chat",
+ "supports_function_calling": true,
+ "supports_tool_choice": true,
+ "supports_system_messages": true,
+ "supports_vision": true,
+ "source": "https://docs.libertai.io/apis/text/"
+ },
+ "libertai/qwen3.6-27b-thinking": {
+ "max_tokens": 262144,
+ "max_input_tokens": 262144,
+ "max_output_tokens": 262144,
+ "input_cost_per_token": 1.5e-07,
+ "output_cost_per_token": 5e-07,
+ "litellm_provider": "libertai",
+ "mode": "chat",
+ "supports_function_calling": true,
+ "supports_tool_choice": true,
+ "supports_system_messages": true,
+ "supports_vision": true,
+ "supports_reasoning": true,
+ "source": "https://docs.libertai.io/apis/text/"
+ },
+ "libertai/qwen3.6-35b-a3b": {
+ "max_tokens": 262144,
+ "max_input_tokens": 262144,
+ "max_output_tokens": 262144,
+ "input_cost_per_token": 1.5e-07,
+ "output_cost_per_token": 5e-07,
+ "litellm_provider": "libertai",
+ "mode": "chat",
+ "supports_function_calling": true,
+ "supports_tool_choice": true,
+ "supports_system_messages": true,
+ "supports_vision": true,
+ "source": "https://docs.libertai.io/apis/text/"
+ },
+ "libertai/qwen3.6-35b-a3b-thinking": {
+ "max_tokens": 262144,
+ "max_input_tokens": 262144,
+ "max_output_tokens": 262144,
+ "input_cost_per_token": 1.5e-07,
+ "output_cost_per_token": 5e-07,
+ "litellm_provider": "libertai",
+ "mode": "chat",
+ "supports_function_calling": true,
+ "supports_tool_choice": true,
+ "supports_system_messages": true,
+ "supports_vision": true,
+ "supports_reasoning": true,
+ "source": "https://docs.libertai.io/apis/text/"
+ },
+ "libertai/qwen3.5-122b-a10b": {
+ "max_tokens": 262144,
+ "max_input_tokens": 262144,
+ "max_output_tokens": 262144,
+ "input_cost_per_token": 2.5e-07,
+ "output_cost_per_token": 1.75e-06,
+ "litellm_provider": "libertai",
+ "mode": "chat",
+ "supports_function_calling": true,
+ "supports_tool_choice": true,
+ "supports_system_messages": true,
+ "supports_vision": true,
+ "source": "https://docs.libertai.io/apis/text/"
+ },
+ "libertai/qwen3.5-122b-a10b-thinking": {
+ "max_tokens": 262144,
+ "max_input_tokens": 262144,
+ "max_output_tokens": 262144,
+ "input_cost_per_token": 2.5e-07,
+ "output_cost_per_token": 1.75e-06,
+ "litellm_provider": "libertai",
+ "mode": "chat",
+ "supports_function_calling": true,
+ "supports_tool_choice": true,
+ "supports_system_messages": true,
+ "supports_vision": true,
+ "supports_reasoning": true,
+ "source": "https://docs.libertai.io/apis/text/"
+ },
+ "libertai/deepseek-v4-flash": {
+ "max_tokens": 200000,
+ "max_input_tokens": 200000,
+ "max_output_tokens": 200000,
+ "input_cost_per_token": 2.5e-07,
+ "output_cost_per_token": 1.75e-06,
+ "litellm_provider": "libertai",
+ "mode": "chat",
+ "supports_function_calling": true,
+ "supports_tool_choice": true,
+ "supports_system_messages": true,
+ "supports_vision": false,
+ "source": "https://docs.libertai.io/apis/text/"
+ },
+ "libertai/deepseek-v4-flash-thinking": {
+ "max_tokens": 200000,
+ "max_input_tokens": 200000,
+ "max_output_tokens": 200000,
+ "input_cost_per_token": 2.5e-07,
+ "output_cost_per_token": 1.75e-06,
+ "litellm_provider": "libertai",
+ "mode": "chat",
+ "supports_function_calling": true,
+ "supports_tool_choice": true,
+ "supports_system_messages": true,
+ "supports_vision": false,
+ "supports_reasoning": true,
+ "source": "https://docs.libertai.io/apis/text/"
+ },
+ "libertai/bge-m3": {
+ "max_tokens": 8192,
+ "max_input_tokens": 8192,
+ "input_cost_per_token": 1e-08,
+ "output_cost_per_token": 0.0,
+ "litellm_provider": "libertai",
+ "mode": "embedding",
+ "source": "https://docs.libertai.io/apis/text/"
+ },
"sarvam/sarvam-m": {
"cache_creation_input_token_cost": 0,
"cache_creation_input_token_cost_above_1hr": 0,
@@ -40637,6 +41939,23 @@
"supports_system_messages": true,
"supports_tool_choice": true
},
+ "gpt-realtime-whisper": {
+ "input_cost_per_second": 0.0002833333333333333,
+ "litellm_provider": "openai",
+ "mode": "audio_transcription",
+ "source": "https://platform.openai.com/docs/models/gpt-realtime-whisper",
+ "supported_endpoints": [
+ "/v1/realtime",
+ "/v1/realtime/transcription_sessions"
+ ],
+ "supported_modalities": [
+ "audio"
+ ],
+ "supported_output_modalities": [
+ "text"
+ ],
+ "supports_audio_input": true
+ },
"sora-2": {
"litellm_provider": "openai",
"mode": "video_generation",
@@ -41315,6 +42634,7 @@
"max_output_tokens": 32768,
"max_tokens": 32768,
"mode": "chat",
+ "supported_endpoints": ["/v1/chat/completions", "/v1/responses"],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_reasoning": true,
@@ -41329,6 +42649,7 @@
"max_output_tokens": 32768,
"max_tokens": 32768,
"mode": "chat",
+ "supported_endpoints": ["/v1/chat/completions", "/v1/responses"],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_reasoning": true,
@@ -41343,6 +42664,7 @@
"max_output_tokens": 65536,
"max_tokens": 65536,
"mode": "chat",
+ "supported_endpoints": ["/v1/chat/completions"],
"supports_function_calling": true,
"supports_reasoning": true,
"supports_response_schema": true,
@@ -41356,6 +42678,7 @@
"max_output_tokens": 65536,
"max_tokens": 65536,
"mode": "chat",
+ "supported_endpoints": ["/v1/chat/completions"],
"supports_function_calling": true,
"supports_reasoning": true,
"supports_response_schema": true,
@@ -41370,6 +42693,7 @@
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "responses",
+ "use_openai_responses_path": true,
"supported_endpoints": ["/v1/responses"],
"supported_modalities": ["text", "image"],
"supported_output_modalities": ["text"],
@@ -41389,6 +42713,7 @@
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "responses",
+ "use_openai_responses_path": true,
"supported_endpoints": ["/v1/responses"],
"supported_modalities": ["text", "image"],
"supported_output_modalities": ["text"],
@@ -41399,6 +42724,54 @@
"supports_tool_choice": true,
"supports_vision": true
},
+ "bedrock_mantle/google.gemma-4-31b": {
+ "input_cost_per_token": 1.4e-07,
+ "output_cost_per_token": 4e-07,
+ "litellm_provider": "bedrock_mantle",
+ "max_input_tokens": 256000,
+ "max_output_tokens": 256000,
+ "max_tokens": 256000,
+ "mode": "chat",
+ "use_openai_responses_path": true,
+ "supported_endpoints": ["/v1/chat/completions", "/v1/responses"],
+ "supports_function_calling": true,
+ "supports_parallel_function_calling": false,
+ "supports_reasoning": true,
+ "supports_tool_choice": true,
+ "supports_vision": true
+ },
+ "bedrock_mantle/google.gemma-4-26b-a4b": {
+ "input_cost_per_token": 1.3e-07,
+ "output_cost_per_token": 4e-07,
+ "litellm_provider": "bedrock_mantle",
+ "max_input_tokens": 256000,
+ "max_output_tokens": 256000,
+ "max_tokens": 256000,
+ "mode": "chat",
+ "use_openai_responses_path": true,
+ "supported_endpoints": ["/v1/chat/completions", "/v1/responses"],
+ "supports_function_calling": true,
+ "supports_parallel_function_calling": false,
+ "supports_reasoning": true,
+ "supports_tool_choice": true,
+ "supports_vision": true
+ },
+ "bedrock_mantle/google.gemma-4-e2b": {
+ "input_cost_per_token": 4e-08,
+ "output_cost_per_token": 8e-08,
+ "litellm_provider": "bedrock_mantle",
+ "max_input_tokens": 128000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "chat",
+ "use_openai_responses_path": true,
+ "supported_endpoints": ["/v1/chat/completions", "/v1/responses"],
+ "supports_function_calling": true,
+ "supports_parallel_function_calling": false,
+ "supports_reasoning": true,
+ "supports_tool_choice": true,
+ "supports_vision": true
+ },
"volcengine/doubao-seed-2-0-pro-260215": {
"litellm_provider": "volcengine",
"max_input_tokens": 256000,
@@ -41690,5 +43063,312 @@
"/v1/audio/transcriptions"
],
"supports_audio_input": true
+ },
+ "soniox/stt-async-v5": {
+ "litellm_provider": "soniox",
+ "max_output_tokens": 8000,
+ "max_tokens": 8000,
+ "input_cost_per_second": 0.0,
+ "output_cost_per_second": 0.0000277778,
+ "mode": "audio_transcription",
+ "source": "https://soniox.com/pricing",
+ "supported_endpoints": [
+ "/v1/audio/transcriptions"
+ ],
+ "supports_audio_input": true
+ },
+ "tensormesh/Qwen/Qwen3.5-397B-A17B-FP8": {
+ "litellm_provider": "tensormesh",
+ "mode": "chat",
+ "input_cost_per_token": 6e-07,
+ "output_cost_per_token": 3.6e-06,
+ "cache_read_input_token_cost": 0,
+ "max_input_tokens": 262144,
+ "max_output_tokens": 262144,
+ "supports_function_calling": true,
+ "supports_tool_choice": true,
+ "supports_response_schema": true,
+ "supports_prompt_caching": true,
+ "supports_system_messages": true,
+ "supports_reasoning": true,
+ "source": "https://serverless.tensormesh.ai/v1/models/openrouter"
+ },
+ "tensormesh/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": {
+ "litellm_provider": "tensormesh",
+ "mode": "chat",
+ "input_cost_per_token": 4.5e-07,
+ "output_cost_per_token": 1.8e-06,
+ "cache_read_input_token_cost": 0,
+ "max_input_tokens": 262144,
+ "max_output_tokens": 262144,
+ "supports_function_calling": true,
+ "supports_tool_choice": true,
+ "supports_response_schema": true,
+ "supports_prompt_caching": true,
+ "supports_system_messages": true,
+ "source": "https://serverless.tensormesh.ai/v1/models/openrouter"
+ },
+ "tensormesh/Qwen/Qwen3.6-27B-FP8": {
+ "litellm_provider": "tensormesh",
+ "mode": "chat",
+ "input_cost_per_token": 3.2e-07,
+ "output_cost_per_token": 3.2e-06,
+ "cache_read_input_token_cost": 0,
+ "max_input_tokens": 262144,
+ "max_output_tokens": 262144,
+ "supports_function_calling": true,
+ "supports_tool_choice": true,
+ "supports_response_schema": true,
+ "supports_prompt_caching": true,
+ "supports_system_messages": true,
+ "supports_reasoning": true,
+ "source": "https://serverless.tensormesh.ai/v1/models/openrouter"
+ },
+ "tensormesh/lukealonso/GLM-5.1-NVFP4-MTP": {
+ "litellm_provider": "tensormesh",
+ "mode": "chat",
+ "input_cost_per_token": 1.4e-06,
+ "output_cost_per_token": 4.4e-06,
+ "cache_read_input_token_cost": 0,
+ "max_input_tokens": 202752,
+ "max_output_tokens": 202752,
+ "supports_function_calling": true,
+ "supports_tool_choice": true,
+ "supports_response_schema": true,
+ "supports_prompt_caching": true,
+ "supports_system_messages": true,
+ "supports_reasoning": true,
+ "source": "https://serverless.tensormesh.ai/v1/models/openrouter"
+ },
+ "tensormesh/deepseek-ai/DeepSeek-V4-Flash": {
+ "litellm_provider": "tensormesh",
+ "mode": "chat",
+ "input_cost_per_token": 1.4e-07,
+ "output_cost_per_token": 2.8e-07,
+ "cache_read_input_token_cost": 0,
+ "max_input_tokens": 32768,
+ "max_output_tokens": 32768,
+ "supports_function_calling": true,
+ "supports_tool_choice": true,
+ "supports_response_schema": true,
+ "supports_prompt_caching": true,
+ "supports_system_messages": true,
+ "supports_reasoning": true,
+ "source": "https://serverless.tensormesh.ai/v1/models/openrouter"
+ },
+ "tensormesh/moonshotai/Kimi-K2.6": {
+ "litellm_provider": "tensormesh",
+ "mode": "chat",
+ "input_cost_per_token": 9.6e-07,
+ "output_cost_per_token": 4e-06,
+ "cache_read_input_token_cost": 0,
+ "max_input_tokens": 32768,
+ "max_output_tokens": 32768,
+ "supports_function_calling": true,
+ "supports_tool_choice": true,
+ "supports_response_schema": true,
+ "supports_prompt_caching": true,
+ "supports_system_messages": true,
+ "supports_reasoning": true,
+ "source": "https://serverless.tensormesh.ai/v1/models/openrouter"
+ },
+ "tensormesh/MiniMaxAI/MiniMax-M2.5": {
+ "litellm_provider": "tensormesh",
+ "mode": "chat",
+ "input_cost_per_token": 3e-07,
+ "output_cost_per_token": 1.2e-06,
+ "cache_read_input_token_cost": 0,
+ "max_input_tokens": 196608,
+ "max_output_tokens": 196608,
+ "supports_function_calling": true,
+ "supports_tool_choice": true,
+ "supports_response_schema": true,
+ "supports_prompt_caching": true,
+ "supports_system_messages": true,
+ "supports_reasoning": true,
+ "source": "https://serverless.tensormesh.ai/v1/models/openrouter"
+ },
+ "tensormesh/google/gemma-4-31B-it": {
+ "litellm_provider": "tensormesh",
+ "mode": "chat",
+ "input_cost_per_token": 1.4e-07,
+ "output_cost_per_token": 5.6e-07,
+ "cache_read_input_token_cost": 0,
+ "max_input_tokens": 32768,
+ "max_output_tokens": 32768,
+ "supports_function_calling": true,
+ "supports_tool_choice": true,
+ "supports_response_schema": true,
+ "supports_prompt_caching": true,
+ "supports_system_messages": true,
+ "supports_reasoning": true,
+ "source": "https://serverless.tensormesh.ai/v1/models/openrouter"
+ },
+ "tensormesh/openai/gpt-oss-120b": {
+ "litellm_provider": "tensormesh",
+ "mode": "chat",
+ "input_cost_per_token": 1.5e-07,
+ "output_cost_per_token": 6e-07,
+ "cache_read_input_token_cost": 0,
+ "max_input_tokens": 131072,
+ "max_output_tokens": 131072,
+ "supports_function_calling": true,
+ "supports_tool_choice": true,
+ "supports_response_schema": true,
+ "supports_prompt_caching": true,
+ "supports_system_messages": true,
+ "supports_reasoning": true,
+ "source": "https://serverless.tensormesh.ai/v1/models/openrouter"
+ },
+ "tensormesh/openai/gpt-oss-20b": {
+ "litellm_provider": "tensormesh",
+ "mode": "chat",
+ "input_cost_per_token": 7e-08,
+ "output_cost_per_token": 2.8e-07,
+ "cache_read_input_token_cost": 0,
+ "max_input_tokens": 131072,
+ "max_output_tokens": 131072,
+ "supports_function_calling": true,
+ "supports_tool_choice": true,
+ "supports_response_schema": true,
+ "supports_prompt_caching": true,
+ "supports_system_messages": true,
+ "supports_reasoning": true,
+ "source": "https://serverless.tensormesh.ai/v1/models/openrouter"
}
-}
\ No newline at end of file
+,
+ "deepseek-v4-flash": {
+ "cache_creation_input_token_cost": 0.0,
+ "cache_read_input_token_cost": 2.8e-09,
+ "input_cost_per_token": 1.4e-07,
+ "input_cost_per_token_cache_hit": 2.8e-09,
+ "litellm_provider": "deepseek",
+ "max_input_tokens": 1000000,
+ "max_output_tokens": 8192,
+ "max_tokens": 8192,
+ "mode": "chat",
+ "output_cost_per_token": 2.8e-07,
+ "source": "https://api-docs.deepseek.com/quick_start/pricing",
+ "supported_endpoints": [
+ "/v1/chat/completions"
+ ],
+ "supports_assistant_prefill": true,
+ "supports_function_calling": true,
+ "supports_native_streaming": true,
+ "supports_parallel_function_calling": true,
+ "supports_prompt_caching": true,
+ "supports_response_schema": true,
+ "supports_system_messages": true,
+ "supports_tool_choice": true,
+ "supports_vision": false
+ },
+ "deepseek-v4-pro": {
+ "cache_creation_input_token_cost": 0.0,
+ "cache_read_input_token_cost": 3.625e-09,
+ "input_cost_per_token": 4.35e-07,
+ "input_cost_per_token_cache_hit": 3.625e-09,
+ "litellm_provider": "deepseek",
+ "max_input_tokens": 1000000,
+ "max_output_tokens": 8192,
+ "max_tokens": 8192,
+ "mode": "chat",
+ "output_cost_per_token": 8.7e-07,
+ "source": "https://api-docs.deepseek.com/quick_start/pricing",
+ "supported_endpoints": [
+ "/v1/chat/completions"
+ ],
+ "supports_assistant_prefill": true,
+ "supports_function_calling": true,
+ "supports_native_streaming": true,
+ "supports_parallel_function_calling": true,
+ "supports_prompt_caching": true,
+ "supports_response_schema": true,
+ "supports_system_messages": true,
+ "supports_tool_choice": true,
+ "supports_vision": false
+ },
+ "deepseek/deepseek-v4-flash": {
+ "cache_creation_input_token_cost": 0.0,
+ "cache_read_input_token_cost": 2.8e-09,
+ "input_cost_per_token": 1.4e-07,
+ "input_cost_per_token_cache_hit": 2.8e-09,
+ "litellm_provider": "deepseek",
+ "max_input_tokens": 1000000,
+ "max_output_tokens": 8192,
+ "max_tokens": 8192,
+ "mode": "chat",
+ "output_cost_per_token": 2.8e-07,
+ "source": "https://api-docs.deepseek.com/quick_start/pricing",
+ "supported_endpoints": [
+ "/v1/chat/completions"
+ ],
+ "supports_assistant_prefill": true,
+ "supports_function_calling": true,
+ "supports_native_streaming": true,
+ "supports_parallel_function_calling": true,
+ "supports_prompt_caching": true,
+ "supports_response_schema": true,
+ "supports_system_messages": true,
+ "supports_tool_choice": true,
+ "supports_vision": false
+ },
+ "darkbloom/gemma-4-26b": {
+ "input_cost_per_token": 3e-08,
+ "litellm_provider": "darkbloom",
+ "max_input_tokens": 131072,
+ "max_output_tokens": 32768,
+ "max_tokens": 32768,
+ "mode": "chat",
+ "output_cost_per_token": 1.65e-07,
+ "source": "https://www.darkbloom.dev/",
+ "supported_endpoints": [
+ "/v1/chat/completions"
+ ],
+ "supports_function_calling": true,
+ "supports_native_streaming": true,
+ "supports_system_messages": true,
+ "supports_tool_choice": true
+ },
+ "darkbloom/gpt-oss-20b": {
+ "input_cost_per_token": 1.45e-08,
+ "litellm_provider": "darkbloom",
+ "max_input_tokens": 131072,
+ "max_output_tokens": 32768,
+ "max_tokens": 32768,
+ "mode": "chat",
+ "output_cost_per_token": 7e-08,
+ "source": "https://www.darkbloom.dev/",
+ "supported_endpoints": [
+ "/v1/chat/completions"
+ ],
+ "supports_function_calling": true,
+ "supports_native_streaming": true,
+ "supports_system_messages": true,
+ "supports_tool_choice": true
+ },
+ "deepseek/deepseek-v4-pro": {
+ "cache_creation_input_token_cost": 0.0,
+ "cache_read_input_token_cost": 3.625e-09,
+ "input_cost_per_token": 4.35e-07,
+ "input_cost_per_token_cache_hit": 3.625e-09,
+ "litellm_provider": "deepseek",
+ "max_input_tokens": 1000000,
+ "max_output_tokens": 8192,
+ "max_tokens": 8192,
+ "mode": "chat",
+ "output_cost_per_token": 8.7e-07,
+ "source": "https://api-docs.deepseek.com/quick_start/pricing",
+ "supported_endpoints": [
+ "/v1/chat/completions"
+ ],
+ "supports_assistant_prefill": true,
+ "supports_function_calling": true,
+ "supports_native_streaming": true,
+ "supports_parallel_function_calling": true,
+ "supports_prompt_caching": true,
+ "supports_response_schema": true,
+ "supports_system_messages": true,
+ "supports_tool_choice": true,
+ "supports_vision": false
+ }
+}
diff --git a/litellm/mypy.ini b/litellm/mypy.ini
deleted file mode 100644
index 4702b591124..00000000000
--- a/litellm/mypy.ini
+++ /dev/null
@@ -1,19 +0,0 @@
-[mypy]
-warn_return_any = False
-ignore_missing_imports = True
-mypy_path = litellm/stubs
-namespace_packages = True
-disable_error_code =
- valid-type,
- annotation-unchecked,
- import-untyped
-
-[mypy-google.*]
-ignore_missing_imports = True
-
-[mypy-cryptography.hazmat.bindings._rust.x509]
-ignore_errors = True
-
-[mypy-fastuuid.*]
-ignore_missing_imports = True
-ignore_errors = True
\ No newline at end of file
diff --git a/litellm/ocr/main.py b/litellm/ocr/main.py
index b27082c361a..3a9ef8db804 100644
--- a/litellm/ocr/main.py
+++ b/litellm/ocr/main.py
@@ -10,7 +10,7 @@ import os
import re
from functools import partial
from io import IOBase
-from typing import Any, Coroutine, Dict, Optional, Union
+from typing import Any, Callable, Coroutine, Dict, Optional, Union, cast
import httpx
@@ -20,6 +20,7 @@ from litellm.constants import request_timeout
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.base_llm.ocr.transformation import BaseOCRConfig, OCRResponse
from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler
+from litellm.ocr.rust_bridge import RustOcr, load_rust_ocr, rust_ocr_enabled
from litellm.types.router import GenericLiteLLMParams
from litellm.utils import ProviderConfigManager, client
@@ -28,6 +29,82 @@ base_llm_http_handler = BaseLLMHTTPHandler()
#################################################
+def _timeout_to_seconds(
+ timeout: Optional[Union[float, httpx.Timeout]],
+) -> Optional[float]:
+ """Convert the Python OCR timeout to a single seconds value for the Rust bridge.
+
+ The Rust HTTP client takes one duration; ``httpx.Timeout`` carries separate
+ connect/read/write/pool values, so pick the read deadline as the closest
+ analog to a total-request timeout.
+ """
+ if timeout is None:
+ return None
+ if isinstance(timeout, httpx.Timeout):
+ return timeout.read
+ return float(timeout)
+
+
+def _run_rust_ocr(
+ rust_ocr: RustOcr,
+ logging_obj: LiteLLMLoggingObj,
+ provider_config: BaseOCRConfig,
+ resolve_api_key: Callable[[str], Optional[str]],
+ model: str,
+ document: dict[str, object],
+ api_key: Optional[str],
+ api_base: Optional[str],
+ optional_params: dict[str, object],
+ litellm_params: dict[str, object],
+ timeout_seconds: Optional[float],
+) -> OCRResponse:
+ """Run the Mistral OCR call through the Rust bridge and wrap the result.
+
+ Resolves the key the same way the Python path does so secret-manager backends
+ (AWS/Azure/GCP/Vault) work; the Rust bridge's own fallback only reads the
+ process environment. The request that Rust actually sends (resolved URL and
+ headers) is mirrored into pre_call so logs match the wire. Dependencies are
+ injected so this stays unit-testable without patching module globals.
+ """
+ resolved_api_key = api_key or resolve_api_key("MISTRAL_API_KEY")
+ resolved_headers = provider_config.validate_environment(
+ headers={},
+ model=model,
+ api_key=resolved_api_key,
+ api_base=api_base,
+ litellm_params=litellm_params,
+ )
+ resolved_complete_url = provider_config.get_complete_url(
+ api_base=api_base,
+ model=model,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ )
+ logging_obj.pre_call(
+ input="OCR document processing",
+ api_key=resolved_api_key,
+ additional_args={
+ "complete_input_dict": {
+ "model": model,
+ "document": document,
+ **optional_params,
+ },
+ "api_base": resolved_complete_url,
+ "headers": resolved_headers,
+ },
+ )
+ return OCRResponse.model_validate(
+ rust_ocr(
+ model=model,
+ document=document,
+ api_key=resolved_api_key,
+ api_base=api_base,
+ optional_params=optional_params,
+ timeout_seconds=timeout_seconds,
+ )
+ )
+
+
@client
async def aocr(
model: str,
@@ -220,7 +297,7 @@ def ocr(
"""
local_vars = locals()
try:
- litellm_logging_obj: LiteLLMLoggingObj = kwargs.pop("litellm_logging_obj") # type: ignore
+ litellm_logging_obj = cast(LiteLLMLoggingObj, kwargs.pop("litellm_logging_obj"))
litellm_call_id: Optional[str] = kwargs.get("litellm_call_id", None)
_is_async = kwargs.pop("aocr", False) is True
@@ -261,7 +338,6 @@ def ocr(
if dynamic_api_base:
api_base = dynamic_api_base
- # Get provider config
ocr_provider_config: Optional[BaseOCRConfig] = (
ProviderConfigManager.get_provider_ocr_config(
model=model,
@@ -278,17 +354,14 @@ def ocr(
f"OCR call - model: {model}, provider: {custom_llm_provider}"
)
- # Get litellm params using GenericLiteLLMParams (same as responses API)
litellm_params = GenericLiteLLMParams(**kwargs)
- # Extract OCR-specific parameters from kwargs
supported_params = ocr_provider_config.get_supported_ocr_params(model=model)
non_default_params = {}
for param in supported_params:
if param in kwargs:
non_default_params[param] = kwargs.pop(param)
- # Map parameters to provider-specific format
optional_params = ocr_provider_config.map_ocr_params(
non_default_params=non_default_params,
optional_params={},
@@ -297,7 +370,8 @@ def ocr(
verbose_logger.debug(f"OCR optional_params after mapping: {optional_params}")
- # Pre Call logging
+ effective_timeout = timeout or request_timeout
+
litellm_logging_obj.update_from_kwargs(
kwargs=kwargs,
model=model,
@@ -309,12 +383,35 @@ def ocr(
custom_llm_provider=custom_llm_provider,
)
- # Call the handler - pass document dict directly
+ # Optional Rust path: hand the whole Mistral OCR call to the Rust bridge.
+ if custom_llm_provider == "mistral" and rust_ocr_enabled():
+ rust_ocr = load_rust_ocr()
+ if rust_ocr is None:
+ verbose_logger.debug(
+ "Rust OCR bridge unavailable; falling back to Python path"
+ )
+ else:
+ from litellm.secret_managers.main import get_secret_str
+
+ return _run_rust_ocr(
+ rust_ocr=rust_ocr,
+ logging_obj=litellm_logging_obj,
+ provider_config=ocr_provider_config,
+ resolve_api_key=get_secret_str,
+ model=model,
+ document=document,
+ api_key=api_key,
+ api_base=api_base,
+ optional_params=optional_params,
+ litellm_params=dict(litellm_params),
+ timeout_seconds=_timeout_to_seconds(effective_timeout),
+ )
+
response = base_llm_http_handler.ocr(
model=model,
- document=document, # Pass the entire document dict
+ document=document,
optional_params=optional_params,
- timeout=timeout or request_timeout,
+ timeout=effective_timeout,
logging_obj=litellm_logging_obj,
api_key=api_key,
api_base=api_base,
diff --git a/litellm/ocr/rust_bridge.py b/litellm/ocr/rust_bridge.py
new file mode 100644
index 00000000000..61f9e8ca69a
--- /dev/null
+++ b/litellm/ocr/rust_bridge.py
@@ -0,0 +1,74 @@
+"""
+Optional Rust-backed OCR path.
+
+Enable with ``litellm.use_litellm_rust()``; the sync ``litellm.ocr()`` entrypoint
+then routes supported Mistral calls through the compiled ``litellm_python_bridge``
+extension, which performs the whole OCR call (URL, headers, HTTP, parse) in Rust.
+
+No module-level ``litellm`` imports keep this a leaf so ``litellm/ocr/main.py``
+can import it statically without forming an import cycle.
+"""
+
+from __future__ import annotations
+
+from typing import Final, Protocol, cast
+
+
+class RustOcr(Protocol):
+ """Signature of the compiled ``litellm_python_bridge.ocr`` entrypoint."""
+
+ def __call__(
+ self,
+ model: str,
+ document: dict[str, object],
+ api_key: str | None,
+ api_base: str | None,
+ optional_params: dict[str, object],
+ timeout_seconds: float | None,
+ ) -> dict[str, object]: ...
+
+
+class _Unset:
+ """Sentinel type so ``ocr=None`` can clear a prior injection while omission preserves it."""
+
+
+_UNSET: Final[_Unset] = _Unset()
+
+_rust_ocr_enabled = False
+_rust_ocr_impl: RustOcr | None = None
+
+
+def use_litellm_rust(
+ enabled: bool = True, *, ocr: RustOcr | None | _Unset = _UNSET
+) -> None:
+ """Route supported OCR calls through the Rust ``litellm_python_bridge`` extension.
+
+ ``ocr`` injects the bridge callable; when omitted the compiled extension is
+ loaded on demand and any previously injected bridge is preserved. Pass
+ ``ocr=None`` explicitly to clear a prior injection.
+ """
+ global _rust_ocr_enabled, _rust_ocr_impl
+ _rust_ocr_enabled = enabled
+ if not isinstance(ocr, _Unset):
+ _rust_ocr_impl = ocr
+
+
+def rust_ocr_enabled() -> bool:
+ """Whether the Rust OCR path has been turned on via ``use_litellm_rust()``."""
+ return _rust_ocr_enabled
+
+
+def load_rust_ocr() -> RustOcr | None:
+ """Return the Rust OCR callable, or ``None`` when no bridge is available.
+
+ Prefers an injected implementation, otherwise loads the compiled
+ ``litellm_python_bridge`` extension; a missing extension yields ``None`` so
+ the caller can fall back to the Python path instead of hard-failing.
+ """
+ if _rust_ocr_impl is not None:
+ return _rust_ocr_impl
+ try:
+ import litellm_python_bridge
+ except ImportError:
+ return None
+ return cast(RustOcr, litellm_python_bridge.ocr)
diff --git a/litellm/passthrough/main.py b/litellm/passthrough/main.py
index c4c9aea6f64..3e60988b9e7 100644
--- a/litellm/passthrough/main.py
+++ b/litellm/passthrough/main.py
@@ -20,7 +20,6 @@ from typing import (
import httpx
from httpx._types import CookieTypes, QueryParamTypes, RequestFiles
-import litellm
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
@@ -201,12 +200,6 @@ def llm_passthrough_route(
_is_async = allm_passthrough_route
- if client is None:
- if _is_async:
- client = litellm.module_level_aclient
- else:
- client = litellm.module_level_client
-
litellm_logging_obj = cast("LiteLLMLoggingObj", kwargs.get("litellm_logging_obj"))
model, custom_llm_provider, api_key, api_base = get_llm_provider(
@@ -218,6 +211,26 @@ def llm_passthrough_route(
litellm_params_dict = get_litellm_params(**kwargs)
+ if client is None:
+ from litellm.llms.custom_httpx.http_handler import (
+ _get_httpx_client,
+ get_async_httpx_client,
+ )
+ from litellm.passthrough.timeout_utils import resolve_llm_passthrough_timeout
+ from litellm.types.llms.custom_http import httpxSpecialProvider
+
+ resolved_timeout = resolve_llm_passthrough_timeout(
+ kwargs=kwargs,
+ litellm_params=litellm_params_dict,
+ )
+ if _is_async:
+ client = get_async_httpx_client(
+ llm_provider=httpxSpecialProvider.PassThroughEndpoint,
+ params={"timeout": resolved_timeout},
+ )
+ else:
+ client = _get_httpx_client(params={"timeout": resolved_timeout})
+
# Add model_id to litellm_params if present in kwargs (for Bedrock Application Inference Profiles)
if "model_id" in kwargs:
litellm_params_dict["model_id"] = kwargs["model_id"]
diff --git a/litellm/passthrough/timeout_utils.py b/litellm/passthrough/timeout_utils.py
new file mode 100644
index 00000000000..a423db2aa91
--- /dev/null
+++ b/litellm/passthrough/timeout_utils.py
@@ -0,0 +1,58 @@
+import sys
+from typing import Optional
+
+DEFAULT_PASS_THROUGH_REQUEST_TIMEOUT_SECONDS = 600.0
+
+
+def resolve_pass_through_request_timeout(
+ endpoint_timeout: Optional[float] = None,
+) -> float:
+ """
+ Resolve the upstream httpx timeout for pass_through_request.
+
+ Precedence: per-endpoint timeout -> general_settings.pass_through_request_timeout -> 600s default.
+
+ Uses sys.modules to read general_settings only when the proxy module is already
+ loaded, avoiding a fastapi transitive import in pure SDK contexts.
+ """
+ if endpoint_timeout is not None:
+ return float(endpoint_timeout)
+
+ try:
+ proxy_server = sys.modules.get("litellm.proxy.proxy_server")
+ if proxy_server is not None:
+ global_timeout = getattr(proxy_server, "general_settings", {}).get(
+ "pass_through_request_timeout"
+ )
+ if global_timeout is not None:
+ return float(global_timeout)
+ except Exception:
+ pass
+
+ return DEFAULT_PASS_THROUGH_REQUEST_TIMEOUT_SECONDS
+
+
+def resolve_llm_passthrough_timeout(
+ kwargs: Optional[dict] = None,
+ litellm_params: Optional[dict] = None,
+ router_timeout: Optional[float] = None,
+) -> float:
+ """
+ Resolve upstream httpx timeout for SDK native passthrough (e.g. Bedrock /converse).
+
+ Precedence: kwargs timeout/request_timeout -> litellm_params timeout/request_timeout
+ -> router_timeout -> general_settings.pass_through_request_timeout -> 600s default.
+ """
+ kwargs = kwargs or {}
+ litellm_params = litellm_params or {}
+
+ for source in (kwargs, litellm_params):
+ for key in ("timeout", "request_timeout"):
+ val = source.get(key)
+ if val is not None:
+ return float(val)
+
+ if router_timeout is not None:
+ return float(router_timeout)
+
+ return resolve_pass_through_request_timeout()
diff --git a/litellm/provider_endpoints_support_backup.json b/litellm/provider_endpoints_support_backup.json
index e0eeb014c51..dd7712aabca 100644
--- a/litellm/provider_endpoints_support_backup.json
+++ b/litellm/provider_endpoints_support_backup.json
@@ -1288,6 +1288,23 @@
"interactions": true
}
},
+ "libertai": {
+ "display_name": "LibertAI (`libertai`)",
+ "url": "https://docs.litellm.ai/docs/providers/libertai",
+ "endpoints": {
+ "chat_completions": true,
+ "messages": true,
+ "responses": false,
+ "embeddings": false,
+ "image_generations": false,
+ "audio_transcriptions": false,
+ "audio_speech": false,
+ "moderations": false,
+ "batches": false,
+ "rerank": false,
+ "a2a": false
+ }
+ },
"litellm_proxy": {
"display_name": "LiteLLM Proxy (`litellm_proxy`)",
"url": "https://docs.litellm.ai/docs/providers/litellm_proxy",
@@ -1818,6 +1835,23 @@
"interactions": true
}
},
+ "darkbloom": {
+ "display_name": "Darkbloom (`darkbloom`)",
+ "url": "https://docs.litellm.ai/docs/providers/darkbloom",
+ "endpoints": {
+ "chat_completions": true,
+ "messages": false,
+ "responses": false,
+ "embeddings": false,
+ "image_generations": false,
+ "audio_transcriptions": false,
+ "audio_speech": false,
+ "moderations": false,
+ "batches": false,
+ "rerank": false,
+ "a2a": false
+ }
+ },
"predibase": {
"display_name": "Predibase (`predibase`)",
"url": "https://docs.litellm.ai/docs/providers/predibase",
diff --git a/litellm/proxy/_experimental/mcp_server/AGENTS.md b/litellm/proxy/_experimental/mcp_server/AGENTS.md
new file mode 100644
index 00000000000..8eebc3ea3b3
--- /dev/null
+++ b/litellm/proxy/_experimental/mcp_server/AGENTS.md
@@ -0,0 +1,95 @@
+# Experimental MCP Server Change Guidelines
+
+Read @../../../../CLAUDE.md and @CLAUDE.md before changing this package.
+
+This directory owns the proxy-hosted MCP server implementation. Keep changes
+inside the module that owns the behavior, and only reach outside this package
+when the public type contract, database schema, dashboard, or cross-proxy route
+wiring must change with it.
+
+## File Structure
+
+Respect the current package boundaries:
+
+```text
+litellm/proxy/_experimental/mcp_server/
+ AGENTS.md
+ CLAUDE.md
+ server.py # ASGI/MCP route handling, sessions, tool calls [PR7: 7-arm only — move BYOK/OAuth pre-fetch into resolver]
+ mcp_server_manager.py # upstream server registry, clients, tool routing [PR7: _create_mcp_client swaps resolve_mcp_auth -> resolve_credentials]
+ auth/
+ user_api_key_auth_mcp.py # LiteLLM admission auth and MCP request headers
+ token_exchange.py # OAuth token exchange handling [unchanged; V1TokenExchangeAdapter delegates here]
+ litellm_auth_handler.py # authenticated-user adapter for MCP sessions
+ outbound_credentials/ # NEW — typed upstream-credential resolution (resolve_credentials + arms)
+ __init__.py # public surface: resolve_credentials, the configs, CredError
+ result.py # Ok | Error union (pure stdlib)
+ types.py # AuthConfig union, CredError, Subject, ServerSpec
+ httpx_auth.py # NoOpAuth, StaticHeaderAuth (every mode -> one httpx.Auth)
+ resolver.py # resolve_credentials(): exhaustive per-mode match + assert_never
+ seams.py # injected Protocols (one per cache-touching mode)
+ v1_adapters.py # v1-backed seam bodies; delegate to auth/oauth2/db owners
+ adapter.py # to_subject / to_server_spec / raise_public (v1 <-> v2 boundary)
+ discoverable_endpoints.py # MCP OAuth metadata, authorize, token, callback
+ byok_oauth_endpoints.py # BYOK OAuth UI/API flow
+ oauth_utils.py # redirect URI and proxy base URL validation
+ oauth2_token_cache.py # OAuth2 and per-user token resolution/cache [PR7: resolve_mcp_auth removed; cache class stays, V1OAuth2CacheAdapter delegates to async_get_token]
+ db.py # MCP server, credential, env var, submission DB access [unchanged; V1ByokStore delegates to _get_byok_credential / get_user_credential]
+ toolset_db.py # MCP toolset DB access
+ rest_endpoints.py # proxy REST facade for listing/calling MCP tools [PR7: 7-arm only — pass identity + inbound token down instead of mcp_auth_header]
+ openapi_to_mcp_generator.py# OpenAPI spec to MCP tool generation
+ sampling_handler.py # MCP sampling to LiteLLM completion flow
+ elicitation_handler.py # MCP elicitation relay flow
+ semantic_tool_filter.py # semantic filtering of available MCP tools
+ guardrail_translation/
+ handler.py # MCP guardrail result translation
+ sse_transport.py # SSE transport implementation
+ mcp_context.py # contextvars for MCP request/session metadata
+ mcp_debug.py # debug helpers
+ tool_registry.py # in-memory MCP tool registry helpers
+ cost_calculator.py # MCP tool cost calculation
+ ui_session_utils.py # dashboard session auth context helpers
+ utils.py # shared primitives used by several modules
+```
+
+Do not add broad catch-all modules. Prefer the existing owner above, and add a
+new file only for a distinct capability that would otherwise make an existing
+module materially harder to understand.
+
+## Implementation Rules
+
+- Preserve the boundary between LiteLLM admission auth and upstream MCP auth.
+ Admission belongs in `auth/user_api_key_auth_mcp.py`; upstream token exchange,
+ delegated auth, per-user OAuth, BYOK, and raw header forwarding belong in the
+ dedicated OAuth/header modules.
+- Treat `none`, bearer/API key, OAuth, OAuth token exchange, delegated upstream
+ auth, SSE, streamable HTTP, and stdio as separate flows. Do not collapse them
+ behind a single generic branch unless tests prove every mode still behaves
+ correctly.
+- Be especially careful with `available_on_public_internet: false` combined with
+ `delegate_auth_to_upstream: true`. The local `CLAUDE.md` explains the anonymous
+ upstream PKCE path that must remain intentional.
+- Keep database-backed fields in sync across migrations, typed models under
+ `litellm/types/mcp.py` or `litellm/types/mcp_server/`, config loading, this
+ package, and dashboard state when the field is user-visible.
+- Use the official MCP SDK types and established LiteLLM Pydantic models where
+ they exist. Avoid untyped protocol dictionaries at package boundaries.
+- Keep security-sensitive logic easy to audit. Header forwarding, IP filtering,
+ public internet checks, token storage, env var interpolation, and credential
+ encryption need focused tests for both allowed and rejected paths.
+- Avoid adding comments to new code unless they explain non-obvious security or
+ protocol behavior. Prefer clear names and small functions.
+
+## Tests
+
+Mirror this package under `tests/test_litellm/proxy/_experimental/mcp_server/`.
+For regressions, extend the existing mapped test file instead of creating a new
+one. Use subdirectories that match the implementation path, such as
+`auth/test_token_exchange.py` for `auth/token_exchange.py` and
+`guardrail_translation/test_mcp_guardrail_handler.py` for
+`guardrail_translation/handler.py`.
+
+Use `tests/mcp_tests/` only when extending an existing broader MCP integration
+scenario that already lives there. Route, auth, tool listing, tool execution,
+OAuth, sampling, elicitation, DB, and dashboard-session changes should have
+focused coverage in the mirrored `tests/test_litellm/...` path first.
diff --git a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py
index dcf7660d002..90108de25c3 100644
--- a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py
+++ b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py
@@ -12,6 +12,7 @@ from litellm.proxy._types import (
LiteLLM_TeamTable,
ProxyException,
SpecialHeaders,
+ SpecialMCPServerNames,
UserAPIKeyAuth,
)
from litellm.proxy.auth.ip_address_utils import IPAddressUtils
@@ -67,9 +68,10 @@ def _is_mcp_passthrough_cold_start(
spec-compliant WWW-Authenticate challenge instead of surfacing a generic
admission error.
- Uses "all" semantics (mirrors :meth:`MCPRequestHandler._target_servers_use_oauth2`):
- one non-passthrough target in a co-targeted set must not flip the bypass
- open for the others. Fails closed when any target cannot be resolved."""
+ Uses "all" semantics (mirrors
+ :meth:`MCPRequestHandler._target_servers_delegate_auth_to_upstream`): one
+ non-passthrough target in a co-targeted set must not flip the bypass open
+ for the others. Fails closed when any target cannot be resolved."""
if not mcp_servers:
return False
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
@@ -124,7 +126,7 @@ class MCPRequestHandler:
LITELLM_MCP_ACCESS_GROUPS_HEADER_NAME = SpecialHeaders.mcp_access_groups.value
@staticmethod
- async def process_mcp_request( # noqa: PLR0915
+ async def process_mcp_request(
scope: Scope,
) -> Tuple[
UserAPIKeyAuth,
@@ -214,101 +216,64 @@ class MCPRequestHandler:
# Only OAuth metadata routes registered under /.well-known/ are public.
if request_route.startswith("/.well-known/"):
validated_user_api_key_auth = UserAPIKeyAuth()
- elif (
- not litellm_api_key
- and MCPRequestHandler._target_servers_delegate_auth_to_upstream( # noqa: E501
- path=request_route,
- mcp_servers=mcp_servers,
- client_ip=IPAddressUtils.get_mcp_client_ip(request),
- )
- ):
- # Operator opted this oauth2 server into upstream-delegated auth
- # (PKCE passthrough): skip LiteLLM API-key/SSO entirely so the
- # client authenticates directly with the upstream MCP server.
- # Fires ONLY when neither x-litellm-api-key nor Authorization is
- # present. If any LiteLLM key is supplied (primary or secondary
- # header), we fall through so user_id is resolved, spend/rate
- # limiting apply, and any stored OAuth token can be retrieved
- # and forwarded upstream. Gated by
- # _target_servers_delegate_auth_to_upstream, which only returns
- # True when EVERY target is auth_type=oauth2 AND has the
- # delegate_auth_to_upstream flag set — fails closed otherwise.
- validated_user_api_key_auth = UserAPIKeyAuth()
elif has_explicit_litellm_key:
- # Explicit x-litellm-api-key provided - always validate normally
+ # An explicit x-litellm-api-key is always a LiteLLM credential, even
+ # for a delegated server, so validate it: identity / spend / rate
+ # limits resolve and any stored upstream token can be forwarded.
validated_user_api_key_auth = await user_api_key_auth(
api_key=litellm_api_key, request=request
)
+ elif MCPRequestHandler._target_servers_delegate_auth_to_upstream(
+ path=request_route,
+ mcp_servers=mcp_servers,
+ client_ip=IPAddressUtils.get_mcp_client_ip(request),
+ ):
+ # Operator opted this oauth2 server into upstream-delegated auth: the
+ # client authenticates directly with the upstream MCP server, so any
+ # Authorization bearer is an upstream token, never a LiteLLM key. Skip
+ # LiteLLM validation entirely — covering both the no-credential
+ # discovery request and the authenticated call carrying the upstream
+ # bearer — so a tool call that succeeds never carries a phantom 401
+ # auth span; the bearer is forwarded upstream unchanged. Gated by
+ # _target_servers_delegate_auth_to_upstream, which returns True only
+ # when EVERY target is auth_type=oauth2 with delegate_auth_to_upstream
+ # set; fails closed otherwise.
+ validated_user_api_key_auth = UserAPIKeyAuth()
elif oauth2_headers:
- # No x-litellm-api-key, but Authorization header present.
- # Could be a LiteLLM key (backward compat) OR an opaque OAuth2 token
- # the operator wants forwarded to an upstream OAuth2-mode MCP server.
- # Try LiteLLM auth first; on auth failure, only fall back to anonymous
- # passthrough when the request actually targets a server whose operator
- # configured ``auth_type=oauth2``. For any other server (api_key,
- # bearer_token, basic, etc.), a failed LiteLLM auth is a real failure
- # and must propagate — otherwise an attacker can exchange any garbage
- # bearer for an anonymous session.
+ # Authorization on a non-delegated server: the bearer must be a real
+ # LiteLLM credential, so a failed validation is a genuine 401/403 and
+ # propagates. The sole anonymous fallback is the auth_type=none
+ # pass-through cold-start (RFC 9728 discovery return), gated on a 401
+ # so a recognized-but-forbidden key still fails closed.
+ client_ip = IPAddressUtils.get_mcp_client_ip(request)
try:
validated_user_api_key_auth = await user_api_key_auth(
api_key=litellm_api_key, request=request
)
except (HTTPException, ProxyException) as e:
- # HTTPException.status_code is int; ProxyException.code is
- # normalized to str in its __init__ but can be ``"None"`` or any
- # non-numeric string when the caller didn't supply a numeric
- # code, so we compare against both int and str forms rather
- # than coercing (``int("None")`` would raise ValueError and
- # rewrite the auth error as a 500).
+ # ProxyException.code is normalized to str (possibly "None"), so
+ # compare both int and str forms rather than coercing.
status = e.status_code if isinstance(e, HTTPException) else e.code
- is_auth_error = status in (401, 403, "401", "403")
is_unauthenticated = status in (401, "401")
- client_ip = IPAddressUtils.get_mcp_client_ip(request)
- if is_auth_error and MCPRequestHandler._target_servers_use_oauth2(
- path=request_route,
- mcp_servers=mcp_servers,
- client_ip=client_ip,
+ mcp_servers_from_path = _parse_mcp_server_names_from_path(
+ request_route, mcp_servers
+ )
+ if (
+ is_unauthenticated
+ and mcp_servers_from_path is not None
+ and not _has_client_supplied_mcp_auth(
+ mcp_auth_header,
+ mcp_server_auth_headers,
+ )
+ and _is_mcp_passthrough_cold_start(
+ mcp_servers_from_path, client_ip=client_ip
+ )
):
verbose_logger.debug(
- "MCP OAuth2: target server is OAuth2-mode, treating "
- "Authorization as upstream OAuth2 token passthrough"
+ "MCP pass-through return: forwarding Authorization as "
+ "upstream OAuth token for delegated auth"
)
validated_user_api_key_auth = UserAPIKeyAuth()
- elif is_unauthenticated:
- # Pass-through cold-start return: per RFC 9728 / MCP
- # Authorization spec the client completes upstream OAuth
- # discovery and returns with ``Authorization: Bearer
- # ``. For ``auth_type=none`` passthrough
- # servers that bearer is not a LiteLLM key (auth above
- # failed) but is meant to be forwarded upstream
- # unchanged. Fall back to anonymous admission so the
- # caller is not rejected for following the discovery
- # flow without also setting ``x-litellm-api-key``.
- # Only trigger on 401 (token unrecognized); a 403 means
- # the key WAS recognized but is forbidden (e.g. over
- # budget / rate limited) and must propagate so those
- # controls are not bypassed via anonymous admission.
- mcp_servers_from_path = _parse_mcp_server_names_from_path(
- request_route, mcp_servers
- )
- if (
- mcp_servers_from_path is not None
- and not _has_client_supplied_mcp_auth(
- mcp_auth_header,
- mcp_server_auth_headers,
- )
- and _is_mcp_passthrough_cold_start(
- mcp_servers_from_path, client_ip=client_ip
- )
- ):
- verbose_logger.debug(
- "MCP pass-through return: target server is "
- "passthrough, treating Authorization as "
- "upstream OAuth token for delegated auth"
- )
- validated_user_api_key_auth = UserAPIKeyAuth()
- else:
- raise
else:
raise
else:
@@ -412,45 +377,6 @@ class MCPRequestHandler:
return [single_server_match.group(1)]
return [servers_and_path]
- @staticmethod
- def _target_servers_use_oauth2(
- path: str, mcp_servers: Optional[List[str]], client_ip: Optional[str]
- ) -> bool:
- """
- True only when EVERY MCP server the request targets is configured for
- ``auth_type == oauth2``. If any target is non-OAuth2 — or if the target
- cannot be resolved at all — return False so the caller fails closed.
-
- Used to gate the "treat Authorization as opaque OAuth2 token" fallback
- in :meth:`process_mcp_request` so a failed LiteLLM-auth cannot be
- exchanged for an anonymous session against a non-OAuth2 server.
- """
- # Inline imports avoid a circular dependency: mcp_server_manager imports
- # from this module.
- from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
- global_mcp_server_manager,
- )
- from litellm.types.mcp import MCPAuth
-
- # Resolve the same target list downstream routing will use. For
- # ``/mcp/...`` routes, ``extract_mcp_auth_context`` overrides the
- # ``x-mcp-servers`` header with path-derived names, so we must mirror
- # that here — otherwise a caller could set the header to a permissive
- # server while the path targets a stricter one (header/path TOCTOU).
- target_names = MCPRequestHandler._resolve_target_server_names(
- path=path, mcp_servers_header=mcp_servers
- )
- if not target_names:
- return False
-
- for name in target_names:
- server = global_mcp_server_manager.get_mcp_server_by_name(
- name, client_ip=client_ip
- )
- if server is None or server.auth_type != MCPAuth.oauth2:
- return False
- return True
-
@staticmethod
def _target_servers_delegate_auth_to_upstream(
path: str, mcp_servers: Optional[List[str]], client_ip: Optional[str]
@@ -472,8 +398,8 @@ class MCPRequestHandler:
)
from litellm.types.mcp import MCPAuth
- # See _target_servers_use_oauth2: must mirror the downstream
- # header-vs-path override or an attacker could set
+ # Must mirror the downstream header-vs-path override
+ # (``extract_mcp_auth_context``) or an attacker could set
# ``x-mcp-servers`` to a delegate-enabled server while the URL path
# targets a non-delegate server, skipping LiteLLM auth for it.
target_names = MCPRequestHandler._resolve_target_server_names(
@@ -717,6 +643,15 @@ class MCPRequestHandler:
user_api_key_auth
)
)
+
+ # The key explicitly opted out of every MCP server. This overrides
+ # team inheritance and additive grants (mirrors no-default-models).
+ if (
+ SpecialMCPServerNames.no_mcp_servers.value
+ in allowed_mcp_servers_for_key
+ ):
+ return []
+
allowed_mcp_servers_for_team = (
await MCPRequestHandler._get_allowed_mcp_servers_for_team(
user_api_key_auth
@@ -1133,6 +1068,13 @@ class MCPRequestHandler:
if key_object_permission is None:
return []
+ # Sentinel opt-out: surface it unexpanded so the caller can short-circuit
+ # to zero servers instead of inheriting the team.
+ if SpecialMCPServerNames.no_mcp_servers.value in (
+ key_object_permission.mcp_servers or []
+ ):
+ return [SpecialMCPServerNames.no_mcp_servers.value]
+
# Permission entries may be server_ids OR names/aliases — expand to ids.
direct_mcp_servers = global_mcp_server_manager.expand_permission_list(
key_object_permission.mcp_servers or []
diff --git a/litellm/proxy/_experimental/mcp_server/db.py b/litellm/proxy/_experimental/mcp_server/db.py
index c52752940c3..8edb831a9df 100644
--- a/litellm/proxy/_experimental/mcp_server/db.py
+++ b/litellm/proxy/_experimental/mcp_server/db.py
@@ -568,10 +568,12 @@ async def delete_mcp_server(
"""
Delete the mcp server from the db by server_id
- The server-row delete is the commit point. Per-user env var rows have no FK
- cascade, so they are cleaned up afterwards on a best-effort basis: a transient
- failure there leaves only orphaned rows pointing at a now-missing server and
- must not turn a successful delete into a caller-visible error.
+ The server-row delete is the commit point. Per-user credential and env var
+ rows have no FK cascade, so they are cleaned up afterwards on a best-effort
+ basis: a transient failure there leaves only orphaned rows pointing at a
+ now-missing server and must not turn a successful delete into a
+ caller-visible error. Each table is cleaned independently so a failure on one
+ still attempts the other.
Returns the deleted mcp server record if it exists, otherwise None
"""
@@ -581,17 +583,20 @@ async def delete_mcp_server(
},
)
if deleted_server is not None:
- try:
- await prisma_client.db.litellm_mcpuserenvvars.delete_many(
- where={"server_id": server_id}
- )
- except Exception as e:
- verbose_proxy_logger.warning(
- "MCP server %s deleted but per-user env var cleanup failed; "
- "orphaned rows can be removed on a later delete: %s",
- server_id,
- e,
- )
+ for model, label in (
+ (prisma_client.db.litellm_mcpusercredentials, "credential"),
+ (prisma_client.db.litellm_mcpuserenvvars, "env var"),
+ ):
+ try:
+ await model.delete_many(where={"server_id": server_id})
+ except Exception as e:
+ verbose_proxy_logger.warning(
+ "MCP server %s deleted but per-user %s cleanup failed; "
+ "orphaned rows can be removed on a later delete: %s",
+ server_id,
+ label,
+ e,
+ )
return deleted_server
diff --git a/litellm/proxy/_experimental/mcp_server/exceptions.py b/litellm/proxy/_experimental/mcp_server/exceptions.py
index fd8fc3d5e58..a00e797a6bd 100644
--- a/litellm/proxy/_experimental/mcp_server/exceptions.py
+++ b/litellm/proxy/_experimental/mcp_server/exceptions.py
@@ -10,8 +10,9 @@ class MCPUpstreamAuthError(Exception):
(typically HTTP 401) and the gateway should surface it transparently to
the client instead of swallowing it.
- Only relevant for pass-through MCP servers (see
- ``MCPServer.is_oauth_passthrough``). The gateway converts this exception
+ Relevant for MCP servers that delegate OAuth to the upstream server,
+ including pass-through servers and OAuth2 servers with
+ ``delegate_auth_to_upstream`` enabled. The gateway converts this exception
into an HTTP 401 response on single-server routes, preserving any
``WWW-Authenticate`` challenge emitted by the upstream so standards-
compliant MCP clients can trigger the upstream OAuth flow.
diff --git a/litellm/proxy/_experimental/mcp_server/mcp_context.py b/litellm/proxy/_experimental/mcp_server/mcp_context.py
index a60138dd340..51918509441 100644
--- a/litellm/proxy/_experimental/mcp_server/mcp_context.py
+++ b/litellm/proxy/_experimental/mcp_server/mcp_context.py
@@ -19,3 +19,9 @@ _mcp_active_toolset_id: ContextVar[Optional[str]] = ContextVar(
_mcp_gateway_initialize_instructions: ContextVar[Optional[str]] = ContextVar(
"_mcp_gateway_initialize_instructions", default=None
)
+
+# Per-request scoped server name; set in MCP HTTP/SSE handlers when the path
+# identifies exactly one upstream server. Never populated from client-supplied headers.
+_mcp_gateway_server_name: ContextVar[Optional[str]] = ContextVar(
+ "_mcp_gateway_server_name", default=None
+)
diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
index 85ac6b399f4..5e704b889ae 100644
--- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
+++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
@@ -80,6 +80,7 @@ from litellm.proxy._types import (
MCPEnvVar,
MCPTransport,
MCPTransportType,
+ SpecialMCPServerNames,
UserAPIKeyAuth,
)
from litellm.proxy.auth.ip_address_utils import IPAddressUtils
@@ -354,6 +355,52 @@ def _deserialize_json_list(data: Any) -> Optional[List[Dict[str, Any]]]:
]
+def _normalize_mcp_server_cost_info(mcp_info: MCPInfo) -> None:
+ """Coerce ``mcp_server_cost_info`` numeric fields to ``float`` at ingest.
+
+ YAML 1.1 parses scientific notation without a decimal point (e.g.
+ ``7e-05``) as a string, and ``MCPServerCostInfo`` is a TypedDict with no
+ runtime validation, so string-typed costs flow through to the UI and
+ crash its ``.toFixed`` formatting. Values that cannot be coerced are
+ dropped with a warning instead of failing the server load.
+ """
+ cost_info = mcp_info.get("mcp_server_cost_info")
+ if not isinstance(cost_info, dict):
+ return
+
+ server_name = mcp_info.get("server_name")
+ normalized = dict(cost_info)
+
+ default_cost = normalized.get("default_cost_per_query")
+ if default_cost is not None:
+ try:
+ normalized["default_cost_per_query"] = float(default_cost)
+ except (TypeError, ValueError):
+ verbose_logger.warning(
+ "MCP server '%s' has non-numeric default_cost_per_query %r; ignoring it",
+ server_name,
+ default_cost,
+ )
+ del normalized["default_cost_per_query"]
+
+ tool_costs = normalized.get("tool_name_to_cost_per_query")
+ if isinstance(tool_costs, dict):
+ normalized_tool_costs = {}
+ for tool_name, cost in tool_costs.items():
+ try:
+ normalized_tool_costs[tool_name] = float(cost)
+ except (TypeError, ValueError):
+ verbose_logger.warning(
+ "MCP server '%s' has non-numeric cost %r for tool '%s'; ignoring it",
+ server_name,
+ cost,
+ tool_name,
+ )
+ normalized["tool_name_to_cost_per_query"] = normalized_tool_costs
+
+ mcp_info["mcp_server_cost_info"] = normalized
+
+
def _create_sampling_callback(user_api_key_auth: Optional[Any] = None):
"""
Create a sampling callback for MCP ClientSession.
@@ -621,6 +668,7 @@ class MCPServerManager:
mcp_info["server_name"] = server_name
if "description" not in mcp_info and server_config.get("description"):
mcp_info["description"] = server_config.get("description")
+ _normalize_mcp_server_cost_info(mcp_info)
# Use alias for name if present, else server_name
alias = server_config.get("alias", None)
@@ -1091,6 +1139,7 @@ class MCPServerManager:
mcp_info["server_name"] = mcp_server.server_name or mcp_server.server_id
if "description" not in mcp_info and mcp_server.description:
mcp_info["description"] = mcp_server.description
+ _normalize_mcp_server_cost_info(mcp_info)
auth_type = cast(MCPAuthType, mcp_server.auth_type)
server_url = mcp_server.url
@@ -1301,6 +1350,17 @@ class MCPServerManager:
allow_all_server_ids = self.get_allow_all_keys_server_ids()
try:
+ # The key explicitly opted out of every MCP server. Return zero before
+ # layering on allow_all_keys servers so the opt-out is absolute.
+ key_object_permission = (
+ user_api_key_auth.object_permission if user_api_key_auth else None
+ )
+ if key_object_permission is not None and (
+ SpecialMCPServerNames.no_mcp_servers.value
+ in (key_object_permission.mcp_servers or [])
+ ):
+ return []
+
# Check if object_permission.mcp_servers is explicitly set
has_explicit_object_permission = False
if user_api_key_auth and user_api_key_auth.object_permission:
@@ -1373,8 +1433,11 @@ class MCPServerManager:
"No allowed MCP Servers found for user api key auth."
)
return list(combined_servers)
- except Exception as e:
- verbose_logger.warning(f"Failed to get allowed MCP servers: {str(e)}.")
+ except Exception: # noqa: BLE001
+ verbose_logger.exception(
+ "Failed to get allowed MCP servers; team-level object_permission "
+ "grants may be dropped. Falling back to global servers only."
+ )
return allow_all_server_ids
async def resolve_toolset_tool_permissions(
@@ -2729,28 +2792,40 @@ class MCPServerManager:
Uses anyio.fail_after() instead of asyncio.wait_for() to avoid conflicts
with the MCP SDK's anyio TaskGroup. See GitHub issue #20715 for details.
- For pass-through MCP servers (``MCPServer.is_oauth_passthrough``) an
+ For OAuth pass-through and upstream-delegated OAuth2 MCP servers, an
upstream HTTP 401 is converted into :class:`MCPUpstreamAuthError`
instead of being swallowed to an empty tool list. That lets the
single-server HTTP routes surface a proper 401 + ``WWW-Authenticate``
challenge so standards-compliant MCP clients trigger the upstream
- OAuth flow. Non-pass-through servers keep today's swallow-and-log
- behaviour so the multi-server ``/mcp`` aggregator doesn't get
- tainted by a single bad server.
+ OAuth flow. Other servers keep today's swallow-and-log behaviour so
+ the multi-server ``/mcp`` aggregator doesn't get tainted by a single
+ bad server.
Args:
client: MCP client instance
server_name: Name of the server for logging
- server: Optional MCPServer; when pass-through, auth errors are
- re-raised as :class:`MCPUpstreamAuthError`.
+ server: Optional MCPServer; when upstream auth is delegated, auth
+ errors are re-raised as :class:`MCPUpstreamAuthError`.
Returns:
List of tools from the server
"""
- is_passthrough = bool(server is not None and server.is_oauth_passthrough)
+ should_surface_upstream_auth = bool(
+ server is not None
+ and (
+ server.is_oauth_passthrough
+ or (
+ server.auth_type == MCPAuth.oauth2
+ and getattr(server, "delegate_auth_to_upstream", False) is True
+ and not server.has_client_credentials
+ )
+ )
+ )
try:
with anyio.fail_after(MCP_TOOL_LISTING_TIMEOUT):
- tools = await client.list_tools(raise_on_error=is_passthrough)
+ tools = await client.list_tools(
+ raise_on_error=should_surface_upstream_auth
+ )
verbose_logger.debug(f"Tools from {server_name}: {tools}")
return tools
except TimeoutError:
@@ -2767,12 +2842,12 @@ class MCPServerManager:
)
return []
except Exception as e:
- if is_passthrough:
+ if should_surface_upstream_auth:
auth_info = _extract_upstream_auth_failure(e)
if auth_info is not None:
status_code, www_authenticate = auth_info
verbose_logger.info(
- f"Upstream auth failure from pass-through MCP server "
+ f"Upstream auth failure from MCP server "
f"{server_name}: HTTP {status_code}"
)
raise MCPUpstreamAuthError(
@@ -3295,7 +3370,7 @@ class MCPServerManager:
)
)
- async def _call_regular_mcp_tool( # noqa: PLR0915
+ async def _call_regular_mcp_tool(
self,
mcp_server: MCPServer,
original_tool_name: str,
diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/__init__.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/__init__.py
new file mode 100644
index 00000000000..73166a45d6e
--- /dev/null
+++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/__init__.py
@@ -0,0 +1,73 @@
+"""Typed upstream-credential resolution for MCP servers.
+
+This subpackage houses the typed credential vocabulary and the ``resolve_credentials``
+dispatch. A server declares one per-mode config from the ``AuthConfig`` discriminated union;
+``UpstreamCredentialProvider.resolve_credentials`` selects one arm and returns an ``httpx.Auth``
+or a typed ``CredError``. Failures are modeled as values via :mod:`.result` (``Result[T,
+CredError]``) rather than raised, so every seam is total. Nothing here is wired onto a live
+request path yet.
+"""
+
+from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import (
+ NoOpAuth,
+ StaticHeaderAuth,
+)
+from litellm.proxy._experimental.mcp_server.outbound_credentials.resolver import (
+ UpstreamCredentialProvider,
+)
+from litellm.proxy._experimental.mcp_server.outbound_credentials.result import (
+ Error,
+ Ok,
+ Result,
+)
+from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
+ Ambient,
+ ApiKeyConfig,
+ ApiKeySource,
+ AssumeRole,
+ AuthConfig,
+ AuthorizationCodeConfig,
+ AuthSpecKind,
+ AwsCredentialSource,
+ AwsSigV4Config,
+ Byok,
+ ClientCredentialsConfig,
+ CredError,
+ NoneConfig,
+ PassthroughConfig,
+ ServerSpec,
+ SharedKey,
+ StaticKeys,
+ Subject,
+ TokenExchangeConfig,
+ parse_auth_spec_kind,
+)
+
+__all__ = [
+ "Ok",
+ "Error",
+ "Result",
+ "NoOpAuth",
+ "StaticHeaderAuth",
+ "UpstreamCredentialProvider",
+ "AuthSpecKind",
+ "CredError",
+ "Subject",
+ "ServerSpec",
+ "AuthConfig",
+ "parse_auth_spec_kind",
+ "AuthorizationCodeConfig",
+ "ClientCredentialsConfig",
+ "TokenExchangeConfig",
+ "ApiKeyConfig",
+ "ApiKeySource",
+ "SharedKey",
+ "Byok",
+ "PassthroughConfig",
+ "NoneConfig",
+ "AwsSigV4Config",
+ "AwsCredentialSource",
+ "StaticKeys",
+ "AssumeRole",
+ "Ambient",
+]
diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/httpx_auth.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/httpx_auth.py
new file mode 100644
index 00000000000..2345fa98123
--- /dev/null
+++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/httpx_auth.py
@@ -0,0 +1,45 @@
+"""Concrete `httpx.Auth` objects the resolver returns for the self-contained modes.
+
+These are the egress credential as the SDK consumes it: an `httpx.Auth` attached to the
+upstream `AsyncClient`. The OAuth-flow modes (`authorization_code`, `client_credentials`,
+`token_exchange`) return SDK-provided auth objects instead and land later.
+
+`auth_flow` mutating the outbound request is the `httpx.Auth` contract, not a house-style
+violation: the request is httpx's object, and these carry no state of their own.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Generator
+
+import httpx
+from pydantic import SecretStr
+
+
+class NoOpAuth(httpx.Auth):
+ """Attaches nothing — the `none` mode (and the seam-level default)."""
+
+ def auth_flow(
+ self, request: httpx.Request
+ ) -> Generator[httpx.Request, httpx.Response, None]:
+ yield request
+
+
+class StaticHeaderAuth(httpx.Auth):
+ """Sets one fixed header on every request — the `api_key` family and `passthrough`.
+
+ The header value is a live credential (a bearer token, an API key, a forwarded user
+ token), so it is held as a `SecretStr` and unwrapped only when written onto the request.
+ That keeps it masked in reprs, `vars()`, tracebacks, and structured logs, matching the
+ `SecretStr` discipline the config models use.
+ """
+
+ def __init__(self, header_value: str, header_name: str = "Authorization") -> None:
+ self.header_name = header_name
+ self._header_value = SecretStr(header_value)
+
+ def auth_flow(
+ self, request: httpx.Request
+ ) -> Generator[httpx.Request, httpx.Response, None]:
+ request.headers[self.header_name] = self._header_value.get_secret_value()
+ yield request
diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py
new file mode 100644
index 00000000000..7bcdb3e6529
--- /dev/null
+++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py
@@ -0,0 +1,70 @@
+"""The one credential resolver: dispatch on the declared mode, fail closed.
+
+`resolve_credentials` selects exactly one arm off the server's typed `config` and either
+produces an `httpx.Auth` or returns a typed `CredError`. The `match` is over the `AuthConfig`
+variant, so each arm receives its own fully-typed config with no field-presence inference and
+no precedence cascade. It is wildcard-free with an `assert_never` tail, so adding a mode without
+an arm fails the type gate (basedpyright `reportMatchNotExhaustive`); a bypassed gate fails loudly
+at runtime instead of returning `None`.
+
+This skeleton ships every arm as a `not_implemented` stub. Each mode's real body, with its
+injected seam, lands in its own follow-up PR; until then the arm returns a typed error rather
+than silently producing no credential. Pure v2: no imports from v1.
+"""
+
+from __future__ import annotations
+
+import httpx
+from typing_extensions import assert_never
+
+from litellm.proxy._experimental.mcp_server.outbound_credentials.result import (
+ Error,
+ Result,
+)
+from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
+ ApiKeyConfig,
+ AuthorizationCodeConfig,
+ AuthSpecKind,
+ AwsSigV4Config,
+ ClientCredentialsConfig,
+ CredError,
+ NoneConfig,
+ PassthroughConfig,
+ ServerSpec,
+ Subject,
+ TokenExchangeConfig,
+)
+
+
+class UpstreamCredentialProvider:
+ """Produces the one `httpx.Auth` for a `(subject, upstream)` pair, per declared mode.
+
+ Collaborators (the per-mode credential stores and token fetchers) are injected as each arm
+ is built; the skeleton needs none, since every arm is a stub.
+ """
+
+ async def resolve_credentials(
+ self, subject: Subject, server: ServerSpec
+ ) -> Result[httpx.Auth, CredError]:
+ match server.config:
+ case NoneConfig():
+ return _not_implemented(AuthSpecKind.none)
+ case ApiKeyConfig():
+ return _not_implemented(AuthSpecKind.api_key)
+ case PassthroughConfig():
+ return _not_implemented(AuthSpecKind.passthrough)
+ case ClientCredentialsConfig():
+ return _not_implemented(AuthSpecKind.client_credentials)
+ case TokenExchangeConfig():
+ return _not_implemented(AuthSpecKind.token_exchange)
+ case AuthorizationCodeConfig():
+ return _not_implemented(AuthSpecKind.authorization_code)
+ case AwsSigV4Config():
+ return _not_implemented(AuthSpecKind.aws_sigv4)
+ assert_never(server.config)
+
+
+def _not_implemented(kind: AuthSpecKind) -> Result[httpx.Auth, CredError]:
+ return Error(
+ CredError.of_not_implemented(f"{kind.value}: resolver arm not implemented yet")
+ )
diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/result.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/result.py
new file mode 100644
index 00000000000..a612e8510f5
--- /dev/null
+++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/result.py
@@ -0,0 +1,54 @@
+"""A tagged-union ``Result`` the type checker can actually narrow.
+
+``Ok`` and ``Error`` are separate frozen classes joined by a ``Union`` alias, so
+reaching for ``result.ok`` before eliminating the ``Error`` arm (via ``isinstance``
+or a ``match`` pattern) is a type error rather than a runtime ``AttributeError``. A
+single class carrying both payload fields would make that unguarded access invisible
+to the type checker.
+
+Both variants are covariant and frozen; the absent side defaults to ``Never`` so a
+bare ``Ok(value)`` or ``Error(err)`` infers fully and is assignable to any ``Result``
+whose matching side fits.
+
+``is_ok`` / ``is_error`` are runtime predicates that also narrow via their ``Literal``
+returns; inside strictly typed code, discriminate with ``match`` or ``isinstance``.
+
+This is the shared ``Result`` shape for the ``outbound_credentials`` resolver: every
+seam returns ``Result[T, CredError]`` instead of raising, so each failure is a value
+the caller must handle rather than an exception that can slip past the type checker.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Generic, Literal, TypeAlias
+
+from typing_extensions import Never, TypeVar
+
+_TOk_co = TypeVar("_TOk_co", covariant=True, default=Never)
+_TError_co = TypeVar("_TError_co", covariant=True, default=Never)
+
+
+@dataclass(frozen=True)
+class Ok(Generic[_TOk_co, _TError_co]):
+ ok: _TOk_co
+
+ def is_ok(self) -> Literal[True]:
+ return True
+
+ def is_error(self) -> Literal[False]:
+ return False
+
+
+@dataclass(frozen=True)
+class Error(Generic[_TOk_co, _TError_co]):
+ error: _TError_co
+
+ def is_ok(self) -> Literal[False]:
+ return False
+
+ def is_error(self) -> Literal[True]:
+ return True
+
+
+Result: TypeAlias = Ok[_TOk_co, _TError_co] | Error[_TOk_co, _TError_co]
diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py
new file mode 100644
index 00000000000..2088dc77252
--- /dev/null
+++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py
@@ -0,0 +1,334 @@
+"""The upstream-credential vocabulary — the typed seam the resolver dispatches on.
+
+This module ships the data types only; the resolver lands in a later PR. It is the contract
+the credential build implements and the spec tests assert against.
+
+Design invariants encoded here:
+
+- **Mode is the single source of truth.** A server declares exactly one per-mode `config`
+ (the `AuthConfig` discriminated union); `auth_spec_kind` is *derived* from it, never a
+ second field that can drift. The resolver dispatches on the config variant, one arm per
+ mode. No field-presence inference, no precedence cascade.
+- **Illegal states unrepresentable.** Each mode's config is its own frozen model holding
+ only that mode's fields — an `aws_sigv4` server cannot hold OAuth fields, and a config
+ missing a required field is rejected at construction, not at call time.
+- **Fail-closed at the boundary.** A raw mode string can only enter through
+ `parse_auth_spec_kind()`, which returns a typed `CredError`.
+- **Errors as values.** Every seam returns `Result[_, CredError]`; only edge adapters raise.
+- **No v1 imports.** This vocabulary stays free of `MCPServer` and the rest of v1; the
+ v1 -> v2 adapter maps onto these types in a later PR.
+
+Sum types are Expression `@tagged_union`s discriminated on a `Literal` `tag`, matched via
+`self.tag` with an `assert_never` tail; `Result` is this package's vendored `Ok | Error`
+union (see `result.py`), not `expression.Result`.
+"""
+
+from __future__ import annotations
+
+from enum import Enum
+from typing import Annotated, Literal
+
+from expression import case, tag, tagged_union
+from pydantic import BaseModel, ConfigDict, Field, SecretStr
+from typing_extensions import assert_never
+
+from litellm.proxy._experimental.mcp_server.outbound_credentials.result import (
+ Error,
+ Ok,
+ Result,
+)
+
+
+class AuthSpecKind(str, Enum):
+ """The server's statically-declared upstream-auth mode — derived from its `config`.
+
+ Covers v1's full `MCPAuth` surface, not only OAuth grants: the three grant modes, the
+ collapsed static-header family, client passthrough, no-auth, and AWS request signing.
+ BYOK is *not* a member: it is the `api_key` mode seeded per-user, a source selector
+ inside that arm. The static-header schemes v1 splits into separate `MCPAuth` values
+ (`bearer_token`/`api_key`/`basic`/`token`/`authorization`) collapse into `api_key`; the
+ scheme is a parameter the arm carries, not its own mode.
+ """
+
+ authorization_code = "authorization_code" # per-user 3LO; gateway-stored token
+ client_credentials = "client_credentials" # gateway service account (M2M)
+ token_exchange = "token_exchange" # RFC 8693: token endpoint + subject_token (OBO)
+ api_key = "api_key" # static header, any scheme (BYOK = per-user-seeded source)
+ passthrough = "passthrough" # client forwards an upstream-audience token
+ none = "none" # no upstream credential; resolve yields a no-op auth, never an error
+ aws_sigv4 = "aws_sigv4" # AWS SigV4 per-request signing (e.g. Bedrock AgentCore)
+
+
+@tagged_union(frozen=True)
+class CredError:
+ """Why a credential could not be produced. Fail-closed: an arm yields this or an `httpx.Auth`.
+
+ Discriminated on the `Literal` `tag`; consumers `match self.tag` (see `summary`) so the
+ type checker can prove exhaustiveness. Construct via the `of_*` factories.
+ """
+
+ tag: Literal[
+ "unauthorized",
+ "misconfigured",
+ "upstream_unavailable",
+ "unsupported_mode",
+ "precondition_required",
+ "not_implemented",
+ ] = tag()
+
+ unauthorized: str = (
+ case()
+ ) # no usable credential for this (subject, server) -> 401 challenge
+ misconfigured: str = (
+ case()
+ ) # the declared mode is missing required config -> 5xx (operator)
+ upstream_unavailable: str = (
+ case()
+ ) # the IdP / token endpoint could not be reached -> 503
+ unsupported_mode: str = (
+ case()
+ ) # a raw mode string did not parse into AuthSpecKind (boundary)
+ precondition_required: str = (
+ case()
+ ) # a required per-user value (e.g. an env var) has not been provided -> 412
+ not_implemented: str = (
+ case()
+ ) # the declared mode's resolver arm is not built yet -> 501 (not operator error)
+
+ @staticmethod
+ def of_unauthorized(detail: str) -> CredError:
+ return CredError(unauthorized=detail)
+
+ @staticmethod
+ def of_misconfigured(detail: str) -> CredError:
+ return CredError(misconfigured=detail)
+
+ @staticmethod
+ def of_upstream_unavailable(detail: str) -> CredError:
+ return CredError(upstream_unavailable=detail)
+
+ @staticmethod
+ def of_unsupported_mode(detail: str) -> CredError:
+ return CredError(unsupported_mode=detail)
+
+ @staticmethod
+ def of_precondition_required(detail: str) -> CredError:
+ return CredError(precondition_required=detail)
+
+ @staticmethod
+ def of_not_implemented(detail: str) -> CredError:
+ return CredError(not_implemented=detail)
+
+ @property
+ def summary(self) -> str:
+ # Exhaustiveness: every Literal tag has an arm; the trailing assert_never typechecks
+ # only while that stays true (a `case _` would defeat reportMatchNotExhaustive).
+ match self.tag:
+ case "unauthorized":
+ return f"unauthorized: {self.unauthorized}"
+ case "misconfigured":
+ return f"misconfigured: {self.misconfigured}"
+ case "upstream_unavailable":
+ return f"upstream unavailable: {self.upstream_unavailable}"
+ case "unsupported_mode":
+ return self.unsupported_mode
+ case "precondition_required":
+ return f"precondition required: {self.precondition_required}"
+ case "not_implemented":
+ return f"not implemented: {self.not_implemented}"
+ assert_never(self.tag)
+
+
+class AuthorizationCodeConfig(BaseModel):
+ """Per-user 3LO; the gateway is the OAuth client and stores the user's token.
+
+ Endpoints are discovered (RFC 9728 -> RFC 8414) and the client is registered via DCR
+ (RFC 7591), so the common case carries none of the fields below; they are optional manual
+ overrides for IdPs without discovery / DCR. The per-user token is read from the token store
+ at resolve time, not held here.
+ """
+
+ model_config = ConfigDict(frozen=True)
+ kind: Literal[AuthSpecKind.authorization_code] = AuthSpecKind.authorization_code
+ scopes: tuple[str, ...] = ()
+ client_id: str | None = None
+ client_secret: SecretStr | None = None
+ authorization_url: str | None = None
+ token_url: str | None = None
+
+
+class ClientCredentialsConfig(BaseModel):
+ """M2M service account; one upstream identity for every user.
+
+ Fields are optional so the config can be built incomplete: a value may be supplied at
+ runtime (`token_url` via RFC 8414 discovery, `client_id`/`secret` via DCR), and the
+ resolver arm raises `CredError.misconfigured` when a needed field is still absent.
+ """
+
+ model_config = ConfigDict(frozen=True)
+ kind: Literal[AuthSpecKind.client_credentials] = AuthSpecKind.client_credentials
+ client_id: str | None = None
+ client_secret: SecretStr | None = None
+ token_url: str | None = None
+ scopes: tuple[str, ...] = ()
+
+
+class TokenExchangeConfig(BaseModel):
+ """RFC 8693 OBO; swap the caller's live subject_token for a token bound to the upstream's
+ audience (`server.resource`, RFC 8707). The gateway authenticates to the exchange endpoint
+ as an OAuth client (`client_id`/`client_secret`); the inbound token is sent only to that
+ endpoint, never to the upstream.
+ """
+
+ model_config = ConfigDict(frozen=True)
+ kind: Literal[AuthSpecKind.token_exchange] = AuthSpecKind.token_exchange
+ subject_token_type: str = "urn:ietf:params:oauth:token-type:access_token"
+ token_exchange_endpoint: str | None = None
+ client_id: str | None = None
+ client_secret: SecretStr | None = None
+ scopes: tuple[str, ...] = ()
+
+
+class SharedKey(BaseModel):
+ """A fixed key configured on the server, identical for every caller."""
+
+ model_config = ConfigDict(frozen=True)
+ source: Literal["shared"] = "shared"
+ value: SecretStr
+
+
+class Byok(BaseModel):
+ """A key the user brings via the entry flow, stored per-user and pulled from the credential
+ store at resolve time. Missing means the user must provide it, a 401 + WWW-Authenticate
+ challenge."""
+
+ model_config = ConfigDict(frozen=True)
+ source: Literal["byok"] = "byok"
+
+
+ApiKeySource = Annotated[SharedKey | Byok, Field(discriminator="source")]
+
+
+class ApiKeyConfig(BaseModel):
+ """A fixed credential injected as a header. The value is shared (in config) or seeded
+ per-user (pulled from the store); `header_name` and `value_prefix` say where and how it is
+ written, modeled like OpenAPI's apiKey scheme so any upstream convention is expressible
+ (Authorization + Bearer, a raw value on X-API-Key, Ocp-Apim-Subscription-Key, etc.).
+ """
+
+ model_config = ConfigDict(frozen=True)
+ kind: Literal[AuthSpecKind.api_key] = AuthSpecKind.api_key
+ header_name: str = "Authorization"
+ value_prefix: str = "Bearer"
+ key_source: ApiKeySource
+
+ def header(self, value: str) -> tuple[str, str]:
+ formatted = f"{self.value_prefix} {value}" if self.value_prefix else value
+ return self.header_name, formatted
+
+
+class PassthroughConfig(BaseModel):
+ """Client-driven upstream OAuth; the gateway forwards the client's upstream token."""
+
+ model_config = ConfigDict(frozen=True)
+ kind: Literal[AuthSpecKind.passthrough] = AuthSpecKind.passthrough
+
+
+class NoneConfig(BaseModel):
+ """No upstream credential; the request is sent unauthenticated."""
+
+ model_config = ConfigDict(frozen=True)
+ kind: Literal[AuthSpecKind.none] = AuthSpecKind.none
+
+
+class StaticKeys(BaseModel):
+ """Long-lived AWS access keys configured on the server."""
+
+ model_config = ConfigDict(frozen=True)
+ source: Literal["static_keys"] = "static_keys"
+ access_key_id: str
+ secret_access_key: SecretStr
+ session_token: SecretStr | None = None
+
+
+class AssumeRole(BaseModel):
+ """An IAM role the gateway assumes via STS for short-lived, auto-refreshed credentials."""
+
+ model_config = ConfigDict(frozen=True)
+ source: Literal["assume_role"] = "assume_role"
+ role_arn: str
+ session_name: str | None = None
+ external_id: str | None = None
+
+
+class Ambient(BaseModel):
+ """The environment's default AWS credential chain (instance profile, IRSA, env vars)."""
+
+ model_config = ConfigDict(frozen=True)
+ source: Literal["ambient"] = "ambient"
+
+
+AwsCredentialSource = Annotated[
+ StaticKeys | AssumeRole | Ambient, Field(discriminator="source")
+]
+
+
+class AwsSigV4Config(BaseModel):
+ """AWS SigV4 per-request signing for an AWS-hosted upstream (e.g. Bedrock AgentCore). The
+ gateway signs with its own AWS identity, never the caller's; `credentials` selects how that
+ identity is obtained, defaulting to the ambient credential chain."""
+
+ model_config = ConfigDict(frozen=True)
+ kind: Literal[AuthSpecKind.aws_sigv4] = AuthSpecKind.aws_sigv4
+ region: str
+ service: str = "bedrock-agentcore"
+ credentials: AwsCredentialSource = Ambient()
+
+
+AuthConfig = Annotated[
+ AuthorizationCodeConfig
+ | ClientCredentialsConfig
+ | TokenExchangeConfig
+ | ApiKeyConfig
+ | PassthroughConfig
+ | NoneConfig
+ | AwsSigV4Config,
+ Field(discriminator="kind"),
+]
+
+
+class Subject(BaseModel):
+ """The validated inbound principal. NOT the v1 request object and NOT the LiteLLM key."""
+
+ model_config = ConfigDict(frozen=True)
+
+ tenant_id: str
+ subject_id: str
+ # Opaque, already-validated inbound identity. Only `token_exchange` / `passthrough` read it.
+ inbound_token: SecretStr | None = None
+
+
+class ServerSpec(BaseModel):
+ """The declared upstream. A v2-native type; the v1 -> v2 adapter maps onto this."""
+
+ model_config = ConfigDict(frozen=True)
+
+ server_id: str
+ resource: str # RFC 8707 audience URI this upstream's tokens are bound to
+ config: AuthConfig
+
+ @property
+ def auth_spec_kind(self) -> AuthSpecKind:
+ return self.config.kind
+
+
+def parse_auth_spec_kind(raw: str) -> Result[AuthSpecKind, CredError]:
+ """Boundary parser — the *only* place an unknown mode is handled, and it fails closed.
+
+ Inside the core the mode is always a valid `AuthSpecKind`, so the resolver never needs a
+ wildcard arm and basedpyright can prove its `match` exhaustive.
+ """
+ try:
+ return Ok(AuthSpecKind(raw))
+ except ValueError:
+ return Error(CredError.of_unsupported_mode(f"unknown auth_spec_kind: {raw!r}"))
diff --git a/litellm/proxy/_experimental/mcp_server/sampling_handler.py b/litellm/proxy/_experimental/mcp_server/sampling_handler.py
index 1637c9eb0b9..b659ba6f813 100644
--- a/litellm/proxy/_experimental/mcp_server/sampling_handler.py
+++ b/litellm/proxy/_experimental/mcp_server/sampling_handler.py
@@ -661,7 +661,7 @@ def _convert_openai_response_to_mcp_result(
)
-async def _check_model_access( # noqa: PLR0915
+async def _check_model_access(
model: str, user_api_key_auth: Any
) -> Optional["ErrorData"]:
"""Enforce model-permission checks for MCP sampling requests.
diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py
index 0477a5d3244..e891425274f 100644
--- a/litellm/proxy/_experimental/mcp_server/server.py
+++ b/litellm/proxy/_experimental/mcp_server/server.py
@@ -47,6 +47,7 @@ from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
from litellm.proxy._experimental.mcp_server.mcp_context import (
_mcp_active_toolset_id,
_mcp_gateway_initialize_instructions,
+ _mcp_gateway_server_name,
)
from litellm.proxy._experimental.mcp_server.mcp_debug import MCPDebug
from litellm.proxy._experimental.mcp_server.utils import (
@@ -62,7 +63,11 @@ from litellm.llms.custom_httpx.http_handler import (
get_async_httpx_client,
httpxSpecialProvider,
)
-from litellm.proxy._types import UserAPIKeyAuth
+from litellm.proxy._types import (
+ ProxyException,
+ SpecialMCPServerNames,
+ UserAPIKeyAuth,
+)
from litellm.proxy.auth.ip_address_utils import IPAddressUtils
from litellm.proxy.litellm_pre_call_utils import (
LiteLLMProxyRequestSetup,
@@ -228,6 +233,28 @@ def _jsonrpc_text_has_top_level_method(text: str) -> bool:
return False
+def _proxy_exception_to_http_exception(exc: ProxyException) -> HTTPException:
+ """Map a ``ProxyException`` to an ``HTTPException`` that preserves its real
+ status code and headers.
+
+ ``user_api_key_auth`` raises ``ProxyException`` (not ``HTTPException``) on
+ auth failures. The MCP ASGI handlers re-raise ``HTTPException`` to keep the
+ status and any ``WWW-Authenticate`` challenge, but a ``ProxyException`` would
+ otherwise fall through to their generic handler and be flattened to a 500 —
+ dropping the 401 + challenge an OAuth client needs to re-authenticate, so the
+ tool call surfaces as a cancelled/terminated session instead.
+ """
+ try:
+ status_code = int(exc.code)
+ except (TypeError, ValueError):
+ status_code = 500
+ return HTTPException(
+ status_code=status_code,
+ detail=exc.message,
+ headers=exc.headers or None,
+ )
+
+
if MCP_AVAILABLE:
from mcp.server import Server
from mcp.server.lowlevel.server import NotificationOptions
@@ -269,6 +296,9 @@ if MCP_AVAILABLE:
global_mcp_tool_registry,
)
from litellm.proxy._experimental.mcp_server.utils import (
+ MCP_TOOL_PREFIX_SEPARATOR,
+ is_tool_name_prefixed,
+ normalize_server_name,
split_server_prefix_from_name,
)
@@ -323,10 +353,14 @@ if MCP_AVAILABLE:
notification_options=notification_options,
experimental_capabilities=experimental_capabilities or {},
)
+ updates: Dict[str, Any] = {}
merged = _mcp_gateway_initialize_instructions.get()
if merged is not None:
- return opts.model_copy(update={"instructions": merged})
- return opts
+ updates["instructions"] = merged
+ scoped_server_name = _mcp_gateway_server_name.get()
+ if scoped_server_name is not None:
+ updates["server_name"] = scoped_server_name
+ return opts.model_copy(update=updates) if updates else opts
########################################################
############ Initialize the MCP Server #################
@@ -609,7 +643,7 @@ if MCP_AVAILABLE:
active_mcp_session_var.reset(_session_reset_token)
@server.call_tool()
- async def mcp_server_tool_call( # noqa: PLR0915
+ async def mcp_server_tool_call(
name: str, arguments: Dict[str, Any] | None
) -> CallToolResult:
"""
@@ -1028,7 +1062,14 @@ if MCP_AVAILABLE:
allowed_mcp_servers: List[MCPServer],
) -> List[MCPServer]:
"""
- Get the filtered MCP servers from the MCP server names
+ Get the filtered MCP servers from the MCP server names.
+
+ Fails closed when ``mcp_servers`` is explicitly provided (path- or
+ header-derived) but none of the names resolve to a server alias or
+ access group the caller can access. The previous behavior returned
+ the full ``allowed_mcp_servers`` set, which silently widened scope
+ when a client targeted ``/mcp//`` and made URL/header
+ namespacing appear to work when it did not.
"""
filtered_server: dict[str, MCPServer] = {}
@@ -1068,6 +1109,17 @@ if MCP_AVAILABLE:
if filtered_server:
return list(filtered_server.values())
+ if mcp_servers is not None:
+ # Caller asked for a specific scope but nothing resolved. Fail
+ # closed so URL/header namespacing cannot silently fall back to
+ # the caller's full allowed-server set.
+ verbose_logger.debug(
+ "MCP scope filter resolved to no servers for requested names %s; "
+ "returning empty list (fail-closed).",
+ mcp_servers,
+ )
+ return []
+
return allowed_mcp_servers
def _tool_name_matches(tool_name: str, filter_list: List[str]) -> bool:
@@ -1544,6 +1596,7 @@ if MCP_AVAILABLE:
user_api_key_auth: Optional[UserAPIKeyAuth],
mcp_servers: Optional[List[str]],
client_ip: Optional[str],
+ scoped_server_endpoint: bool = False,
) -> AsyncIterator[None]:
allowed = await _get_allowed_mcp_servers(
user_api_key_auth=user_api_key_auth,
@@ -1565,13 +1618,24 @@ if MCP_AVAILABLE:
return_exceptions=True,
)
merged = _merge_gateway_initialize_instructions(allowed_mcp_servers=allowed)
- tok = _mcp_gateway_initialize_instructions.set(merged)
+ scoped_server_name = None
+ if scoped_server_endpoint and len(allowed) == 1:
+ scoped_server = allowed[0]
+ scoped_server_name = (
+ scoped_server.alias
+ or scoped_server.server_name
+ or scoped_server.name
+ or scoped_server.server_id
+ )
+ instructions_token = _mcp_gateway_initialize_instructions.set(merged)
+ server_name_token = _mcp_gateway_server_name.set(scoped_server_name)
try:
yield
finally:
- _mcp_gateway_initialize_instructions.reset(tok)
+ _mcp_gateway_initialize_instructions.reset(instructions_token)
+ _mcp_gateway_server_name.reset(server_name_token)
- async def _get_tools_from_mcp_servers( # noqa: PLR0915
+ async def _get_tools_from_mcp_servers(
user_api_key_auth: Optional[UserAPIKeyAuth],
mcp_auth_header: Optional[str],
mcp_servers: Optional[List[str]],
@@ -2415,7 +2479,7 @@ if MCP_AVAILABLE:
},
)
- async def execute_mcp_tool( # noqa: PLR0915
+ async def execute_mcp_tool(
name: str,
arguments: Dict[str, Any],
allowed_mcp_servers: List[MCPServer],
@@ -2466,47 +2530,60 @@ if MCP_AVAILABLE:
None,
)
- # Resolve the actual MCP server up-front so the permission check uses
- # the canonical server.name even when the tool name is prefixed with a
- # short ID (LITELLM_USE_SHORT_MCP_TOOL_PREFIX) that doesn't match the
- # server's display name directly.
- mcp_server = global_mcp_server_manager._get_mcp_server_from_tool_name(name)
- if mcp_server is None and requested_server is not None:
- # REST callers may pass the raw tool name (no prefix) plus a
- # ``requested_server_id``. The mapping might only contain the
- # prefixed form, so retry the lookup with every known prefix of
- # the requested server before treating the tool as unresolved —
- # otherwise the tool_server_mismatch guard below is silently
- # bypassed.
- for known_prefix in iter_known_server_prefixes(requested_server):
- candidate = global_mcp_server_manager._get_mcp_server_from_tool_name(
- add_server_prefix_to_name(name, known_prefix)
- )
- if candidate is not None:
- mcp_server = candidate
- break
- if mcp_server is not None:
- server_name = mcp_server.name
+ name_is_prefixed = False
+ if requested_server is not None and MCP_TOOL_PREFIX_SEPARATOR in name:
+ all_registry_prefixes: Set[str] = set()
+ for registry_server in global_mcp_server_manager.get_registry().values():
+ for known_prefix in iter_known_server_prefixes(registry_server):
+ all_registry_prefixes.add(normalize_server_name(known_prefix))
+ name_is_prefixed = is_tool_name_prefixed(
+ name, known_server_prefixes=all_registry_prefixes
+ )
- # REST /mcp-rest/tools/call passes server_id — tool must belong to that server
- if requested_server is not None:
- if (
- mcp_server is not None
- and mcp_server.server_id != requested_server.server_id
- ):
- raise HTTPException(
- status_code=403,
- detail={
- "error": "tool_server_mismatch",
- "message": (
- f"Tool '{name}' belongs to MCP server '{mcp_server.name}' "
- f"but request specified server_id for '{requested_server.name}'."
- ),
- },
- )
- if mcp_server is None:
- mcp_server = requested_server
- server_name = requested_server.name
+ if requested_server is not None and not name_is_prefixed:
+ # REST callers may pass server_id with the upstream tool name (no
+ # LiteLLM prefix). The first segment is not a registered server
+ # prefix, so the whole string is the upstream tool name and may
+ # legitimately contain the separator (e.g. "text-to-speech").
+ # server_id is authoritative for routing and auth.
+ mcp_server = requested_server
+ server_name = requested_server.name
+ original_tool_name = name
+ else:
+ # Resolve from tool name (MCP JSON-RPC or prefixed REST tool names).
+ mcp_server = global_mcp_server_manager._get_mcp_server_from_tool_name(name)
+ if mcp_server is None and requested_server is not None:
+ for known_prefix in iter_known_server_prefixes(requested_server):
+ candidate = (
+ global_mcp_server_manager._get_mcp_server_from_tool_name(
+ add_server_prefix_to_name(name, known_prefix)
+ )
+ )
+ if candidate is not None:
+ mcp_server = candidate
+ break
+ if mcp_server is not None:
+ server_name = mcp_server.name
+
+ if requested_server is not None:
+ if (
+ mcp_server is not None
+ and mcp_server.server_id != requested_server.server_id
+ ):
+ raise HTTPException(
+ status_code=403,
+ detail={
+ "error": "tool_server_mismatch",
+ "message": (
+ f"Tool '{name}' belongs to MCP server "
+ f"'{mcp_server.name}' but request specified "
+ f"server_id for '{requested_server.name}'."
+ ),
+ },
+ )
+ if mcp_server is None:
+ mcp_server = requested_server
+ server_name = requested_server.name
# Only enforce server-level permissions when we can resolve a server
if server_name:
@@ -2535,6 +2612,7 @@ if MCP_AVAILABLE:
standard_logging_mcp_tool_call
)
litellm_logging_obj.model = f"MCP: {name}"
+ litellm_logging_obj.model_call_details["model"] = f"MCP: {name}"
# Resolve the MCP server early so BYOK checks and credential injection
# apply to ALL dispatch paths (local tool registry AND managed MCP server).
if mcp_server is None:
@@ -3300,6 +3378,19 @@ if MCP_AVAILABLE:
from litellm.proxy._types import LiteLLM_ObjectPermissionTable
from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view
+ # A key scoped to no MCP servers opts out of every MCP path. Enforce it
+ # here too, since toolset scoping replaces mcp_servers and would otherwise
+ # drop the sentinel. Checked before the admin branch, mirroring
+ # get_allowed_mcp_servers.
+ original_op = user_api_key_auth.object_permission
+ if original_op is not None and SpecialMCPServerNames.no_mcp_servers.value in (
+ original_op.mcp_servers or []
+ ):
+ raise HTTPException(
+ status_code=403,
+ detail="API key is scoped to no MCP servers; toolset access is denied.",
+ )
+
# Access control: non-admin keys must have this toolset in their grant list.
# Use _user_has_admin_view so that PROXY_ADMIN_VIEW_ONLY is also treated as admin.
is_admin = _user_has_admin_view(user_api_key_auth)
@@ -3409,6 +3500,8 @@ if MCP_AVAILABLE:
)
if stored_oauth_headers:
continue
+ if getattr(server, "delegate_auth_to_upstream", False) is True:
+ continue
request = StarletteRequest(scope)
base_url = get_request_base_url(request)
@@ -3606,7 +3699,7 @@ if MCP_AVAILABLE:
detail="Forbidden",
)
- async def handle_streamable_http_mcp( # noqa: PLR0915
+ async def handle_streamable_http_mcp(
scope: Scope, receive: Receive, send: Send
) -> None:
"""Handle MCP requests through StreamableHTTP."""
@@ -3620,6 +3713,7 @@ if MCP_AVAILABLE:
oauth2_headers,
raw_headers,
) = await extract_mcp_auth_context(scope, path)
+ scoped_server_endpoint = len(_get_mcp_servers_in_path(path) or []) == 1
# Extract client IP for MCP access control
_client_ip = IPAddressUtils.get_mcp_client_ip(StarletteRequest(scope))
@@ -3896,6 +3990,7 @@ if MCP_AVAILABLE:
user_api_key_auth,
mcp_servers,
_client_ip,
+ scoped_server_endpoint=scoped_server_endpoint,
):
await target_manager.handle_request(scope, receive, local_send)
if use_stateful and session_id and scope.get("method") == "DELETE":
@@ -3941,7 +4036,7 @@ if MCP_AVAILABLE:
):
_stateful_session_locks.pop(active_request_session_id, None)
except MCPUpstreamAuthError as e:
- # Pass-through server returned 401 — surface it to the client so
+ # Upstream delegated auth returned 401; surface it to the client so
# standards-compliant MCP clients trigger the upstream OAuth flow.
raise e.to_http_exception(
base_url=get_request_base_url(StarletteRequest(scope)),
@@ -3950,6 +4045,12 @@ if MCP_AVAILABLE:
except HTTPException:
# Re-raise HTTP exceptions to preserve status codes and details
raise
+ except ProxyException as e:
+ # Auth failures from user_api_key_auth arrive as ProxyException, not
+ # HTTPException. Preserve the real status (e.g. 401 + WWW-Authenticate)
+ # so OAuth clients can re-authenticate instead of receiving a generic
+ # 500 that surfaces as a cancelled tool call.
+ raise _proxy_exception_to_http_exception(e)
except Exception as e:
verbose_logger.exception(f"Error handling MCP request: {e}")
# Try to send a graceful error response for non-HTTP exceptions
@@ -3980,6 +4081,7 @@ if MCP_AVAILABLE:
oauth2_headers,
raw_headers,
) = await extract_mcp_auth_context(scope, path)
+ scoped_server_endpoint = len(_get_mcp_servers_in_path(path) or []) == 1
# Extract client IP for MCP access control
_sse_client_ip = IPAddressUtils.get_mcp_client_ip(StarletteRequest(scope))
@@ -4052,10 +4154,11 @@ if MCP_AVAILABLE:
user_api_key_auth,
mcp_servers,
_sse_client_ip,
+ scoped_server_endpoint=scoped_server_endpoint,
):
await sse_session_manager.handle_request(scope, receive, send)
except MCPUpstreamAuthError as e:
- # Pass-through server returned 401 — surface it to the client so
+ # Upstream delegated auth returned 401; surface it to the client so
# standards-compliant MCP clients trigger the upstream OAuth flow.
raise e.to_http_exception(
base_url=get_request_base_url(StarletteRequest(scope)),
@@ -4065,6 +4168,12 @@ if MCP_AVAILABLE:
# Re-raise HTTP exceptions to preserve status codes and details
# (e.g. 401 + WWW-Authenticate challenges from OAuth pass-through).
raise
+ except ProxyException as e:
+ # Auth failures from user_api_key_auth arrive as ProxyException, not
+ # HTTPException. Preserve the real status (e.g. 401 + WWW-Authenticate)
+ # so OAuth clients can re-authenticate instead of receiving a generic
+ # 500 that surfaces as a cancelled tool call.
+ raise _proxy_exception_to_http_exception(e)
except Exception as e:
verbose_logger.exception(f"Error handling MCP request: {e}")
# Try to send a graceful error response for non-HTTP exceptions
diff --git a/litellm/proxy/_experimental/mcp_server/utils.py b/litellm/proxy/_experimental/mcp_server/utils.py
index 97cfa74ea45..b0141d3207c 100644
--- a/litellm/proxy/_experimental/mcp_server/utils.py
+++ b/litellm/proxy/_experimental/mcp_server/utils.py
@@ -23,9 +23,20 @@ import os
from urllib.parse import quote
# Constants
-LITELLM_MCP_SERVER_NAME = "litellm-mcp-server"
+#
+# NOTE: The environment-backed values below are read once, when this module is
+# first imported, and cached for the lifetime of the process. Changing the
+# corresponding environment variables after import has no effect unless the
+# module is reloaded (e.g. ``importlib.reload``). Tests that override these
+# variables must reload this module — see
+# ``tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_identity_env.py``.
+LITELLM_MCP_SERVER_NAME = os.environ.get(
+ "LITELLM_MCP_SERVER_NAME", "litellm-mcp-server"
+)
LITELLM_MCP_SERVER_VERSION = "1.0.0"
-LITELLM_MCP_SERVER_DESCRIPTION = "MCP Server for LiteLLM"
+LITELLM_MCP_SERVER_DESCRIPTION = os.environ.get(
+ "LITELLM_MCP_SERVER_DESCRIPTION", "MCP Server for LiteLLM"
+)
MCP_TOOL_PREFIX_SEPARATOR = os.environ.get("MCP_TOOL_PREFIX_SEPARATOR", "-")
MCP_TOOL_PREFIX_FORMAT = "{server_name}{separator}{tool_name}"
diff --git a/litellm/proxy/_experimental/out/404.html b/litellm/proxy/_experimental/out/404.html
index 45de348c4d5..6d246c81652 100644
--- a/litellm/proxy/_experimental/out/404.html
+++ b/litellm/proxy/_experimental/out/404.html
@@ -1 +1 @@
-404: This page could not be found.LiteLLM Dashboard
404
This page could not be found.
\ No newline at end of file
+404: This page could not be found.LiteLLM Dashboard
404
This page could not be found.
\ No newline at end of file
diff --git a/litellm/proxy/_experimental/out/404/index.html b/litellm/proxy/_experimental/out/404/index.html
index 45de348c4d5..6d246c81652 100644
--- a/litellm/proxy/_experimental/out/404/index.html
+++ b/litellm/proxy/_experimental/out/404/index.html
@@ -1 +1 @@
-404: This page could not be found.LiteLLM Dashboard
404
This page could not be found.
\ No newline at end of file
+404: This page could not be found.LiteLLM Dashboard