diff --git a/.circleci/config.yml b/.circleci/config.yml
index 32983c92e27..cf69ff68da6 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -158,6 +158,8 @@ jobs:
CHOCOLATEY_CONFIRM_ALL: "true"
- run:
name: Install Dependencies
+ environment:
+ UV_HTTP_TIMEOUT: "300"
command: |
$installer = Join-Path $env:TEMP "uv-install.ps1"
Invoke-WebRequest -Uri https://astral.sh/uv/0.10.9/install.ps1 -OutFile $installer
@@ -2475,10 +2477,15 @@ jobs:
DISABLE_SCHEMA_UPDATE: "true"
SERVER_ROOT_PATH: ""
PROXY_LOGOUT_URL: ""
+ # LITELLM_LICENSE is forwarded from the project env so premium-gated
+ # UI flows can be exercised. license.spec.ts asserts the resulting
+ # JWT carries premium_user=true; if it ever stops being passed, that
+ # test fails loudly rather than silently regressing premium coverage.
command: |
- uv run --no-sync python -m litellm.proxy.proxy_cli \
- --config ui/litellm-dashboard/e2e_tests/fixtures/config.yml \
- --port 4000
+ LITELLM_LICENSE="$LITELLM_LICENSE" \
+ uv run --no-sync python -m litellm.proxy.proxy_cli \
+ --config ui/litellm-dashboard/e2e_tests/fixtures/config.yml \
+ --port 4000
background: true
- run:
name: Wait for proxy to be ready
@@ -2495,9 +2502,12 @@ jobs:
exit 1
- run:
name: Run Playwright E2E tests
+ # Forward LITELLM_LICENSE so license.spec.ts can detect that the
+ # proxy was launched with a license and assert premium_user=true.
command: |
cd ui/litellm-dashboard
- npx playwright test --config e2e_tests/playwright.config.ts
+ LITELLM_LICENSE="$LITELLM_LICENSE" \
+ npx playwright test --config e2e_tests/playwright.config.ts
no_output_timeout: 10m
- store_artifacts:
path: ui/litellm-dashboard/test-results
@@ -2531,7 +2541,6 @@ jobs:
paths:
- litellm-docker-database.tar.zst
-
test_bad_database_url:
machine:
image: ubuntu-2204:2024.04.1
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index e86fca17c7a..babe3b62933 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -53,3 +53,31 @@ jobs:
uses: github/codeql-action/analyze@ebcb5b36ded6beda4ceefea6a8bc4cc885255bb3 # v3
with:
category: "/language:${{ matrix.language }}"
+ output: sarif-results
+ upload: failure-only
+
+ # py/weak-sensitive-data-hashing (CWE-328) fires on the OCI signing call at
+ # litellm/llms/oci/common_utils.py, which hashes the HTTP request body to
+ # produce the x-content-sha256 header required by the OCI HTTP signing spec —
+ # a content-integrity hash, not a password or secret hash. SHA-256 is mandated
+ # by Oracle for this header; see
+ # https://docs.oracle.com/en-us/iaas/Content/API/Concepts/signingrequests.htm
+ # The `usedforsecurity=False` flag on the hashlib.sha256 call already declares
+ # non-security intent, but CodeQL's taint flow still re-fires when callers
+ # further up the stack are modified. The suppression is scoped to this one
+ # file/rule pair via SARIF post-filtering so every other callsite of
+ # py/weak-sensitive-data-hashing in the repository continues to be analyzed.
+ - name: Filter SARIF (OCI sha256)
+ if: matrix.language == 'python'
+ uses: advanced-security/filter-sarif@2da736ff05ef065cb2894ac6892e47b5eac2c3c0 # v1.1
+ with:
+ patterns: |
+ -litellm/llms/oci/common_utils.py:py/weak-sensitive-data-hashing
+ input: sarif-results/python.sarif
+ output: sarif-results/python.sarif
+
+ - name: Upload SARIF
+ uses: github/codeql-action/upload-sarif@ebcb5b36ded6beda4ceefea6a8bc4cc885255bb3 # v3
+ with:
+ sarif_file: sarif-results
+ category: "/language:${{ matrix.language }}"
diff --git a/.github/workflows/test-unit-proxy-db.yml b/.github/workflows/test-unit-proxy-db.yml
index 8ee3a1ed0cd..2d4e85630dc 100644
--- a/.github/workflows/test-unit-proxy-db.yml
+++ b/.github/workflows/test-unit-proxy-db.yml
@@ -215,8 +215,10 @@ jobs:
tests/proxy_unit_tests/test_models_fallback_endpoint.py
tests/proxy_unit_tests/test_google_endpoint_routing.py
tests/proxy_unit_tests/test_google_gemini_proxy_request.py
+ tests/proxy_unit_tests/test_gemini_agents_endpoints.py
tests/proxy_unit_tests/test_get_favicon.py
tests/proxy_unit_tests/test_get_image.py
+ tests/proxy_unit_tests/test_reducto_ocr_route.py
tests/proxy_unit_tests/test_ui_path_detection.py
tests/proxy_unit_tests/test_prompt_test_endpoint.py
tests/proxy_unit_tests/test_check_batch_cost.py
diff --git a/.github/workflows/test-unit-proxy-mgmt-behavior.yml b/.github/workflows/test-unit-proxy-mgmt-behavior.yml
new file mode 100644
index 00000000000..e73997323a4
--- /dev/null
+++ b/.github/workflows/test-unit-proxy-mgmt-behavior.yml
@@ -0,0 +1,34 @@
+name: "Unit Tests: Proxy Management-Endpoint Behavior Pinning"
+
+on:
+ pull_request:
+ branches:
+ - main
+ - litellm_internal_staging
+ - litellm_oss_branch
+ - "litellm_**"
+
+permissions:
+ contents: read
+ id-token: write
+ pull-requests: write
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ proxy-mgmt-behavior:
+ uses: ./.github/workflows/_test-unit-services-base.yml
+ with:
+ test-path: tests/proxy_behavior
+ # workers=0 (no xdist): the world seed is a single shared Postgres
+ # state — two xdist workers both call seed_world() and race on the
+ # ``behavior-pin-budget`` row, producing UniqueViolation + cascading
+ # missing-membership FK failures. The whole suite is ~7s sequentially,
+ # so the cost of disabling parallelism here is negligible.
+ workers: 0
+ reruns: 0
+ enable-postgres: true
+ artifact-name: proxy-mgmt-behavior
+ timeout-minutes: 15
diff --git a/README.md b/README.md
index 72fd43925c9..8df351e9303 100644
--- a/README.md
+++ b/README.md
@@ -292,7 +292,7 @@ curl -X POST 'http://0.0.0.0:4000/v1/chat/completions' \
| [CompactifAI (`compactifai`)](https://docs.litellm.ai/docs/providers/compactifai) | ✅ | ✅ | ✅ | | | | | | | |
| [Custom (`custom`)](https://docs.litellm.ai/docs/providers/custom_llm_server) | ✅ | ✅ | ✅ | | | | | | | |
| [Custom OpenAI (`custom_openai`)](https://docs.litellm.ai/docs/providers/openai_compatible) | ✅ | ✅ | ✅ | | | ✅ | ✅ | ✅ | ✅ | |
-| [Dashscope (`dashscope`)](https://docs.litellm.ai/docs/providers/dashscope) | ✅ | ✅ | ✅ | | | | | | | |
+| [Dashscope (`dashscope`)](https://docs.litellm.ai/docs/providers/dashscope) | ✅ | ✅ | ✅ | ✅ | | | | | | ✅ |
| [Databricks (`databricks`)](https://docs.litellm.ai/docs/providers/databricks) | ✅ | ✅ | ✅ | | | | | | | |
| [DataRobot (`datarobot`)](https://docs.litellm.ai/docs/providers/datarobot) | ✅ | ✅ | ✅ | | | | | | | |
| [Deepgram (`deepgram`)](https://docs.litellm.ai/docs/providers/deepgram) | ✅ | ✅ | ✅ | | | ✅ | | | | |
diff --git a/deploy/charts/litellm-helm/Chart.yaml b/deploy/charts/litellm-helm/Chart.yaml
index 0f6db331e50..0aef2442bfe 100644
--- a/deploy/charts/litellm-helm/Chart.yaml
+++ b/deploy/charts/litellm-helm/Chart.yaml
@@ -24,7 +24,7 @@ version: 1.1.0
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
-appVersion: v1.80.12
+appVersion: v1.85.1
annotations:
org.opencontainers.image.source: "https://github.com/BerriAI/litellm"
diff --git a/deploy/charts/litellm-helm/templates/deployment.yaml b/deploy/charts/litellm-helm/templates/deployment.yaml
index 25f69080878..aefe2a564bb 100644
--- a/deploy/charts/litellm-helm/templates/deployment.yaml
+++ b/deploy/charts/litellm-helm/templates/deployment.yaml
@@ -53,7 +53,7 @@ spec:
- name: {{ include "litellm.name" . }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
- image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default (printf "main-%s" .Chart.AppVersion) }}"
+ image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
env:
- name: HOST
diff --git a/deploy/charts/litellm-helm/templates/hpa.yaml b/deploy/charts/litellm-helm/templates/hpa.yaml
index 71e199c5aeb..fec4d1f5c5e 100644
--- a/deploy/charts/litellm-helm/templates/hpa.yaml
+++ b/deploy/charts/litellm-helm/templates/hpa.yaml
@@ -12,6 +12,10 @@ spec:
name: {{ include "litellm.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
+ {{- if .Values.autoscaling.behavior }}
+ behavior:
+ {{- toYaml .Values.autoscaling.behavior | nindent 4 }}
+ {{- end }}
metrics:
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
- type: Resource
diff --git a/deploy/charts/litellm-helm/templates/migrations-job.yaml b/deploy/charts/litellm-helm/templates/migrations-job.yaml
index c3f32fe32f3..5ec7f5b7f3e 100644
--- a/deploy/charts/litellm-helm/templates/migrations-job.yaml
+++ b/deploy/charts/litellm-helm/templates/migrations-job.yaml
@@ -41,7 +41,7 @@ spec:
{{- end }}
containers:
- name: prisma-migrations
- image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default (printf "main-%s" .Chart.AppVersion) }}"
+ image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
diff --git a/deploy/charts/litellm-helm/tests/hpa_tests.yaml b/deploy/charts/litellm-helm/tests/hpa_tests.yaml
new file mode 100644
index 00000000000..ec18c3591d3
--- /dev/null
+++ b/deploy/charts/litellm-helm/tests/hpa_tests.yaml
@@ -0,0 +1,36 @@
+suite: "hpa with behavior"
+templates:
+ - hpa.yaml
+tests:
+ - it: "renders behavior when set"
+ set:
+ autoscaling.enabled: true
+ autoscaling.behavior:
+ scaleUp:
+ stabilizationWindowSeconds: 60
+ policies:
+ - type: Pods
+ value: 2
+ periodSeconds: 60
+ scaleDown:
+ stabilizationWindowSeconds: 90
+ policies:
+ - type: Pods
+ value: 1
+ periodSeconds: 60
+ asserts:
+ - isKind: { of: HorizontalPodAutoscaler }
+ - equal: { path: spec.behavior.scaleUp.stabilizationWindowSeconds, value: 60 }
+ - equal: { path: spec.behavior.scaleDown.stabilizationWindowSeconds, value: 90 }
+
+---
+suite: "hpa without behavior"
+templates:
+ - hpa.yaml
+tests:
+ - it: "does not render behavior when not set"
+ set:
+ autoscaling.enabled: true
+ asserts:
+ - isKind: { of: HorizontalPodAutoscaler }
+ - isNull: { path: spec.behavior }
diff --git a/deploy/charts/litellm-helm/values.yaml b/deploy/charts/litellm-helm/values.yaml
index 9c7c013341b..a9cdf28f0e7 100644
--- a/deploy/charts/litellm-helm/values.yaml
+++ b/deploy/charts/litellm-helm/values.yaml
@@ -10,7 +10,7 @@ image:
repository: ghcr.io/berriai/litellm-database
pullPolicy: Always
# Overrides the image tag whose default is the chart appVersion.
- # tag: "main-latest"
+ # tag: "latest"
tag: ""
imagePullSecrets: []
@@ -184,6 +184,7 @@ autoscaling:
maxReplicas: 100
targetCPUUtilizationPercentage: 80
# targetMemoryUtilizationPercentage: 80
+ # behavior: {}
# Autoscaling with keda is mutually exclusive with hpa
keda:
diff --git a/docker/Dockerfile.non_root b/docker/Dockerfile.non_root
index 0b9d1b9d318..8717e5b3fcd 100644
--- a/docker/Dockerfile.non_root
+++ b/docker/Dockerfile.non_root
@@ -24,7 +24,8 @@ RUN for i in 1 2 3; do \
curl \
openssl \
libsndfile \
- nodejs && break || sleep 5; \
+ nodejs \
+ npm && break || sleep 5; \
done
ENV UV_PROJECT_ENVIRONMENT=/app/.venv \
diff --git a/license_cache.json b/license_cache.json
index 803db8fdd1b..dc061b48f4f 100644
--- a/license_cache.json
+++ b/license_cache.json
@@ -50,6 +50,7 @@
"h11:0.16.0": "MIT",
"requests-toolbelt:1.0.0": "Apache 2.0",
"tornado:6.5.4": "Apache-2.0",
+ "granian:2.5.7": "BSD-3-Clause",
"mlflow:3.11.1": "Copyright 2018 Databricks, Inc. All rights reserved.\n \n \t\t\t\tApache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n \n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n \n 1. Definitions.\n \n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n \n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n \n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n \n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n \n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n \n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n \n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n \n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n \n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n \n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n \n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n \n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n \n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n \n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n \n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n \n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n \n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n \n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n \n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n \n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n \n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n \n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n \n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n \n END OF TERMS AND CONDITIONS\n APPENDIX: How to apply the Apache License to your work.\n \n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n \n Copyright [yyyy] [name of copyright owner]\n \n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n \n http://www.apache.org/licenses/LICENSE-2.0\n \n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n ",
"nvidia-riva-client:2.15.0": "MIT",
"numpy:1.26.0": "Copyright (c) 2005-2023, NumPy Developers. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the NumPy Developers nor the names of any contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ---- This binary distribution of NumPy also bundles the following software: Name: GCC runtime library Files: .dylibs/* Description: dynamically linked to files compiled with gcc Availability: https://gcc.gnu.org/viewcvs/gcc/ License: GPLv3 + runtime exception Copyright (C) 2002-2017 Free Software Foundation, Inc. Libgfortran is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. Libgfortran is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. Under Section 7 of GPL version 3, you are granted additional permissions described in the GCC Runtime Library Exception, version 3.1, as published by the Free Software Foundation. You should have received a copy of the GNU General Public License and a copy of the GCC Runtime Library Exception along with this program; see the files COPYING3 and COPYING.RUNTIME respectively. If not, see . ---- Full text of license texts referred to above follows (that they are listed below does not necessarily imply the conditions apply to the present binary release): ---- GCC RUNTIME LIBRARY EXCEPTION Version 3.1, 31 March 2009 Copyright (C) 2009 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. This GCC Runtime Library Exception (\"Exception\") is an additional permission under section 7 of the GNU General Public License, version 3 (\"GPLv3\"). It applies to a given file (the \"Runtime Library\") that bears a notice placed by the copyright holder of the file stating that the file is governed by GPLv3 along with this Exception. When you use GCC to compile a program, GCC may combine portions of certain GCC header files and runtime libraries with the compiled program. The purpose of this Exception is to allow compilation of non-GPL (including proprietary) programs to use, in this way, the header files and runtime libraries covered by this Exception. 0. Definitions. A file is an \"Independent Module\" if it either requires the Runtime Library for execution after a Compilation Process, or makes use of an interface provided by the Runtime Library, but is not otherwise based on the Runtime Library. \"GCC\" means a version of the GNU Compiler Collection, with or without modifications, governed by version 3 (or a specified later version) of the GNU General Public License (GPL) with the option of using any subsequent versions published by the FSF. \"GPL-compatible Software\" is software whose conditions of propagation, modification and use would permit combination with GCC in accord with the license of GCC. \"Target Code\" refers to output from any compiler for a real or virtual target processor architecture, in executable form or suitable for input to an assembler, loader, linker and/or execution phase. Notwithstanding that, Target Code does not include data in any format that is used as a compiler intermediate representation, or used for producing a compiler intermediate representation. The \"Compilation Process\" transforms code entirely represented in non-intermediate languages designed for human-written code, and/or in Java Virtual Machine byte code, into Target Code. Thus, for example, use of source code generators and preprocessors need not be considered part of the Compilation Process, since the Compilation Process can be understood as starting with the output of the generators or preprocessors. A Compilation Process is \"Eligible\" if it is done using GCC, alone or with other GPL-compatible software, or if it is done without using any work based on GCC. For example, using non-GPL-compatible Software to optimize any GCC intermediate representations would not qualify as an Eligible Compilation Process. 1. Grant of Additional Permission. You have permission to propagate a work of Target Code formed by combining the Runtime Library with Independent Modules, even if such propagation would otherwise violate the terms of GPLv3, provided that all Target Code was generated by Eligible Compilation Processes. You may then convey such a combination under terms of your choice, consistent with the licensing of the Independent Modules. 2. No Weakening of GCC Copyleft. The availability of this Exception does not imply any general presumption that third-party software is unaffected by the copyleft requirements of the license of GCC. ---- GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. \"This License\" refers to version 3 of the GNU General Public License. \"Copyright\" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. \"The Program\" refers to any copyrightable work licensed under this License. Each licensee is addressed as \"you\". \"Licensees\" and \"recipients\" may be individuals or organizations. To \"modify\" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a \"modified version\" of the earlier work or a work \"based on\" the earlier work. A \"covered work\" means either the unmodified Program or a work based on the Program. To \"propagate\" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To \"convey\" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays \"Appropriate Legal Notices\" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The \"source code\" for a work means the preferred form of the work for making modifications to it. \"Object code\" means any non-source form of a work. A \"Standard Interface\" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The \"System Libraries\" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A \"Major Component\", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The \"Corresponding Source\" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to \"keep intact all notices\". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an \"aggregate\" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A \"User Product\" is either (1) a \"consumer product\", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, \"normally used\" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. \"Installation Information\" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. \"Additional permissions\" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered \"further restrictions\" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An \"entity transaction\" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A \"contributor\" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's \"contributor version\". A contributor's \"essential patent claims\" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, \"control\" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a \"patent license\" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To \"grant\" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. \"Knowingly relying\" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is \"discriminatory\" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License \"or any later version\" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the \"copyright\" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an \"about box\". You should also get your employer (if you work as a programmer) or school, if any, to sign a \"copyright disclaimer\" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read .",
diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260514120000_add_blocked_to_proxy_model_table/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260514120000_add_blocked_to_proxy_model_table/migration.sql
new file mode 100644
index 00000000000..3253b63a884
--- /dev/null
+++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260514120000_add_blocked_to_proxy_model_table/migration.sql
@@ -0,0 +1,4 @@
+-- AlterTable
+-- Adds the admin-toggleable pause flag used by the router's blocked filter and the
+-- credential lookup helpers; defaults to false so existing rows behave unchanged.
+ALTER TABLE "LiteLLM_ProxyModelTable" ADD COLUMN IF NOT EXISTS "blocked" BOOLEAN NOT NULL DEFAULT false;
diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
index b53507abe6a..78143fe0411 100644
--- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
+++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
@@ -48,9 +48,10 @@ model LiteLLM_CredentialsTable {
// Models on proxy
model LiteLLM_ProxyModelTable {
model_id String @id @default(uuid())
- model_name String
+ model_name String
litellm_params Json
- model_info Json?
+ model_info Json?
+ blocked Boolean @default(false)
created_at DateTime @default(now()) @map("created_at")
created_by String
updated_at DateTime @default(now()) @updatedAt @map("updated_at")
diff --git a/litellm-proxy-extras/pyproject.toml b/litellm-proxy-extras/pyproject.toml
index ee27cc3585e..0654f17ec68 100644
--- a/litellm-proxy-extras/pyproject.toml
+++ b/litellm-proxy-extras/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "litellm-proxy-extras"
-version = "0.4.72"
+version = "0.4.73"
description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package."
readme = "README.md"
requires-python = ">=3.9"
@@ -26,7 +26,7 @@ required-version = ">=0.10.9"
module-root = ""
[tool.commitizen]
-version = "0.4.72"
+version = "0.4.73"
version_files = [
"pyproject.toml:^version",
"../pyproject.toml:litellm-proxy-extras==",
diff --git a/litellm/__init__.py b/litellm/__init__.py
index e1b367fb234..7c92623358d 100644
--- a/litellm/__init__.py
+++ b/litellm/__init__.py
@@ -225,6 +225,10 @@ use_chat_completions_url_for_anthropic_messages: bool = bool(
route_all_chat_openai_to_responses: bool = (
os.getenv("LITELLM_ROUTE_ALL_CHAT_OPENAI_TO_RESPONSES", "false").lower() == "true"
) # When True, routes all OpenAI /chat/completions requests through the Responses API bridge
+use_legacy_interactions_schema: bool = (
+ os.getenv("LITELLM_USE_LEGACY_INTERACTIONS_SCHEMA", "false").lower() == "true"
+) # When True, sends Api-Revision: 2026-05-07 to Google so responses use the legacy `outputs`
+# schema instead of the new `steps` schema. Remove this flag after June 8, 2026.
retry = True
### AUTH ###
api_key: Optional[str] = None
@@ -409,6 +413,12 @@ internal_user_budget_duration: Optional[str] = None
tag_budget_config: Optional[Dict[str, "BudgetConfig"]] = None
max_end_user_budget: Optional[float] = None
max_end_user_budget_id: Optional[str] = None
+# When True, end-user IDs extracted from requests are validated against
+# LiteLLM_EndUserTable / LiteLLM_UserTable. Values that do not resolve to a
+# known row are dropped before reaching spend logs. Defaults to False for
+# backwards compatibility — arbitrary client-supplied identifiers still
+# pass through unchanged.
+validate_end_user_id_in_db: bool = False
disable_end_user_cost_tracking: Optional[bool] = None
disable_end_user_cost_tracking_prometheus_only: Optional[bool] = None
enable_end_user_cost_tracking_prometheus_only: Optional[bool] = None
@@ -416,6 +426,7 @@ custom_prometheus_metadata_labels: List[str] = []
custom_prometheus_tags: List[str] = []
prometheus_metrics_config: Optional[List] = None
prometheus_emit_stream_label: bool = False
+prometheus_user_budget_label_include_email_alias: bool = False
prometheus_end_user_metrics_max_series_per_metric: Optional[int] = 10000
prometheus_end_user_metrics_ttl_seconds: Optional[float] = 3600.0
prometheus_end_user_metrics_cleanup_interval_seconds: Optional[float] = 60.0
@@ -631,6 +642,7 @@ minimax_models: Set = set()
aws_polly_models: Set = set()
gigachat_models: Set = set()
llamagate_models: Set = set()
+reducto_models: Set = set()
bedrock_mantle_models: Set = set()
@@ -898,6 +910,8 @@ def add_known_models(model_cost_map: Optional[Dict] = None):
gigachat_models.add(key)
elif value.get("litellm_provider") == "llamagate":
llamagate_models.add(key)
+ elif value.get("litellm_provider") == "reducto":
+ reducto_models.add(key)
elif value.get("litellm_provider") == "bedrock_mantle":
bedrock_mantle_models.add(key)
@@ -1009,6 +1023,7 @@ model_list = list(
| ovhcloud_models
| lemonade_models
| docker_model_runner_models
+ | reducto_models
| bedrock_mantle_models
| set(clarifai_models)
)
@@ -1115,6 +1130,7 @@ models_by_provider: dict = {
"aws_polly": aws_polly_models,
"gigachat": gigachat_models,
"llamagate": llamagate_models,
+ "reducto": reducto_models,
"bedrock_mantle": bedrock_mantle_models,
}
@@ -1287,6 +1303,18 @@ from .responses.main import *
# Interactions API is available as litellm.interactions module
# Usage: litellm.interactions.create(), litellm.interactions.get(), etc.
from . import interactions
+from .interactions.agents.main import (
+ acreate as acreate_agent,
+ create as create_agent,
+ alist as alist_agents,
+ list as list_agents,
+ aget as aget_agent,
+ get as get_agent,
+ adelete as adelete_agent,
+ delete as delete_agent,
+ alist_versions as alist_agent_versions,
+ list_versions as list_agent_versions,
+)
from .skills.main import (
create_skill,
acreate_skill,
@@ -1849,6 +1877,9 @@ if TYPE_CHECKING:
from .llms.azure.completion.transformation import (
AzureOpenAITextConfig as AzureOpenAITextConfig,
)
+ from .llms.azure.audio_transcription.transformation import (
+ AzureSpeechAudioTranscriptionConfig as AzureSpeechAudioTranscriptionConfig,
+ )
from .llms.hosted_vllm.chat.transformation import (
HostedVLLMChatConfig as HostedVLLMChatConfig,
)
@@ -1880,6 +1911,12 @@ if TYPE_CHECKING:
from .llms.dashscope.chat.transformation import (
DashScopeChatConfig as DashScopeChatConfig,
)
+ from .llms.dashscope.embed.transformation import (
+ DashScopeEmbeddingConfig as DashScopeEmbeddingConfig,
+ )
+ from .llms.dashscope.rerank.transformation import (
+ DashScopeRerankConfig as DashScopeRerankConfig,
+ )
from .llms.moonshot.chat.transformation import (
MoonshotChatConfig as MoonshotChatConfig,
)
diff --git a/litellm/_lazy_imports_registry.py b/litellm/_lazy_imports_registry.py
index e3656b340fa..17eb6609292 100644
--- a/litellm/_lazy_imports_registry.py
+++ b/litellm/_lazy_imports_registry.py
@@ -273,6 +273,7 @@ LLM_CONFIG_NAMES = (
"AzureOpenAIConfig",
"AzureOpenAIGPT5Config",
"AzureOpenAITextConfig",
+ "AzureSpeechAudioTranscriptionConfig",
"HostedVLLMChatConfig",
"HostedVLLMEmbeddingConfig",
# Alias for backwards compatibility
@@ -1054,6 +1055,10 @@ _LLM_CONFIGS_IMPORT_MAP = {
".llms.azure.completion.transformation",
"AzureOpenAITextConfig",
),
+ "AzureSpeechAudioTranscriptionConfig": (
+ ".llms.azure.audio_transcription.transformation",
+ "AzureSpeechAudioTranscriptionConfig",
+ ),
"HostedVLLMChatConfig": (
".llms.hosted_vllm.chat.transformation",
"HostedVLLMChatConfig",
diff --git a/litellm/_redis.py b/litellm/_redis.py
index 65284162663..5ab551453bb 100644
--- a/litellm/_redis.py
+++ b/litellm/_redis.py
@@ -100,6 +100,8 @@ def _get_redis_cluster_kwargs(client=None):
"azure_tenant_id",
"azure_client_secret",
"max_connections",
+ "socket_timeout",
+ "socket_connect_timeout",
}
return available_args
diff --git a/litellm/_uuid.py b/litellm/_uuid.py
index 52acf647dd8..2b7c3b82d35 100644
--- a/litellm/_uuid.py
+++ b/litellm/_uuid.py
@@ -6,7 +6,6 @@ Always uses fastuuid for performance.
import fastuuid as _uuid # type: ignore
-
# Expose a module-like alias so callers can use: uuid.uuid4()
uuid = _uuid
diff --git a/litellm/anthropic_interface/exceptions/exception_mapping_utils.py b/litellm/anthropic_interface/exceptions/exception_mapping_utils.py
index 28020e763f4..4548185bbdc 100644
--- a/litellm/anthropic_interface/exceptions/exception_mapping_utils.py
+++ b/litellm/anthropic_interface/exceptions/exception_mapping_utils.py
@@ -9,7 +9,6 @@ from typing import Dict, Optional
from .exceptions import AnthropicErrorResponse, AnthropicErrorType
-
# HTTP status code -> Anthropic error type
# Source: https://docs.anthropic.com/en/api/errors
ANTHROPIC_ERROR_TYPE_MAP: Dict[int, AnthropicErrorType] = {
diff --git a/litellm/anthropic_interface/exceptions/exceptions.py b/litellm/anthropic_interface/exceptions/exceptions.py
index 984390fa702..b289e493e6b 100644
--- a/litellm/anthropic_interface/exceptions/exceptions.py
+++ b/litellm/anthropic_interface/exceptions/exceptions.py
@@ -2,7 +2,6 @@
from typing_extensions import Literal, Required, TypedDict
-
# Known Anthropic error types
# Source: https://docs.anthropic.com/en/api/errors
AnthropicErrorType = Literal[
diff --git a/litellm/caching/caching_handler.py b/litellm/caching/caching_handler.py
index 3cf1d911d7f..3f4e54382c9 100644
--- a/litellm/caching/caching_handler.py
+++ b/litellm/caching/caching_handler.py
@@ -87,6 +87,16 @@ class CachingHandlerResponse(BaseModel):
in_memory_cache_obj = InMemoryCache()
+def _is_chat_completion_cached_dict(cached_result: dict) -> bool:
+ cached_id = cached_result.get("id")
+ if isinstance(cached_id, str) and cached_id.startswith("chatcmpl"):
+ return True
+ obj = cached_result.get("object")
+ if isinstance(obj, str):
+ return obj.startswith("chat.completion")
+ return "choices" in cached_result
+
+
def _should_defer_streaming_cache_hit_callbacks(*, kwargs: Dict[str, Any]) -> bool:
"""
When stream=True, do not run success callbacks at cache-hit time.
@@ -861,27 +871,47 @@ class LLMCachingHandler:
elif (call_type == "aresponses" or call_type == "responses") and isinstance(
cached_result, dict
):
- from litellm.responses.streaming_iterator import (
- CachedResponsesAPIStreamingIterator,
- )
-
- response_obj = ResponsesAPIResponse(**cached_result)
- if (
- hasattr(response_obj, "_hidden_params")
- and response_obj._hidden_params is not None
- and isinstance(response_obj._hidden_params, dict)
- ):
- response_obj._hidden_params["cache_hit"] = True
-
- if kwargs.get("stream", False) is True:
- cached_result = CachedResponsesAPIStreamingIterator(
- response=response_obj,
- logging_obj=logging_obj,
- request_data=kwargs,
- call_type=call_type,
- )
+ use_chat_completion_cache = _is_chat_completion_cached_dict(cached_result)
+ if use_chat_completion_cache:
+ if kwargs.get("stream", False) is True:
+ bridge_call_type = (
+ CallTypes.acompletion.value
+ if call_type == "aresponses"
+ else CallTypes.completion.value
+ )
+ cached_result = self._convert_cached_stream_response(
+ cached_result=cached_result,
+ call_type=bridge_call_type,
+ logging_obj=logging_obj,
+ model=model,
+ )
+ else:
+ cached_result = convert_to_model_response_object(
+ response_object=cached_result,
+ model_response_object=ModelResponse(),
+ )
else:
- cached_result = response_obj
+ from litellm.responses.streaming_iterator import (
+ CachedResponsesAPIStreamingIterator,
+ )
+
+ response_obj = ResponsesAPIResponse(**cached_result)
+ if (
+ hasattr(response_obj, "_hidden_params")
+ and response_obj._hidden_params is not None
+ and isinstance(response_obj._hidden_params, dict)
+ ):
+ response_obj._hidden_params["cache_hit"] = True
+
+ if kwargs.get("stream", False) is True:
+ cached_result = CachedResponsesAPIStreamingIterator(
+ response=response_obj,
+ logging_obj=logging_obj,
+ request_data=kwargs,
+ call_type=call_type,
+ )
+ else:
+ cached_result = response_obj
if (
hasattr(cached_result, "_hidden_params")
diff --git a/litellm/completion_extras/litellm_responses_transformation/handler.py b/litellm/completion_extras/litellm_responses_transformation/handler.py
index ce398ee8288..2de7bda6467 100644
--- a/litellm/completion_extras/litellm_responses_transformation/handler.py
+++ b/litellm/completion_extras/litellm_responses_transformation/handler.py
@@ -37,6 +37,15 @@ class ResponsesToCompletionBridgeHandler:
stream = litellm_params.get("stream", False)
return bool(stream)
+ @staticmethod
+ def _is_preformatted_cached_chat_stream(result: Any) -> bool:
+ from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
+
+ return (
+ isinstance(result, CustomStreamWrapper)
+ and result.custom_llm_provider == "cached_response"
+ )
+
@staticmethod
def _coerce_response_object(
response_obj: Any,
@@ -177,6 +186,8 @@ class ResponsesToCompletionBridgeHandler:
**request_data,
)
+ from litellm.types.utils import ModelResponse
+
stream = self._resolve_stream_flag(optional_params, litellm_params)
if isinstance(result, ResponsesAPIResponse):
return self.transformation_handler.transform_response(
@@ -192,6 +203,8 @@ class ResponsesToCompletionBridgeHandler:
api_key=kwargs.get("api_key"),
json_mode=kwargs.get("json_mode"),
)
+ elif isinstance(result, ModelResponse):
+ return result
elif not stream:
responses_api_response = self._collect_response_from_stream(result)
return self.transformation_handler.transform_response(
@@ -208,6 +221,10 @@ class ResponsesToCompletionBridgeHandler:
json_mode=kwargs.get("json_mode"),
)
else:
+ if self._is_preformatted_cached_chat_stream(result):
+ return self._apply_post_stream_processing(
+ result, model, custom_llm_provider
+ )
completion_stream = self.transformation_handler.get_model_response_iterator(
streaming_response=result, # type: ignore
sync_stream=True,
@@ -256,6 +273,8 @@ class ResponsesToCompletionBridgeHandler:
aresponses=True,
)
+ from litellm.types.utils import ModelResponse
+
stream = self._resolve_stream_flag(optional_params, litellm_params)
if isinstance(result, ResponsesAPIResponse):
return self.transformation_handler.transform_response(
@@ -271,6 +290,8 @@ class ResponsesToCompletionBridgeHandler:
api_key=kwargs.get("api_key"),
json_mode=kwargs.get("json_mode"),
)
+ elif isinstance(result, ModelResponse):
+ return result
elif not stream:
responses_api_response = await self._collect_response_from_stream_async(
result
@@ -289,6 +310,10 @@ class ResponsesToCompletionBridgeHandler:
json_mode=kwargs.get("json_mode"),
)
else:
+ if self._is_preformatted_cached_chat_stream(result):
+ return self._apply_post_stream_processing(
+ result, model, custom_llm_provider
+ )
completion_stream = self.transformation_handler.get_model_response_iterator(
streaming_response=result, # type: ignore
sync_stream=False,
diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py
index 32423f23314..51abbbf729b 100644
--- a/litellm/completion_extras/litellm_responses_transformation/transformation.py
+++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py
@@ -30,6 +30,11 @@ from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator
from litellm.llms.base_llm.bridges.completion_transformation import (
CompletionTransformationBridge,
)
+from litellm.responses.sse_output_recovery import (
+ parse_sse_json_chunk,
+ record_output_item_chunk,
+ record_output_text_chunk,
+)
from litellm.types.llms.openai import (
ChatCompletionAnnotation,
ChatCompletionReasoningItem,
@@ -97,7 +102,7 @@ def _build_reasoning_item(
def _reasoning_item_to_response_input(
- r_item: Union[ChatCompletionReasoningItem, Dict[str, Any]]
+ r_item: Union[ChatCompletionReasoningItem, Dict[str, Any]],
) -> Dict[str, Any]:
"""Convert a stored ChatCompletionReasoningItem back to a Responses API input item."""
r_input: Dict[str, Any] = {
@@ -601,6 +606,79 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
return choices
+ @classmethod
+ def _extract_output_from_completed_event(
+ cls, parsed_chunk: Dict[str, Any]
+ ) -> Optional[List[Dict[str, Any]]]:
+ response_payload = parsed_chunk.get("response")
+ if not isinstance(response_payload, dict):
+ return None
+ response_output = response_payload.get("output")
+ if not isinstance(response_output, list) or len(response_output) == 0:
+ return None
+ return cast(List[Dict[str, Any]], response_output)
+
+ @classmethod
+ def _recover_output_items_from_raw_sse(
+ cls, raw_sse: Optional[str]
+ ) -> List[Dict[str, Any]]:
+ if not raw_sse or not isinstance(raw_sse, str):
+ return []
+
+ recovered_output_items: Dict[int, Dict[str, Any]] = {}
+ recovered_text_only_items: Dict[int, Dict[str, Any]] = {}
+
+ for chunk in raw_sse.splitlines():
+ parsed_chunk = parse_sse_json_chunk(chunk)
+ if parsed_chunk is None:
+ continue
+
+ event_type = parsed_chunk.get("type")
+
+ if event_type == ResponsesAPIStreamEvents.RESPONSE_COMPLETED:
+ recovered_output = cls._extract_output_from_completed_event(
+ parsed_chunk
+ )
+ if recovered_output is not None:
+ return recovered_output
+ continue
+
+ if event_type == ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE:
+ record_output_item_chunk(
+ parsed_chunk=parsed_chunk,
+ output_items=recovered_output_items,
+ )
+ continue
+
+ if event_type == ResponsesAPIStreamEvents.OUTPUT_TEXT_DONE:
+ record_output_text_chunk(
+ parsed_chunk=parsed_chunk,
+ output_items=recovered_output_items,
+ text_only_items=recovered_text_only_items,
+ )
+ continue
+
+ # Merge text-only items into the recovered output items. Real
+ # OUTPUT_ITEM_DONE events take precedence at any given output_index,
+ # but text-only items at indices without a matching OUTPUT_ITEM_DONE
+ # must still be preserved (e.g. multi-output responses where some
+ # indices only emitted OUTPUT_TEXT_DONE).
+ merged_items: Dict[int, Dict[str, Any]] = {**recovered_text_only_items}
+ merged_items.update(recovered_output_items)
+
+ if merged_items:
+ return [item for _, item in sorted(merged_items.items())]
+
+ return []
+
+ @classmethod
+ def _recover_output_items_from_logging(
+ cls, logging_obj: "LiteLLMLoggingObj"
+ ) -> List[Dict[str, Any]]:
+ model_call_details = getattr(logging_obj, "model_call_details", {}) or {}
+ original_response = model_call_details.get("original_response")
+ return cls._recover_output_items_from_raw_sse(original_response)
+
def transform_response( # noqa: PLR0915
self,
model: str,
@@ -625,9 +703,22 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
if raw_response.error is not None:
raise ValueError(f"Error in response: {raw_response.error}")
+ output_items = raw_response.output
+ if len(output_items) == 0:
+ recovered_output_items = self._recover_output_items_from_logging(
+ logging_obj
+ )
+ if recovered_output_items:
+ output_items = cast(Any, recovered_output_items)
+ raw_response.output = cast(Any, recovered_output_items)
+ verbose_logger.warning(
+ "Recovered empty Responses API output from raw SSE for model=%s",
+ model,
+ )
+
# Convert response output to choices using the static helper
choices = self._convert_response_output_to_choices(
- output_items=raw_response.output,
+ output_items=output_items,
handle_raw_dict_callback=self._handle_raw_dict_response_item,
)
@@ -641,7 +732,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
)
else:
raise ValueError(
- f"Unknown items in responses API response: {raw_response.output}"
+ f"Unknown items in responses API response: {output_items}"
)
setattr(model_response, "choices", choices)
@@ -1141,6 +1232,14 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
event_type = parsed_chunk.get("type")
if isinstance(event_type, ResponsesAPIStreamEvents):
event_type = event_type.value
+
+ if parsed_chunk.get("object") == "chat.completion.chunk" or (
+ event_type is None
+ and isinstance(parsed_chunk.get("choices"), list)
+ and parsed_chunk.get("choices")
+ ):
+ return ModelResponseStream(**parsed_chunk)
+
verbose_logger.debug(f"Chat provider: Processing event type: {event_type}")
if event_type == "response.created":
@@ -1229,7 +1328,7 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
raise ValueError(
f"Chat provider: Invalid function argument delta {parsed_chunk}"
)
- elif event_type == "response.output_item.done":
+ elif event_type == ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE:
# New output item added
output_item = parsed_chunk.get("item", {})
if output_item.get("type") == "function_call":
diff --git a/litellm/compression/content_detection.py b/litellm/compression/content_detection.py
index 0655a42daf5..975117eb608 100644
--- a/litellm/compression/content_detection.py
+++ b/litellm/compression/content_detection.py
@@ -5,7 +5,6 @@ Auto-detect content type per message: code, JSON, or text.
import json
import re
-
_CODE_KEYWORDS = re.compile(
r"\b(?:def |function |class |import |from |require\(|#include|fn |func |const |let |var |public |private |static )\b"
)
diff --git a/litellm/constants.py b/litellm/constants.py
index e36746326cc..fb765c0226c 100644
--- a/litellm/constants.py
+++ b/litellm/constants.py
@@ -1443,6 +1443,12 @@ CLI_JWT_EXPIRATION_HOURS = int(
or os.getenv("LITELLM_CLI_JWT_EXPIRATION_HOURS")
or 24
)
+# Comma-separated allowlisted OIDC claim map for CLI SSO polling, e.g.
+# "employment_type->acme_employment_type,org_info.department->department"
+CLI_SSO_CLAIM_MAP = (
+ os.getenv("CLI_SSO_CLAIM_MAP") or os.getenv("LITELLM_CLI_SSO_CLAIM_MAP") or ""
+)
+CLI_SSO_CLAIM_MAX_SCALAR_LENGTH = 1024
########################### UI SESSION DURATION ###########################
# Duration for UI login session (username/password, SSO, invitation links). Format: "30s", "30m", "24h", "7d"
diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py
index c0ec148d03a..98e00cf5788 100644
--- a/litellm/cost_calculator.py
+++ b/litellm/cost_calculator.py
@@ -173,17 +173,45 @@ def _cost_per_token_custom_pricing_helper(
prompt_tokens: float = 0,
completion_tokens: float = 0,
response_time_ms: Optional[float] = 0.0,
+ cached_tokens: float = 0,
+ cache_creation_tokens: float = 0,
### CUSTOM PRICING ###
custom_cost_per_token: Optional[CostPerToken] = None,
custom_cost_per_second: Optional[float] = None,
) -> Optional[Tuple[float, float]]:
- """Internal helper function for calculating cost, if custom pricing given"""
+ """Internal helper function for calculating cost, if custom pricing given.
+
+ prompt_tokens is assumed to include both cached_tokens and cache_creation_tokens
+ (OpenAI-compatible convention). Anthropic-style usage where prompt_tokens excludes
+ cache tokens is handled at the caller (cost_per_token) before invoking this helper.
+ """
if custom_cost_per_token is None and custom_cost_per_second is None:
return None
if custom_cost_per_token is not None:
- input_cost = custom_cost_per_token["input_cost_per_token"] * prompt_tokens
- output_cost = custom_cost_per_token["output_cost_per_token"] * completion_tokens
+ input_cost_per_token = custom_cost_per_token["input_cost_per_token"]
+ output_cost_per_token = custom_cost_per_token["output_cost_per_token"]
+
+ cache_read_input_token_cost = custom_cost_per_token.get(
+ "cache_read_input_token_cost",
+ input_cost_per_token,
+ )
+ cache_creation_input_token_cost = custom_cost_per_token.get(
+ "cache_creation_input_token_cost",
+ input_cost_per_token,
+ )
+
+ regular_prompt_tokens = max(
+ prompt_tokens - cached_tokens - cache_creation_tokens,
+ 0,
+ )
+
+ input_cost = (
+ regular_prompt_tokens * input_cost_per_token
+ + cached_tokens * cache_read_input_token_cost
+ + cache_creation_tokens * cache_creation_input_token_cost
+ )
+ output_cost = completion_tokens * output_cost_per_token
return input_cost, output_cost
elif custom_cost_per_second is not None:
output_cost = custom_cost_per_second * response_time_ms / 1000 # type: ignore
@@ -323,10 +351,56 @@ def cost_per_token( # noqa: PLR0915
)
## CUSTOM PRICING ##
+ # Normalize cache token counts across providers:
+ # - OpenAI-compatible: usage.prompt_tokens_details.cached_tokens
+ # (prompt_tokens already INCLUDES cached_tokens)
+ # - Anthropic: usage.cache_read_input_tokens / cache_creation_input_tokens
+ # (prompt_tokens does NOT include these — adjust before calling helper)
+ _cache_read_tokens: float = 0
+ _cache_creation_tokens: float = 0
+ _is_anthropic_style = False
+
+ if usage_object is not None:
+ _pt_details = getattr(usage_object, "prompt_tokens_details", None)
+ if _pt_details is not None:
+ _cache_read_tokens = float(getattr(_pt_details, "cached_tokens", 0) or 0)
+ # OpenAI-compatible providers report cache-write tokens under
+ # either `cache_write_tokens` (kimi-k2) or `cache_creation_tokens`.
+ # Mirror db_spend_update_writer to stay symmetric.
+ _cache_creation_tokens = float(
+ getattr(_pt_details, "cache_write_tokens", 0)
+ or getattr(_pt_details, "cache_creation_tokens", 0)
+ or 0
+ )
+
+ _anthropic_read = getattr(usage_object, "cache_read_input_tokens", None)
+ _anthropic_create = getattr(usage_object, "cache_creation_input_tokens", None)
+ if _anthropic_read is not None or _anthropic_create is not None:
+ _is_anthropic_style = True
+ if _anthropic_read is not None:
+ _cache_read_tokens = float(_anthropic_read)
+ if _anthropic_create is not None:
+ _cache_creation_tokens = float(_anthropic_create)
+
+ if not _cache_read_tokens and cache_read_input_tokens:
+ _cache_read_tokens = float(cache_read_input_tokens)
+ _is_anthropic_style = True
+ if not _cache_creation_tokens and cache_creation_input_tokens:
+ _cache_creation_tokens = float(cache_creation_input_tokens)
+ _is_anthropic_style = True
+
+ # Anthropic reports prompt_tokens as input_tokens (excluding cache tokens).
+ # Adjust so the helper's "prompt_tokens includes cache tokens" invariant holds.
+ _normalized_prompt_tokens = float(prompt_tokens)
+ if _is_anthropic_style:
+ _normalized_prompt_tokens += _cache_read_tokens + _cache_creation_tokens
+
response_cost = _cost_per_token_custom_pricing_helper(
- prompt_tokens=prompt_tokens,
+ prompt_tokens=_normalized_prompt_tokens,
completion_tokens=completion_tokens,
response_time_ms=response_time_ms,
+ cached_tokens=_cache_read_tokens,
+ cache_creation_tokens=_cache_creation_tokens,
custom_cost_per_second=custom_cost_per_second,
custom_cost_per_token=custom_cost_per_token,
)
@@ -1805,10 +1879,6 @@ def ocr_cost(
if response.usage_info is None:
raise ValueError("OCR response usage_info is None")
- pages_processed = response.usage_info.pages_processed
- if pages_processed is None:
- raise ValueError("OCR response pages_processed is None")
-
try:
model_info: Optional[ModelInfo] = litellm.get_model_info(
model=model, custom_llm_provider=custom_llm_provider
@@ -1816,9 +1886,49 @@ def ocr_cost(
except Exception:
model_info = None
- ocr_cost_per_page: float = 0.0
+ credits = getattr(response.usage_info, "credits", None)
+ cost_per_credit = None
if model_info is not None:
- ocr_cost_per_page = model_info.get("ocr_cost_per_page") or 0.0
+ cost_per_credit = model_info.get("ocr_cost_per_credit")
+ if credits is not None and cost_per_credit is not None:
+ return cost_per_credit * credits, 0.0
+
+ ocr_cost_per_page: Optional[float] = None
+ if model_info is not None:
+ ocr_cost_per_page = model_info.get("ocr_cost_per_page")
+
+ pages_processed = response.usage_info.pages_processed
+ if pages_processed is None:
+ if cost_per_credit is not None or ocr_cost_per_page is None:
+ # Surface missing usage data instead of silently under-reporting
+ # cost. The previous behavior raised ValueError; we now return 0.0
+ # for credit-priced or unpriced models, so log a warning to keep
+ # the regression visible to operators.
+ verbose_logger.warning(
+ "OCR cost: model=%s custom_llm_provider=%s response.usage_info."
+ "pages_processed is None and credits=%s; returning 0.0 cost.",
+ model,
+ custom_llm_provider,
+ credits,
+ )
+ return 0.0, 0.0
+ raise ValueError("OCR response pages_processed is None")
+
+ if ocr_cost_per_page is None:
+ # No per-page pricing configured. Either the model is on credit-based
+ # pricing (and credits weren't returned, so the credit branch above did
+ # not match) or the model has no OCR pricing entry at all. Surface a
+ # warning so that missing pricing entries are visible rather than
+ # silently producing zero cost for billable usage.
+ verbose_logger.warning(
+ "OCR cost: model=%s custom_llm_provider=%s reported "
+ "pages_processed=%s but no ocr_cost_per_page is configured; "
+ "returning 0.0 cost.",
+ model,
+ custom_llm_provider,
+ pages_processed,
+ )
+ return 0.0, 0.0
total_ocr_processing_cost: float = ocr_cost_per_page * pages_processed
return total_ocr_processing_cost, 0.0
diff --git a/litellm/exceptions.py b/litellm/exceptions.py
index 8b005291556..17f5b43c273 100644
--- a/litellm/exceptions.py
+++ b/litellm/exceptions.py
@@ -918,9 +918,11 @@ class GuardrailRaisedException(Exception):
guardrail_name: Optional[str] = None,
message: str = "",
should_wrap_with_default_message: bool = True,
+ status_code: int = 400,
):
default_message = f"Guardrail raised an exception, Guardrail: {guardrail_name}, Message: {message}"
self.guardrail_name = guardrail_name
+ self.status_code = status_code
self.message = default_message if should_wrap_with_default_message else message
super().__init__(self.message)
@@ -930,12 +932,14 @@ class BlockedPiiEntityError(Exception):
self,
entity_type: str,
guardrail_name: Optional[str] = None,
+ status_code: int = 400,
):
"""
Raised when a blocked entity is detected by a guardrail.
"""
self.entity_type = entity_type
self.guardrail_name = guardrail_name
+ self.status_code = status_code
self.message = f"Blocked entity detected: {entity_type} by Guardrail: {guardrail_name}. This entity is not allowed to be used in this request."
super().__init__(self.message)
diff --git a/litellm/files/types.py b/litellm/files/types.py
index 688bc86f0cf..ba42a39f666 100644
--- a/litellm/files/types.py
+++ b/litellm/files/types.py
@@ -1,6 +1,5 @@
from typing import AsyncIterator, Dict, Iterator, Literal, NamedTuple, Union
-
FileContentProvider = Literal[
"openai", "azure", "vertex_ai", "bedrock", "hosted_vllm", "anthropic", "manus"
]
diff --git a/litellm/google_genai/adapters/__init__.py b/litellm/google_genai/adapters/__init__.py
index bfa9e712678..6fbe7d95a55 100644
--- a/litellm/google_genai/adapters/__init__.py
+++ b/litellm/google_genai/adapters/__init__.py
@@ -1,10 +1,10 @@
"""
Google GenAI Adapters for LiteLLM
-This module provides adapters for transforming Google GenAI generate_content requests
+This module provides adapters for transforming Google GenAI generate_content requests
to/from LiteLLM completion format with full support for:
- Text content transformation
-- Tool calling (function declarations, function calls, function responses)
+- Tool calling (function declarations, function calls, function responses)
- Streaming (both regular and tool calling)
- Mixed content (text + tool calls)
"""
diff --git a/litellm/integrations/SlackAlerting/batching_handler.py b/litellm/integrations/SlackAlerting/batching_handler.py
index fdce2e04793..828f3eb4175 100644
--- a/litellm/integrations/SlackAlerting/batching_handler.py
+++ b/litellm/integrations/SlackAlerting/batching_handler.py
@@ -1,9 +1,9 @@
"""
-Handles Batching + sending Httpx Post requests to slack
+Handles Batching + sending Httpx Post requests to slack
-Slack alerts are sent every 10s or when events are greater than X events
+Slack alerts are sent every 10s or when events are greater than X events
-see custom_batch_logger.py for more details / defaults
+see custom_batch_logger.py for more details / defaults
"""
from typing import TYPE_CHECKING, Any
diff --git a/litellm/integrations/SlackAlerting/utils.py b/litellm/integrations/SlackAlerting/utils.py
index e695266c88b..e2580768178 100644
--- a/litellm/integrations/SlackAlerting/utils.py
+++ b/litellm/integrations/SlackAlerting/utils.py
@@ -18,7 +18,7 @@ else:
def process_slack_alerting_variables(
- alert_to_webhook_url: Optional[Dict[AlertType, Union[List[str], str]]]
+ alert_to_webhook_url: Optional[Dict[AlertType, Union[List[str], str]]],
) -> Optional[Dict[AlertType, Union[List[str], str]]]:
"""
process alert_to_webhook_url
diff --git a/litellm/integrations/additional_logging_utils.py b/litellm/integrations/additional_logging_utils.py
index 795afd81d41..59319140a18 100644
--- a/litellm/integrations/additional_logging_utils.py
+++ b/litellm/integrations/additional_logging_utils.py
@@ -1,5 +1,5 @@
"""
-Base class for Additional Logging Utils for CustomLoggers
+Base class for Additional Logging Utils for CustomLoggers
- Health Check for the logging util
- Get Request / Response Payload for the logging util
diff --git a/litellm/integrations/custom_batch_logger.py b/litellm/integrations/custom_batch_logger.py
index f9d4496c21f..8f4844501c3 100644
--- a/litellm/integrations/custom_batch_logger.py
+++ b/litellm/integrations/custom_batch_logger.py
@@ -1,5 +1,5 @@
"""
-Custom Logger that handles batching logic
+Custom Logger that handles batching logic
Use this if you want your logs to be stored in memory and flushed periodically.
"""
@@ -14,22 +14,38 @@ from litellm.integrations.custom_logger import CustomLogger
class CustomBatchLogger(CustomLogger):
+ preserve_events_added_during_flush = False
+
+ # Default cap on the in-memory log queue. Prevents unbounded memory growth
+ # if ``async_send_batch`` consistently fails (e.g. the destination is
+ # unreachable) and events are preserved across flush attempts. Subclasses
+ # may override by passing ``max_queue_size`` or by setting the attribute
+ # directly (see ``RubrikLogger`` for an example).
+ DEFAULT_MAX_QUEUE_SIZE = 50_000
+
def __init__(
self,
flush_lock: Optional[asyncio.Lock] = None,
batch_size: Optional[int] = None,
flush_interval: Optional[int] = None,
+ max_queue_size: Optional[int] = None,
**kwargs,
) -> None:
"""
Args:
flush_lock (Optional[asyncio.Lock], optional): Lock to use when flushing the queue. Defaults to None. Only used for custom loggers that do batching
+ max_queue_size (Optional[int], optional): Maximum number of events to retain in ``log_queue``. When the limit is exceeded (e.g. because the send destination is unreachable and events are preserved for retry), the oldest events are dropped. Defaults to ``DEFAULT_MAX_QUEUE_SIZE``.
"""
self.log_queue: List = []
self.flush_interval = flush_interval or litellm.DEFAULT_FLUSH_INTERVAL_SECONDS
self.batch_size: int = batch_size or litellm.DEFAULT_BATCH_SIZE
self.last_flush_time = time.time()
self.flush_lock = flush_lock
+ self.max_queue_size: int = (
+ max_queue_size
+ if max_queue_size is not None
+ else self.DEFAULT_MAX_QUEUE_SIZE
+ )
super().__init__(**kwargs)
@@ -47,11 +63,40 @@ class CustomBatchLogger(CustomLogger):
async with self.flush_lock:
if self.log_queue:
+ log_queue_length = len(self.log_queue)
verbose_logger.debug(
"CustomLogger: Flushing batch of %s events", len(self.log_queue)
)
- await self.async_send_batch()
- self.log_queue.clear()
+ try:
+ await self.async_send_batch()
+ except Exception:
+ # If the underlying batch send raised, do NOT drop the
+ # in-flight events. They will be retried on the next flush.
+ # Most existing async_send_batch implementations swallow
+ # their own errors, so this only affects loggers that opt
+ # in to surfacing failures (e.g. Rubrik).
+ verbose_logger.exception(
+ "CustomLogger: async_send_batch raised; preserving "
+ "%s events in queue for retry",
+ log_queue_length,
+ )
+ # Guard against unbounded queue growth if the destination
+ # is persistently unreachable. Drop the oldest events
+ # beyond ``max_queue_size``.
+ overflow = len(self.log_queue) - self.max_queue_size
+ if overflow > 0:
+ del self.log_queue[:overflow]
+ verbose_logger.warning(
+ "CustomLogger: log queue exceeded max_queue_size=%s; "
+ "dropped %s oldest events.",
+ self.max_queue_size,
+ overflow,
+ )
+ return
+ if self.preserve_events_added_during_flush:
+ del self.log_queue[:log_queue_length]
+ else:
+ self.log_queue.clear()
self.last_flush_time = time.time()
async def async_send_batch(self, *args, **kwargs):
diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py
index c937ad0a7bf..82a35f2eedd 100644
--- a/litellm/integrations/custom_guardrail.py
+++ b/litellm/integrations/custom_guardrail.py
@@ -43,7 +43,11 @@ if TYPE_CHECKING:
dc = DualCache()
-from litellm.exceptions import ModifyResponseException as ModifyResponseException
+from litellm.exceptions import (
+ BlockedPiiEntityError,
+ GuardrailRaisedException,
+ ModifyResponseException,
+)
class CustomGuardrail(CustomLogger):
@@ -737,12 +741,15 @@ class CustomGuardrail(CustomLogger):
(this was logged previously as an API failure - guardrail_failed_to_respond).
Guardrails signal intentional blocks by raising:
+ - GuardrailRaisedException (generic guardrail API, tool permission)
+ - BlockedPiiEntityError (Presidio PII detection)
- HTTPException with status 400 (content policy violation)
- ModifyResponseException (passthrough mode violation)
"""
-
if isinstance(e, ModifyResponseException):
return True
+ if isinstance(e, (GuardrailRaisedException, BlockedPiiEntityError)):
+ return True
if (
HTTPException is not None
and isinstance(e, HTTPException)
diff --git a/litellm/integrations/focus/transformer.py b/litellm/integrations/focus/transformer.py
index b7d28e3dbb9..6f4433b4a05 100644
--- a/litellm/integrations/focus/transformer.py
+++ b/litellm/integrations/focus/transformer.py
@@ -9,7 +9,6 @@ import polars as pl
from .schema import FOCUS_NORMALIZED_SCHEMA
-
_TAG_KEYS = (
"team_id",
"team_alias",
diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py
index 2de8e8bf7f1..81fdc5a1e21 100644
--- a/litellm/integrations/opentelemetry.py
+++ b/litellm/integrations/opentelemetry.py
@@ -673,6 +673,15 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
if parent_otel_span is not None:
parent_otel_span.set_status(Status(StatusCode.ERROR))
+ # Stamp team attributes onto the SERVER (root) span too, so the
+ # trace root is team-filterable on the failure path like the
+ # child exception span below.
+ self._set_team_attributes_on_span(
+ span=parent_otel_span,
+ team_id=user_api_key_dict.team_id,
+ team_alias=user_api_key_dict.team_alias,
+ )
+
# Stamp structured error attrs on the SERVER span itself; the
# failure path otherwise only sets its status (_handle_failure
# records on the litellm_request child span). Inline import:
@@ -693,6 +702,14 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
},
)
+ # _record_exception_on_span only stamps when error_code is set;
+ # bare TypeError etc. has none, and the span is about to be ended.
+ error_code = (
+ error_information.get("error_code") if error_information else None
+ )
+ if not error_code:
+ self.set_response_status_code_attribute(parent_otel_span, 500)
+
# Pre-request latency (request_data carries the propagated
# metadata on the failure path; omitted if it failed before handoff).
self.set_preprocessing_duration_attribute(parent_otel_span, request_data)
@@ -709,12 +726,65 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
key="exception",
value=str(original_exception),
)
+ self._set_team_attributes_on_span(
+ span=exception_logging_span,
+ team_id=user_api_key_dict.team_id,
+ team_alias=user_api_key_dict.team_alias,
+ )
exception_logging_span.set_status(Status(StatusCode.ERROR))
exception_logging_span.end(end_time=self._to_ns(datetime.now()))
+ # Emit guardrail spans for any guardrail invocations that
+ # ran during this request. _handle_failure typically does this,
+ # but for pre-call guardrail blocks the standard_logging_object
+ # may not carry guardrail_information by the time _handle_failure
+ # fires (the data lives only in request_data["metadata"]). Pull
+ # directly from request_data so the span is recorded either way;
+ # _emit_once dedupes if _handle_failure already emitted it.
+ self._emit_guardrail_spans_from_request_data(
+ request_data=request_data,
+ parent_span=parent_otel_span,
+ )
+
# End Parent OTEL Sspan
parent_otel_span.end(end_time=self._to_ns(datetime.now()))
+ def _emit_guardrail_spans_from_request_data(
+ self,
+ request_data: dict,
+ parent_span: Optional[Any],
+ ) -> None:
+ """Emit ``guardrail`` spans from ``request_data["metadata"]
+ ["standard_logging_guardrail_information"]``.
+
+ Routed through ``_create_guardrail_span`` so the dedupe state in
+ ``_otel_internal`` is honoured — if ``_handle_failure`` already
+ emitted these spans for the same kwargs, this is a no-op.
+ """
+ from opentelemetry import trace as _trace
+
+ metadata = (request_data or {}).get("metadata") or {}
+ guardrail_information = metadata.get("standard_logging_guardrail_information")
+ if not guardrail_information:
+ return
+
+ # _create_guardrail_span reads guardrail_information from
+ # kwargs["standard_logging_object"] and shares its dedupe state via
+ # kwargs["litellm_params"]["metadata"]["_otel_internal"]. Pass the
+ # SAME metadata dict the proxy populated so _handle_failure and
+ # this hook see the same dedupe markers.
+ kwargs: Dict[str, Any] = {
+ "litellm_params": {"metadata": metadata},
+ "standard_logging_object": {
+ "guardrail_information": guardrail_information,
+ "metadata": metadata,
+ },
+ }
+ context = (
+ _trace.set_span_in_context(parent_span) if parent_span is not None else None
+ )
+ self._create_guardrail_span(kwargs=kwargs, context=context)
+
async def async_post_call_success_hook(
self,
data: dict,
@@ -736,11 +806,6 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
# Pre-request latency on the SERVER span (success path).
self.set_preprocessing_duration_attribute(parent_span, kwargs)
- # http.response.status_code on the SERVER span (success path).
- # A successful proxy response is HTTP 200; the failure path sets
- # this from the error code in _record_exception_on_span.
- self.set_response_status_code_attribute(parent_span, 200)
-
# 3. Guardrail span
self._create_guardrail_span(kwargs=kwargs, context=ctx)
@@ -923,7 +988,15 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
and hasattr(proxy_span, "is_recording")
and proxy_span.is_recording()
):
- proxy_span.end(end_time=self._to_ns(end_time))
+ self._close_proxy_span_ok(proxy_span, end_time)
+
+ def _close_proxy_span_ok(self, span: Span, end_time) -> None:
+ """Stamp http.response.status_code=200 + status=OK, then end the span."""
+ from opentelemetry.trace import Status, StatusCode
+
+ self.set_response_status_code_attribute(span, 200)
+ span.set_status(Status(StatusCode.OK))
+ span.end(end_time=self._to_ns(end_time))
def _handle_success(self, kwargs, response_obj, start_time, end_time):
"""Create the litellm_request span then close the proxy span."""
@@ -1009,8 +1082,14 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
parent_span is not None
and hasattr(parent_span, "name")
and parent_span.name == LITELLM_PROXY_REQUEST_SPAN_NAME
+ and hasattr(parent_span, "is_recording")
+ and parent_span.is_recording()
):
- parent_span.end(end_time=self._to_ns(end_time))
+ self._close_proxy_span_ok(parent_span, end_time)
+
+ # Stamp team attributes onto the SERVER (root) span before it is
+ # closed, so the trace root carries them like every child span.
+ self._set_team_attributes_on_proxy_span_from_kwargs(kwargs)
# close the proxy span explicitly from kwargs metadata
# after all child spans (litellm_request, guardrail, raw_request)
@@ -1070,8 +1149,70 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
)
raw_span.set_status(Status(StatusCode.OK))
self.set_raw_request_attributes(raw_span, kwargs, response_obj)
+ self._set_team_attributes_from_kwargs(raw_span, kwargs)
raw_span.end(end_time=self._to_ns(end_time))
+ def _set_team_attributes_on_span(
+ self,
+ span: Span,
+ team_id: Optional[str],
+ team_alias: Optional[str],
+ ) -> None:
+ """Stamp team_id / team_alias onto a span so every child span of a
+ litellm_request trace carries them, not just the root span.
+
+ Empty strings are treated as absent: a request made with the master
+ key or a team-less virtual key carries ``user_api_key_team_id=""``
+ in ``standard_logging_object.metadata``; propagating that to every
+ span only adds noise that makes traces look mis-instrumented.
+ """
+ if team_id:
+ self.safe_set_attribute(
+ span=span,
+ key="metadata.user_api_key_team_id",
+ value=team_id,
+ )
+ if team_alias:
+ self.safe_set_attribute(
+ span=span,
+ key="metadata.user_api_key_team_alias",
+ value=team_alias,
+ )
+
+ def _set_team_attributes_from_kwargs(self, span: Span, kwargs: dict) -> None:
+ """Pull team_id / team_alias from the standard logging metadata in kwargs and stamp them onto span."""
+ std_log = kwargs.get("standard_logging_object")
+ md: dict = {}
+ if isinstance(std_log, dict):
+ md = std_log.get("metadata") or {}
+ elif std_log is not None:
+ md = getattr(std_log, "metadata", None) or {}
+ self._set_team_attributes_on_span(
+ span=span,
+ team_id=md.get("user_api_key_team_id"),
+ team_alias=md.get("user_api_key_team_alias"),
+ )
+
+ def _set_team_attributes_on_proxy_span_from_kwargs(self, kwargs: dict) -> None:
+ """Stamp team attributes onto the proxy SERVER (root) span so the
+ trace root is filterable by team, not just its children. The root
+ span is created in auth before the team is resolved and is
+ otherwise only closed (never re-attributed) on the success path.
+
+ Guarded to the LiteLLM-created proxy span (by name + recording) so
+ externally provided parent spans are never mutated.
+ """
+ litellm_params = kwargs.get("litellm_params") or {}
+ metadata = litellm_params.get("metadata") or {}
+ proxy_span = metadata.get("litellm_parent_otel_span")
+ if (
+ proxy_span is not None
+ and getattr(proxy_span, "name", None) == LITELLM_PROXY_REQUEST_SPAN_NAME
+ and hasattr(proxy_span, "is_recording")
+ and proxy_span.is_recording()
+ ):
+ self._set_team_attributes_from_kwargs(proxy_span, kwargs)
+
def _record_metrics(self, kwargs, response_obj, start_time, end_time):
duration_s = (end_time - start_time).total_seconds()
params = kwargs.get("litellm_params") or {}
@@ -1107,8 +1248,13 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
"mcp_tool_call_metadata",
"vector_store_request_metadata",
]:
- if md.get(key) is not None:
- common_attrs[f"metadata.{key}"] = str(md[key])
+ value = md.get(key)
+ if value is None:
+ continue
+ if isinstance(value, (dict, list)):
+ common_attrs[f"metadata.{key}"] = safe_dumps(value)
+ else:
+ common_attrs[f"metadata.{key}"] = str(value)
# get hidden params
hidden_params = getattr(std_log, "hidden_params", None) or (std_log or {}).get(
@@ -1526,12 +1672,45 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
"masked_entity_count", safe_dumps(masked_entity_count)
)
+ guardrail_response = guardrail_information.get("guardrail_response")
+ if guardrail_response is not None:
+ guardrail_span.set_attribute(
+ "guardrail_response", safe_dumps(guardrail_response)
+ )
+
+ # Surface guardrail_status (success / guardrail_intervened /
+ # guardrail_failed_to_respond / not_run) as a top-level span
+ # attribute so trace backends can filter on it without parsing
+ # guardrail_response.
self.safe_set_attribute(
span=guardrail_span,
- key="guardrail_response",
- value=guardrail_information.get("guardrail_response"),
+ key="guardrail_status",
+ value=guardrail_information.get("guardrail_status"),
)
+ # Provider's raw top-level action (e.g. Bedrock's
+ # ``GUARDRAIL_INTERVENED`` / ``NONE``). Populated by the provider
+ # hook onto StandardLoggingGuardrailInformation so this integration
+ # stays provider-agnostic — we only read a normalised string.
+ guardrail_action = guardrail_information.get("guardrail_action")
+ if guardrail_action:
+ guardrail_span.set_attribute("guardrail_action", guardrail_action)
+
+ # The provider hook (e.g. Bedrock) extracts violation_categories
+ # from the raw response BEFORE redaction and stamps them onto
+ # StandardLoggingGuardrailInformation. Surfacing them here as a
+ # queryable attribute lets dashboards group by violation category
+ # without parsing the redacted guardrail_response blob.
+ violation_categories = guardrail_information.get("violation_categories")
+ if violation_categories:
+ # OTel sequence attributes must be homogeneous primitives;
+ # serialise to JSON once so set_attribute never coerces.
+ guardrail_span.set_attribute(
+ "guardrail_violation_categories", safe_dumps(violation_categories)
+ )
+
+ self._set_team_attributes_from_kwargs(guardrail_span, kwargs)
+
guardrail_span.end(end_time=self._to_ns(end_time_datetime))
def _handle_failure(self, kwargs, response_obj, start_time, end_time):
@@ -2875,6 +3054,11 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
management_endpoint_span.set_status(Status(StatusCode.OK))
management_endpoint_span.end(end_time=_end_time_ns)
+ # The management wrapper has no other hook that closes the SERVER span.
+ self.set_response_status_code_attribute(parent_otel_span, 200)
+ parent_otel_span.set_status(Status(StatusCode.OK))
+ parent_otel_span.end(end_time=_end_time_ns)
+
async def async_management_endpoint_failure_hook(
self,
logging_payload: ManagementEndpointLoggingPayload,
@@ -2925,6 +3109,24 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
management_endpoint_span.set_status(Status(StatusCode.ERROR))
management_endpoint_span.end(end_time=_end_time_ns)
+ # The management wrapper has no other hook that closes the SERVER span.
+ from litellm.litellm_core_utils.litellm_logging import (
+ StandardLoggingPayloadSetup,
+ )
+
+ error_information = StandardLoggingPayloadSetup.get_error_information(
+ original_exception=_exception,
+ )
+ parent_otel_span.set_status(Status(StatusCode.ERROR))
+ self._record_exception_on_span(
+ span=parent_otel_span,
+ kwargs={
+ "exception": _exception,
+ "standard_logging_object": {"error_information": error_information},
+ },
+ )
+ parent_otel_span.end(end_time=_end_time_ns)
+
def create_litellm_proxy_request_started_span(
self,
start_time: datetime,
diff --git a/litellm/integrations/opik/utils.py b/litellm/integrations/opik/utils.py
index b0ab5991c91..43577505c11 100644
--- a/litellm/integrations/opik/utils.py
+++ b/litellm/integrations/opik/utils.py
@@ -105,7 +105,7 @@ def _remove_nulls(x: Dict[str, Any]) -> Dict[str, Any]:
def get_traces_and_spans_from_payload(
- payload: List[Dict[str, Any]]
+ payload: List[Dict[str, Any]],
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""
Separate traces and spans from payload.
diff --git a/litellm/integrations/prometheus.py b/litellm/integrations/prometheus.py
index 30af0dcb8ed..5f052842122 100644
--- a/litellm/integrations/prometheus.py
+++ b/litellm/integrations/prometheus.py
@@ -166,6 +166,53 @@ class PrometheusLogger(CustomLogger):
labelnames=self.get_labels_for_metric("litellm_output_tokens_metric"),
)
+ # Token-type detail metrics. These break out cached, cache-creation,
+ # audio and reasoning tokens that providers report inside
+ # prompt_tokens_details / completion_tokens_details on the usage
+ # object. They are sparse (only incremented when the provider
+ # reports a non-zero value) and are additive to the existing
+ # input/output token totals — no breaking change for existing
+ # dashboards built on the totals.
+ self.litellm_input_cached_tokens_metric = self._counter_factory(
+ "litellm_input_cached_tokens_metric",
+ "Provider-side cached input tokens (e.g. OpenAI prompt_tokens_details.cached_tokens, Anthropic cache_read_input_tokens)",
+ labelnames=self.get_labels_for_metric(
+ "litellm_input_cached_tokens_metric"
+ ),
+ )
+
+ self.litellm_input_cache_creation_tokens_metric = self._counter_factory(
+ "litellm_input_cache_creation_tokens_metric",
+ "Provider-side input tokens written to prompt cache (e.g. Anthropic cache_creation_input_tokens)",
+ labelnames=self.get_labels_for_metric(
+ "litellm_input_cache_creation_tokens_metric"
+ ),
+ )
+
+ self.litellm_input_audio_tokens_metric = self._counter_factory(
+ "litellm_input_audio_tokens_metric",
+ "Audio input tokens reported in prompt_tokens_details.audio_tokens",
+ labelnames=self.get_labels_for_metric(
+ "litellm_input_audio_tokens_metric"
+ ),
+ )
+
+ self.litellm_output_reasoning_tokens_metric = self._counter_factory(
+ "litellm_output_reasoning_tokens_metric",
+ "Reasoning tokens reported in completion_tokens_details.reasoning_tokens",
+ labelnames=self.get_labels_for_metric(
+ "litellm_output_reasoning_tokens_metric"
+ ),
+ )
+
+ self.litellm_output_audio_tokens_metric = self._counter_factory(
+ "litellm_output_audio_tokens_metric",
+ "Audio output tokens reported in completion_tokens_details.audio_tokens",
+ labelnames=self.get_labels_for_metric(
+ "litellm_output_audio_tokens_metric"
+ ),
+ )
+
# Remaining Budget for Team
self.litellm_remaining_team_budget_metric = self._gauge_factory(
"litellm_remaining_team_budget_metric",
@@ -1301,6 +1348,101 @@ class PrometheusLogger(CustomLogger):
amount=float(standard_logging_payload["completion_tokens"]),
)
+ # Token-type detail metrics — sparse, only emitted when the provider
+ # reports a non-zero value in usage.prompt_tokens_details /
+ # usage.completion_tokens_details.
+ self._increment_token_detail_metrics(
+ standard_logging_payload=standard_logging_payload,
+ enum_values=enum_values,
+ label_context=label_context,
+ )
+
+ def _increment_token_detail_metrics(
+ self,
+ standard_logging_payload: StandardLoggingPayload,
+ enum_values: UserAPIKeyLabelValues,
+ label_context: Optional[PrometheusLabelFactoryContext] = None,
+ ) -> None:
+ """
+ Increment per-token-type counters from the Usage object that providers
+ attach to the request. The Usage dict is plumbed onto
+ ``standard_logging_payload["metadata"]["usage_object"]`` by
+ ``get_standard_logging_object_payload``.
+
+ Each counter is only incremented when the underlying value is > 0, so
+ scrape output stays sparse for providers that don't report these
+ details (most non-OpenAI/Anthropic models).
+ """
+ metadata = standard_logging_payload.get("metadata") or {}
+ usage_object = (
+ metadata.get("usage_object") if isinstance(metadata, dict) else None
+ )
+ if not isinstance(usage_object, dict):
+ return
+
+ prompt_details = usage_object.get("prompt_tokens_details") or {}
+ completion_details = usage_object.get("completion_tokens_details") or {}
+
+ detail_metrics: List[Tuple[Any, DEFINED_PROMETHEUS_METRICS, Any]] = [
+ (
+ self.litellm_input_cached_tokens_metric,
+ "litellm_input_cached_tokens_metric",
+ (
+ prompt_details.get("cached_tokens")
+ if isinstance(prompt_details, dict)
+ else None
+ ),
+ ),
+ (
+ self.litellm_input_cache_creation_tokens_metric,
+ "litellm_input_cache_creation_tokens_metric",
+ (
+ prompt_details.get("cache_creation_tokens")
+ if isinstance(prompt_details, dict)
+ else None
+ ),
+ ),
+ (
+ self.litellm_input_audio_tokens_metric,
+ "litellm_input_audio_tokens_metric",
+ (
+ prompt_details.get("audio_tokens")
+ if isinstance(prompt_details, dict)
+ else None
+ ),
+ ),
+ (
+ self.litellm_output_reasoning_tokens_metric,
+ "litellm_output_reasoning_tokens_metric",
+ (
+ completion_details.get("reasoning_tokens")
+ if isinstance(completion_details, dict)
+ else None
+ ),
+ ),
+ (
+ self.litellm_output_audio_tokens_metric,
+ "litellm_output_audio_tokens_metric",
+ (
+ completion_details.get("audio_tokens")
+ if isinstance(completion_details, dict)
+ else None
+ ),
+ ),
+ ]
+
+ for counter, metric_name, value in detail_metrics:
+ if not isinstance(value, (int, float)) or value <= 0:
+ continue
+ PrometheusLogger._inc_labeled_counter(
+ self,
+ counter,
+ metric_name,
+ enum_values,
+ label_context=label_context,
+ amount=float(value),
+ )
+
def _increment_cache_metrics(
self,
standard_logging_payload: StandardLoggingPayload,
@@ -3540,6 +3682,10 @@ class PrometheusLogger(CustomLogger):
user_object.budget_reset_at = user_info.budget_reset_at
if user_object.max_budget is None and user_info.max_budget is not None:
user_object.max_budget = user_info.max_budget
+ if user_info.user_email is not None:
+ user_object.user_email = user_info.user_email
+ if user_info.user_alias is not None:
+ user_object.user_alias = user_info.user_alias
return user_object
@@ -3556,6 +3702,8 @@ class PrometheusLogger(CustomLogger):
"""
enum_values = UserAPIKeyLabelValues(
user=user.user_id,
+ user_email=user.user_email or "",
+ user_alias=user.user_alias or "",
)
_labels = prometheus_label_factory(
diff --git a/litellm/integrations/rubrik.py b/litellm/integrations/rubrik.py
new file mode 100644
index 00000000000..af396ecdc73
--- /dev/null
+++ b/litellm/integrations/rubrik.py
@@ -0,0 +1,605 @@
+"""Rubrik LiteLLM Plugin for tool blocking and batch logging."""
+
+import asyncio
+import os
+import random
+import time
+import urllib.parse
+import uuid
+from collections import Counter
+from typing import TYPE_CHECKING, Any, Literal, Optional
+
+import httpx
+from litellm._logging import verbose_logger
+from litellm.integrations.custom_batch_logger import CustomBatchLogger
+from litellm.integrations.custom_guardrail import (
+ CustomGuardrail,
+ ModifyResponseException,
+)
+from litellm.litellm_core_utils.core_helpers import safe_deep_copy
+from litellm.llms.custom_httpx.http_handler import (
+ get_async_httpx_client,
+ httpxSpecialProvider,
+)
+from litellm.types.guardrails import GuardrailEventHooks
+from litellm.types.utils import (
+ ChatCompletionMessageToolCall,
+ Function,
+ GenericGuardrailAPIInputs,
+ StandardLoggingPayload,
+)
+
+if TYPE_CHECKING:
+ from litellm.litellm_core_utils.litellm_logging import (
+ Logging as LiteLLMLoggingObj,
+ )
+
+_ENDPOINT_ANTHROPIC_MESSAGES = "/v1/messages"
+_WEBHOOK_PATH_TOOL_BLOCKING = "/v1/after_completion/openai/v1"
+_WEBHOOK_PATH_LOGGING_BATCH = "/v1/litellm/batch"
+_MAX_QUEUE_SIZE = 10_000
+_DROP_WARNING_INTERVAL_SECONDS = 60.0
+
+
+class _MalformedToolBlockingResponseError(Exception):
+ """Raised when the tool blocking service returns a structurally invalid
+ response (e.g. empty ``choices``).
+
+ Distinct from transient network/HTTP errors so callers can surface a
+ louder, misconfiguration-style log instead of treating it as a routine
+ fail-open.
+ """
+
+
+class RubrikLogger(CustomGuardrail, CustomBatchLogger):
+ def __init__(
+ self,
+ api_key: str | None = None,
+ api_base: str | None = None,
+ **kwargs,
+ ):
+ self.flush_lock = asyncio.Lock()
+ kwargs.setdefault("guardrail_name", "rubrik")
+ # `initialize_guardrail` always passes these kwargs explicitly, with
+ # value `None` when the user omits `mode` / `default_on` from the
+ # guardrail config. Coerce None (omitted) to the desired default
+ # while preserving any explicit value the caller did set --
+ # in particular `default_on=False` if the user wants the guardrail
+ # off by default.
+ kwargs["event_hook"] = kwargs.get("event_hook") or GuardrailEventHooks.post_call
+ if kwargs.get("default_on") is None:
+ kwargs["default_on"] = True
+ super().__init__(
+ flush_lock=self.flush_lock,
+ **kwargs,
+ )
+
+ verbose_logger.debug("initializing rubrik logger")
+
+ self.sampling_rate = 1.0
+ rbrk_sampling_rate = os.getenv("RUBRIK_SAMPLING_RATE")
+ if rbrk_sampling_rate is not None:
+ try:
+ parsed_rate = float(rbrk_sampling_rate.strip())
+ self.sampling_rate = max(0.0, min(1.0, parsed_rate))
+ if parsed_rate != self.sampling_rate:
+ verbose_logger.warning(
+ f"RUBRIK_SAMPLING_RATE={parsed_rate} clamped to "
+ f"{self.sampling_rate}"
+ )
+ except ValueError:
+ verbose_logger.warning(
+ f"Invalid RUBRIK_SAMPLING_RATE: {rbrk_sampling_rate!r}, using 1.0"
+ )
+
+ self.key = api_key or os.getenv("RUBRIK_API_KEY")
+ if not self.key:
+ verbose_logger.warning(
+ "Rubrik: No API key configured. Requests will be unauthenticated."
+ )
+ _batch_size = os.getenv("RUBRIK_BATCH_SIZE")
+
+ if _batch_size:
+ try:
+ self.batch_size = int(_batch_size)
+ except ValueError:
+ verbose_logger.warning(
+ f"Invalid RUBRIK_BATCH_SIZE: {_batch_size!r}, using default"
+ )
+
+ # Cap the in-memory retry queue so a Rubrik webhook outage cannot let
+ # authenticated traffic accumulate prompt/response payloads until the
+ # proxy runs out of memory. Once the cap is reached, oldest events are
+ # dropped to make room for fresh ones (drop-oldest backpressure).
+ self.max_queue_size = _MAX_QUEUE_SIZE
+ self._dropped_since_warning = 0
+ self._last_drop_warning_time = 0.0
+
+ _webhook_url = api_base or os.getenv("RUBRIK_WEBHOOK_URL")
+
+ if _webhook_url is None:
+ raise ValueError(
+ "Rubrik webhook URL not configured. "
+ "Set RUBRIK_WEBHOOK_URL or pass api_base."
+ )
+
+ _webhook_url = _webhook_url.rstrip("/").removesuffix("/v1")
+ self.tool_blocking_endpoint = f"{_webhook_url}{_WEBHOOK_PATH_TOOL_BLOCKING}"
+ self.logging_endpoint = f"{_webhook_url}{_WEBHOOK_PATH_LOGGING_BATCH}"
+
+ self.async_httpx_client = get_async_httpx_client(
+ llm_provider=httpxSpecialProvider.LoggingCallback
+ )
+
+ self.tool_blocking_client = get_async_httpx_client(
+ llm_provider=httpxSpecialProvider.LoggingCallback,
+ params={"timeout": httpx.Timeout(5.0, connect=2.0)},
+ )
+
+ self._headers: dict[str, str] = {"Content-Type": "application/json"}
+ if self.key:
+ self._headers["Authorization"] = f"Bearer {self.key}"
+
+ # Periodic flush is started lazily on the first log event so that
+ # low-traffic deployments still get their batches drained even when the
+ # logger is instantiated outside a running event loop (sync init).
+ self._flush_task: Optional[asyncio.Task[Any]] = (
+ self._start_periodic_flush_task()
+ )
+
+ def _start_periodic_flush_task(self) -> Optional[asyncio.Task[Any]]:
+ """Start the periodic flush task only when an event loop is already running."""
+ try:
+ loop = asyncio.get_running_loop()
+ except RuntimeError:
+ verbose_logger.debug(
+ "Rubrik logger init: no running event loop, "
+ "periodic flush will start on first log event."
+ )
+ return None
+ return loop.create_task(self.periodic_flush())
+
+ def _ensure_periodic_flush_task(self) -> None:
+ # Synchronous helper: in asyncio's cooperative model there is no await
+ # between the check and assignment, so two callers cannot race here.
+ if self._flush_task is None or self._flush_task.done():
+ self._flush_task = self._start_periodic_flush_task()
+
+ async def aclose(self):
+ """Close the dedicated HTTP clients used by this logger."""
+ # Cancel the periodic flush task before closing the HTTP clients so
+ # the loop doesn't wake up and try to POST via a closed client.
+ if self._flush_task is not None and not self._flush_task.done():
+ self._flush_task.cancel()
+ try:
+ await self._flush_task
+ except (asyncio.CancelledError, Exception):
+ pass
+ self._flush_task = None
+ await self.tool_blocking_client.close()
+ await self.async_httpx_client.close()
+
+ # -- Guardrail hook --------------------------------------------------------
+
+ async def apply_guardrail(
+ self,
+ inputs: GenericGuardrailAPIInputs,
+ request_data: dict,
+ input_type: Literal["request", "response"],
+ logging_obj: Optional["LiteLLMLoggingObj"] = None,
+ ) -> GenericGuardrailAPIInputs:
+ """Validate tool calls against the blocking service (fail-open)."""
+ if input_type != "response":
+ return inputs
+
+ tool_calls = inputs.get("tool_calls")
+ if not tool_calls:
+ return inputs
+
+ try:
+ return await self._check_tool_calls(
+ inputs, tool_calls, request_data, logging_obj
+ )
+ except ModifyResponseException:
+ raise
+ except _MalformedToolBlockingResponseError as e:
+ # Distinct from transient errors: the service responded but the
+ # payload was structurally invalid, which usually indicates a
+ # misconfigured webhook or a breaking change in its response
+ # format. Log loudly so operators notice their tool-blocking
+ # policy is not actually being enforced.
+ verbose_logger.critical(
+ "Tool blocking service returned a malformed response: %s. "
+ "Tool calls are NOT being checked -- verify the webhook "
+ "configuration. Returning original response unchanged.",
+ e,
+ exc_info=True,
+ )
+ return inputs
+ except Exception as e:
+ verbose_logger.error(
+ f"Tool blocking hook failed: {e}. "
+ "Returning original response unchanged.",
+ exc_info=True,
+ )
+ return inputs
+
+ async def _check_tool_calls(
+ self,
+ inputs: GenericGuardrailAPIInputs,
+ tool_calls: Any,
+ request_data: dict,
+ logging_obj: Optional["LiteLLMLoggingObj"],
+ ) -> GenericGuardrailAPIInputs:
+ """Send tool calls to blocking service, raise if any are blocked."""
+ message_tool_calls = self._normalize_tool_calls(tool_calls)
+
+ call_details = (
+ getattr(logging_obj, "model_call_details", {}) if logging_obj else {}
+ )
+ response = request_data.get("response")
+ request_id = getattr(response, "id", None) if response else None
+ if logging_obj and not call_details:
+ verbose_logger.warning(
+ "Rubrik: logging_obj present but model_call_details is empty "
+ "-- request context will be missing"
+ )
+
+ response_data = self._build_tool_call_payload(message_tool_calls, request_id)
+ req_data = self._extract_request_data(call_details)
+
+ service_response = await self._post_to_tool_blocking_service(
+ response_data, req_data
+ )
+ blocked_explanation = self._extract_blocked_tools(
+ service_response, message_tool_calls
+ )
+
+ if blocked_explanation is not None:
+ model = self._resolve_model(request_data, call_details)
+ raise ModifyResponseException(
+ message=blocked_explanation,
+ model=model,
+ request_data=request_data,
+ guardrail_name=self.guardrail_name,
+ )
+
+ return inputs
+
+ @staticmethod
+ def _normalize_tool_calls(tool_calls: Any) -> list[ChatCompletionMessageToolCall]:
+ """Convert tool_calls from inputs to ChatCompletionMessageToolCall objects."""
+ result = []
+ for tc in tool_calls:
+ if isinstance(tc, ChatCompletionMessageToolCall):
+ result.append(tc)
+ elif isinstance(tc, dict):
+ func = tc.get("function", {})
+ result.append(
+ ChatCompletionMessageToolCall(
+ id=tc.get("id", ""),
+ type=tc.get("type", "function"),
+ function=Function(
+ name=func.get("name", ""),
+ arguments=func.get("arguments", ""),
+ ),
+ )
+ )
+ elif hasattr(tc, "id") and hasattr(tc, "function"):
+ result.append(
+ ChatCompletionMessageToolCall(
+ id=tc.id or "",
+ type=getattr(tc, "type", None) or "function",
+ function=tc.function,
+ )
+ )
+ else:
+ raise TypeError(
+ f"Cannot normalize tool_call of type {type(tc).__name__}"
+ )
+ return result
+
+ @staticmethod
+ def _build_tool_call_payload(
+ tool_calls: list[ChatCompletionMessageToolCall],
+ request_id: str | None,
+ ) -> dict[str, Any]:
+ """Build a full OpenAI ChatCompletion-format dict for the blocking service."""
+ return {
+ "id": request_id or f"chatcmpl-{uuid.uuid4()}",
+ "object": "chat.completion",
+ "created": int(time.time()),
+ "model": "",
+ "choices": [
+ {
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": None,
+ "tool_calls": [
+ tc.model_dump(exclude_none=True) for tc in tool_calls
+ ],
+ },
+ "finish_reason": "tool_calls",
+ }
+ ],
+ }
+
+ @staticmethod
+ def _extract_request_data(call_details: dict[str, Any]) -> dict[str, Any]:
+ """Extract original request data from model_call_details."""
+ if not call_details:
+ return {}
+ litellm_params = call_details.get("litellm_params", {}) or {}
+ return {
+ "messages": call_details.get("messages"),
+ "model": call_details.get("model"),
+ "proxy_server_request": RubrikLogger._sanitize_proxy_server_request(
+ litellm_params.get("proxy_server_request")
+ ),
+ }
+
+ @staticmethod
+ def _sanitize_proxy_server_request(proxy_server_request: Any) -> Any:
+ """Allowlist only routing fields (``url``, ``method``) when forwarding
+ ``proxy_server_request`` to the external Rubrik webhook, dropping
+ inbound ``headers`` (Authorization, Cookie, x-api-key, ...) and the raw
+ request ``body`` so proxy credentials are not exfiltrated."""
+ if not isinstance(proxy_server_request, dict):
+ return proxy_server_request
+ return {
+ key: proxy_server_request[key]
+ for key in ("url", "method")
+ if key in proxy_server_request
+ }
+
+ @staticmethod
+ def _resolve_model(
+ request_data: dict[str, Any], call_details: dict[str, Any]
+ ) -> str:
+ """Get the model name for the ModifyResponseException."""
+ response = request_data.get("response")
+ if response and hasattr(response, "model"):
+ return response.model or "unknown"
+ return call_details.get("model", "unknown")
+
+ # -- Logging hooks ---------------------------------------------------------
+
+ async def _prepare_log_payload(
+ self, kwargs: dict, event_type: str
+ ) -> StandardLoggingPayload | None:
+ """Shared logic for success and failure logging."""
+ if random.random() > self.sampling_rate:
+ verbose_logger.debug(
+ f"Skipping Rubrik {event_type} logging "
+ f"(sampling_rate={self.sampling_rate})"
+ )
+ return None
+
+ # Deep-copy so mutations don't affect other callbacks sharing this object
+ standard_logging_payload: StandardLoggingPayload = safe_deep_copy(
+ kwargs["standard_logging_object"]
+ )
+
+ # For Anthropic /v1/messages requests, LiteLLM creates a separate
+ # ModelResponse (with a generated chatcmpl-* id) for logging, which
+ # differs from the original Anthropic msg-* id on the response dict.
+ # Normalize to litellm_call_id so that the logging and tool-blocking
+ # endpoints see the same request identifier.
+ litellm_params = kwargs.get("litellm_params", {}) or {}
+ proxy_request = litellm_params.get("proxy_server_request", {}) or {}
+ url_path = urllib.parse.urlparse(proxy_request.get("url", "")).path
+ if url_path.endswith(_ENDPOINT_ANTHROPIC_MESSAGES):
+ _litellm_call_id = kwargs.get("litellm_call_id")
+ if _litellm_call_id:
+ standard_logging_payload["id"] = _litellm_call_id # type: ignore[literal-required]
+
+ if "system" in kwargs:
+ system_prompt_msg_list = kwargs["system"]
+ try:
+ if system_prompt_msg_list:
+ system_scaffold = {
+ "role": "system",
+ "content": system_prompt_msg_list,
+ }
+ if isinstance(standard_logging_payload["messages"], list):
+ standard_logging_payload["messages"].insert(0, system_scaffold)
+ elif isinstance(standard_logging_payload["messages"], (dict, str)):
+ standard_logging_payload["messages"] = [
+ system_scaffold,
+ standard_logging_payload["messages"],
+ ]
+ except Exception as e:
+ verbose_logger.warning(
+ f"Rubrik: failed to prepend system prompt: {e}",
+ exc_info=True,
+ )
+
+ return standard_logging_payload
+
+ async def _enqueue_log_event(self, kwargs: dict, event_type: str):
+ try:
+ self._ensure_periodic_flush_task()
+ payload = await self._prepare_log_payload(kwargs, event_type)
+ if payload is None:
+ return
+
+ self.log_queue.append(payload)
+ self._enforce_max_queue_size()
+
+ if len(self.log_queue) >= self.batch_size:
+ await self.flush_queue()
+ except Exception as e:
+ verbose_logger.error(
+ f"Rubrik {event_type} logging hook failed: {e}. "
+ "Skipping logging for this event.",
+ exc_info=True,
+ )
+
+ def _enforce_max_queue_size(self) -> None:
+ overflow = len(self.log_queue) - self.max_queue_size
+ if overflow <= 0:
+ return
+ del self.log_queue[:overflow]
+ self._dropped_since_warning += overflow
+ now = time.time()
+ if now - self._last_drop_warning_time >= _DROP_WARNING_INTERVAL_SECONDS:
+ verbose_logger.warning(
+ "Rubrik: log queue exceeded max_queue_size=%s; dropped %s "
+ "oldest events since the last warning. The Rubrik webhook may "
+ "be unhealthy or undersized for current traffic.",
+ self.max_queue_size,
+ self._dropped_since_warning,
+ )
+ self._dropped_since_warning = 0
+ self._last_drop_warning_time = now
+
+ async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
+ await self._enqueue_log_event(kwargs, "success")
+
+ async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time):
+ await self._enqueue_log_event(kwargs, "failure")
+
+ # -- Batch logging ---------------------------------------------------------
+
+ async def _log_batch_to_rubrik(self, data):
+ # NOTE: this method intentionally re-raises on failure so the parent
+ # CustomBatchLogger.flush_queue keeps the unsent events in the queue
+ # for the next flush attempt instead of silently dropping them.
+ try:
+ response = await self.async_httpx_client.post(
+ url=self.logging_endpoint,
+ json=data,
+ headers=self._headers,
+ )
+ response.raise_for_status()
+ except httpx.HTTPStatusError as e:
+ verbose_logger.exception(
+ f"Rubrik HTTP Error: {e.response.status_code} - {e.response.text}"
+ )
+ raise
+ except Exception:
+ verbose_logger.exception("Rubrik Layer Error")
+ raise
+
+ async def async_send_batch(self):
+ """Handles sending batches of responses to Rubrik.
+
+ Note: the canonical flush path is :meth:`flush_queue`, which takes a
+ single snapshot used for both sending and queue draining. This method
+ is kept for direct callers / tests; it intentionally does NOT remove
+ events from the queue.
+ """
+ if not self.log_queue:
+ return
+
+ log_queue_snapshot = list(self.log_queue)
+ verbose_logger.debug(
+ "Rubrik: Flushing batch of %s events", len(log_queue_snapshot)
+ )
+ await self._log_batch_to_rubrik(
+ data=log_queue_snapshot,
+ )
+
+ async def flush_queue(self):
+ """Snapshot, send, and drain in one consistent step.
+
+ Overrides the base implementation so the same snapshot drives both
+ the HTTP send and the queue truncation. This avoids the subtle
+ coupling where the base class captures `len(self.log_queue)`
+ separately from the snapshot taken inside `async_send_batch`,
+ which could otherwise drift in a future refactor and cause
+ duplicate deliveries to Rubrik.
+ """
+ if self.flush_lock is None:
+ return
+
+ async with self.flush_lock:
+ if not self.log_queue:
+ return
+ snapshot = list(self.log_queue)
+ verbose_logger.debug("Rubrik: Flushing batch of %s events", len(snapshot))
+ try:
+ await self._log_batch_to_rubrik(data=snapshot)
+ except Exception:
+ # Already logged with traceback inside _log_batch_to_rubrik.
+ # Preserve the in-flight events for retry on the next flush.
+ return
+ del self.log_queue[: len(snapshot)]
+ self.last_flush_time = time.time()
+
+ # -- Tool blocking service -------------------------------------------------
+
+ async def _post_to_tool_blocking_service(
+ self,
+ response_data: dict[str, Any],
+ request_data: dict[str, Any],
+ ) -> dict[str, Any]:
+ """Post a payload to the tool blocking service and return the response.
+
+ Args:
+ response_data: The OpenAI-formatted response payload to send.
+ request_data: Original LLM request data to include alongside
+ the response for additional context. Empty dict if unavailable.
+
+ Raises:
+ Exception: If the service is unavailable or returns an error.
+ """
+ envelope = {
+ "request": request_data,
+ "response": response_data,
+ }
+ verbose_logger.debug(
+ f"Sending request to tool blocking service: "
+ f"{self.tool_blocking_endpoint}"
+ )
+ http_response = await self.tool_blocking_client.post(
+ self.tool_blocking_endpoint,
+ json=envelope,
+ headers=self._headers,
+ )
+ http_response.raise_for_status()
+ result: dict[str, Any] = http_response.json()
+ return result
+
+ @staticmethod
+ def _extract_blocked_tools(
+ service_response: dict[str, Any],
+ all_tool_calls: list[ChatCompletionMessageToolCall],
+ ) -> Optional[str]:
+ """Return the blocking explanation if any tool calls were blocked.
+
+ Compares the service response (which contains only allowed tools) against
+ the full set of tool calls. Returns ``None`` if all tools are allowed, or
+ the explanation string (prefixed with newlines) otherwise.
+
+ Expects service_response in OpenAI chat completion format:
+ {"choices": [{"message": {"tool_calls": [...], "content": "..."}}]}
+ """
+ choices = service_response.get("choices", [])
+ if not choices:
+ raise _MalformedToolBlockingResponseError(
+ "Tool blocking service returned empty response"
+ )
+
+ message = choices[0].get("message", {})
+ returned_tool_calls = message.get("tool_calls") or []
+ blocking_explanation = message.get("content", "")
+
+ allowed_id_counts: Counter = Counter(
+ tc["id"]
+ for tc in returned_tool_calls
+ if isinstance(tc, dict) and tc.get("id")
+ )
+ required_id_counts: Counter = Counter(tc.id for tc in all_tool_calls if tc.id)
+
+ all_allowed = len(returned_tool_calls) >= len(all_tool_calls) and all(
+ allowed_id_counts.get(tc_id, 0) >= count
+ for tc_id, count in required_id_counts.items()
+ )
+
+ if all_allowed:
+ return None
+
+ explanation = blocking_explanation or "Tool call blocked by policy."
+ return f"\n\n{explanation}"
diff --git a/litellm/integrations/s3_v2.py b/litellm/integrations/s3_v2.py
index 332e84dd07d..4ed8a809a13 100644
--- a/litellm/integrations/s3_v2.py
+++ b/litellm/integrations/s3_v2.py
@@ -1,8 +1,8 @@
"""
s3 Bucket Logging Integration
-async_log_success_event: Processes the event, stores it in memory for DEFAULT_S3_FLUSH_INTERVAL_SECONDS seconds or until DEFAULT_S3_BATCH_SIZE and then flushes to s3
-async_log_failure_event: Processes the event, stores it in memory for DEFAULT_S3_FLUSH_INTERVAL_SECONDS seconds or until DEFAULT_S3_BATCH_SIZE and then flushes to s3
+async_log_success_event: Processes the event, stores it in memory for DEFAULT_S3_FLUSH_INTERVAL_SECONDS seconds or until DEFAULT_S3_BATCH_SIZE and then flushes to s3
+async_log_failure_event: Processes the event, stores it in memory for DEFAULT_S3_FLUSH_INTERVAL_SECONDS seconds or until DEFAULT_S3_BATCH_SIZE and then flushes to s3
NOTE 1: S3 does not provide a BATCH PUT API endpoint, so we create tasks to upload each element individually
"""
diff --git a/litellm/interactions/__init__.py b/litellm/interactions/__init__.py
index e1125b649a6..ed01462cba6 100644
--- a/litellm/interactions/__init__.py
+++ b/litellm/interactions/__init__.py
@@ -5,31 +5,40 @@ This module provides SDK methods for Google's Interactions API.
Usage:
import litellm
-
+
# Create an interaction with a model
response = litellm.interactions.create(
model="gemini-2.5-flash",
input="Hello, how are you?"
)
-
+
# Create an interaction with an agent
response = litellm.interactions.create(
agent="deep-research-pro-preview-12-2025",
input="Research the current state of cancer research"
)
-
+
# Async version
response = await litellm.interactions.acreate(...)
-
+
# Get an interaction
response = litellm.interactions.get(interaction_id="...")
-
+
# Delete an interaction
result = litellm.interactions.delete(interaction_id="...")
-
+
# Cancel an interaction
result = litellm.interactions.cancel(interaction_id="...")
+ # Create a managed agent on the provider side
+ result = litellm.interactions.agents.create(
+ name="waverunner",
+ custom_llm_provider="gemini",
+ api_key="...",
+ base_agent="gemini-2.5-flash",
+ instructions="You are a helpful assistant.",
+ )
+
Methods:
- create(): Sync create interaction
- acreate(): Async create interaction
@@ -39,8 +48,12 @@ Methods:
- adelete(): Async delete interaction
- cancel(): Sync cancel interaction
- acancel(): Async cancel interaction
+
+Sub-modules:
+- agents: Provider-side agent creation (litellm.interactions.agents.create)
"""
+from litellm.interactions import agents
from litellm.interactions.main import (
acancel,
acreate,
@@ -65,4 +78,6 @@ __all__ = [
# Cancel
"cancel",
"acancel",
+ # Sub-modules
+ "agents",
]
diff --git a/litellm/interactions/agents/__init__.py b/litellm/interactions/agents/__init__.py
new file mode 100644
index 00000000000..711a54fdcbb
--- /dev/null
+++ b/litellm/interactions/agents/__init__.py
@@ -0,0 +1,39 @@
+"""
+litellm.interactions.agents
+
+Full CRUD SDK for provider-side managed agents (e.g. Gemini v1beta/agents).
+
+ litellm.interactions.agents.create(name=..., ...)
+ litellm.interactions.agents.list(api_key=...)
+ litellm.interactions.agents.get(name=..., ...)
+ litellm.interactions.agents.delete(name=..., ...)
+ litellm.interactions.agents.list_versions(name=..., ...)
+
+Async counterparts: acreate, alist, aget, adelete, alist_versions
+"""
+
+from litellm.interactions.agents.main import (
+ acreate,
+ adelete,
+ aget,
+ alist,
+ alist_versions,
+ create,
+ delete,
+ get,
+ list,
+ list_versions,
+)
+
+__all__ = [
+ "create",
+ "acreate",
+ "list",
+ "alist",
+ "get",
+ "aget",
+ "delete",
+ "adelete",
+ "list_versions",
+ "alist_versions",
+]
diff --git a/litellm/interactions/agents/http_handler.py b/litellm/interactions/agents/http_handler.py
new file mode 100644
index 00000000000..d45ca6f4346
--- /dev/null
+++ b/litellm/interactions/agents/http_handler.py
@@ -0,0 +1,478 @@
+"""
+HTTP handler for the Agents API.
+
+Extends InteractionsHTTPHandler so that the shared HTTP infrastructure
+(_handle_error, _sync_client, _async_client) is reused rather than
+duplicated. BaseAgentsAPIConfig stays as pure transform code.
+"""
+
+from typing import Any, Coroutine, Dict, Optional, Union
+
+import httpx
+
+from litellm.constants import request_timeout
+from litellm.interactions.http_handler import InteractionsHTTPHandler
+from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
+from litellm.llms.base_llm.agents.transformation import BaseAgentsAPIConfig
+from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
+from litellm.types.agents import (
+ AgentCreateResponse,
+ AgentDeleteResult,
+ AgentListResponse,
+ AgentVersionsResponse,
+)
+from litellm.types.router import GenericLiteLLMParams
+
+
+class AgentsHTTPHandler(InteractionsHTTPHandler):
+ """HTTP handler for Agents API CRUD requests."""
+
+ # ------------------------------------------------------------------ #
+ # CREATE #
+ # ------------------------------------------------------------------ #
+
+ def create_agent(
+ self,
+ agents_api_config: BaseAgentsAPIConfig,
+ name: str,
+ litellm_params: GenericLiteLLMParams,
+ logging_obj: LiteLLMLoggingObj,
+ extra_headers: Optional[Dict[str, Any]] = None,
+ extra_body: Optional[Dict[str, Any]] = None,
+ timeout: Optional[Union[float, httpx.Timeout]] = None,
+ client: Optional[HTTPHandler] = None,
+ _is_async: bool = False,
+ ) -> Union[AgentCreateResponse, Coroutine[Any, Any, AgentCreateResponse]]:
+ if _is_async:
+ return self.async_create_agent(
+ agents_api_config=agents_api_config,
+ name=name,
+ litellm_params=litellm_params,
+ logging_obj=logging_obj,
+ extra_headers=extra_headers,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+
+ sync_httpx_client = self._sync_client(litellm_params, client)
+ headers = agents_api_config.validate_environment(
+ headers=extra_headers or {}, litellm_params=dict(litellm_params)
+ )
+ url = agents_api_config.get_complete_url(
+ api_base=litellm_params.get("api_base"),
+ litellm_params=dict(litellm_params),
+ )
+ data = agents_api_config.transform_create_request(
+ name=name, litellm_params=dict(litellm_params)
+ )
+ if extra_body:
+ data.update(extra_body)
+
+ logging_obj.pre_call(
+ input=name,
+ api_key="",
+ additional_args={
+ "complete_input_dict": data,
+ "api_base": url,
+ "headers": headers,
+ },
+ )
+ try:
+ response = sync_httpx_client.post(
+ url=url, headers=headers, json=data, timeout=timeout or request_timeout
+ )
+ except Exception as e:
+ raise self._handle_error(e=e, provider_config=agents_api_config)
+
+ logging_obj.post_call(
+ original_response=response.text,
+ additional_args={"complete_input_dict": data},
+ )
+ return agents_api_config.transform_create_response(
+ raw_response=response, name=name
+ )
+
+ async def async_create_agent(
+ self,
+ agents_api_config: BaseAgentsAPIConfig,
+ name: str,
+ litellm_params: GenericLiteLLMParams,
+ logging_obj: LiteLLMLoggingObj,
+ extra_headers: Optional[Dict[str, Any]] = None,
+ extra_body: Optional[Dict[str, Any]] = None,
+ timeout: Optional[Union[float, httpx.Timeout]] = None,
+ client: Optional[AsyncHTTPHandler] = None,
+ ) -> AgentCreateResponse:
+ async_httpx_client = self._async_client(litellm_params, client)
+ headers = agents_api_config.validate_environment(
+ headers=extra_headers or {}, litellm_params=dict(litellm_params)
+ )
+ url = agents_api_config.get_complete_url(
+ api_base=litellm_params.get("api_base"),
+ litellm_params=dict(litellm_params),
+ )
+ data = agents_api_config.transform_create_request(
+ name=name, litellm_params=dict(litellm_params)
+ )
+ if extra_body:
+ data.update(extra_body)
+
+ logging_obj.pre_call(
+ input=name,
+ api_key="",
+ additional_args={
+ "complete_input_dict": data,
+ "api_base": url,
+ "headers": headers,
+ },
+ )
+ try:
+ response = await async_httpx_client.post(
+ url=url, headers=headers, json=data, timeout=timeout or request_timeout
+ )
+ except Exception as e:
+ raise self._handle_error(e=e, provider_config=agents_api_config)
+
+ logging_obj.post_call(
+ original_response=response.text,
+ additional_args={"complete_input_dict": data},
+ )
+ return agents_api_config.transform_create_response(
+ raw_response=response, name=name
+ )
+
+ # ------------------------------------------------------------------ #
+ # LIST #
+ # ------------------------------------------------------------------ #
+
+ def list_agents(
+ self,
+ agents_api_config: BaseAgentsAPIConfig,
+ litellm_params: GenericLiteLLMParams,
+ logging_obj: LiteLLMLoggingObj,
+ extra_headers: Optional[Dict[str, Any]] = None,
+ timeout: Optional[Union[float, httpx.Timeout]] = None,
+ client: Optional[HTTPHandler] = None,
+ _is_async: bool = False,
+ ) -> Union[AgentListResponse, Coroutine[Any, Any, AgentListResponse]]:
+ if _is_async:
+ return self.async_list_agents(
+ agents_api_config=agents_api_config,
+ litellm_params=litellm_params,
+ logging_obj=logging_obj,
+ extra_headers=extra_headers,
+ timeout=timeout,
+ )
+
+ sync_httpx_client = self._sync_client(litellm_params, client)
+ headers = agents_api_config.validate_environment(
+ headers=extra_headers or {}, litellm_params=dict(litellm_params)
+ )
+ url, params = agents_api_config.transform_list_request(
+ api_base=litellm_params.get("api_base"),
+ litellm_params=dict(litellm_params),
+ )
+ logging_obj.pre_call(
+ input="list_agents",
+ api_key="",
+ additional_args={"api_base": url, "headers": headers},
+ )
+ try:
+ response = sync_httpx_client.get(url=url, headers=headers, params=params)
+ except Exception as e:
+ raise self._handle_error(e=e, provider_config=agents_api_config)
+
+ logging_obj.post_call(original_response=response.text, additional_args={})
+ return agents_api_config.transform_list_response(raw_response=response)
+
+ async def async_list_agents(
+ self,
+ agents_api_config: BaseAgentsAPIConfig,
+ litellm_params: GenericLiteLLMParams,
+ logging_obj: LiteLLMLoggingObj,
+ extra_headers: Optional[Dict[str, Any]] = None,
+ timeout: Optional[Union[float, httpx.Timeout]] = None,
+ client: Optional[AsyncHTTPHandler] = None,
+ ) -> AgentListResponse:
+ async_httpx_client = self._async_client(litellm_params, client)
+ headers = agents_api_config.validate_environment(
+ headers=extra_headers or {}, litellm_params=dict(litellm_params)
+ )
+ url, params = agents_api_config.transform_list_request(
+ api_base=litellm_params.get("api_base"),
+ litellm_params=dict(litellm_params),
+ )
+ logging_obj.pre_call(
+ input="list_agents",
+ api_key="",
+ additional_args={"api_base": url, "headers": headers},
+ )
+ try:
+ response = await async_httpx_client.get(
+ url=url, headers=headers, params=params
+ )
+ except Exception as e:
+ raise self._handle_error(e=e, provider_config=agents_api_config)
+
+ logging_obj.post_call(original_response=response.text, additional_args={})
+ return agents_api_config.transform_list_response(raw_response=response)
+
+ # ------------------------------------------------------------------ #
+ # GET #
+ # ------------------------------------------------------------------ #
+
+ def get_agent(
+ self,
+ agents_api_config: BaseAgentsAPIConfig,
+ name: str,
+ litellm_params: GenericLiteLLMParams,
+ logging_obj: LiteLLMLoggingObj,
+ extra_headers: Optional[Dict[str, Any]] = None,
+ timeout: Optional[Union[float, httpx.Timeout]] = None,
+ client: Optional[HTTPHandler] = None,
+ _is_async: bool = False,
+ ) -> Union[AgentCreateResponse, Coroutine[Any, Any, AgentCreateResponse]]:
+ if _is_async:
+ return self.async_get_agent(
+ agents_api_config=agents_api_config,
+ name=name,
+ litellm_params=litellm_params,
+ logging_obj=logging_obj,
+ extra_headers=extra_headers,
+ timeout=timeout,
+ )
+
+ sync_httpx_client = self._sync_client(litellm_params, client)
+ headers = agents_api_config.validate_environment(
+ headers=extra_headers or {}, litellm_params=dict(litellm_params)
+ )
+ url, params = agents_api_config.transform_get_request(
+ name=name,
+ api_base=litellm_params.get("api_base"),
+ litellm_params=dict(litellm_params),
+ )
+ logging_obj.pre_call(
+ input=name,
+ api_key="",
+ additional_args={"api_base": url, "headers": headers},
+ )
+ try:
+ response = sync_httpx_client.get(url=url, headers=headers, params=params)
+ except Exception as e:
+ raise self._handle_error(e=e, provider_config=agents_api_config)
+
+ logging_obj.post_call(original_response=response.text, additional_args={})
+ return agents_api_config.transform_get_response(
+ raw_response=response, name=name
+ )
+
+ async def async_get_agent(
+ self,
+ agents_api_config: BaseAgentsAPIConfig,
+ name: str,
+ litellm_params: GenericLiteLLMParams,
+ logging_obj: LiteLLMLoggingObj,
+ extra_headers: Optional[Dict[str, Any]] = None,
+ timeout: Optional[Union[float, httpx.Timeout]] = None,
+ client: Optional[AsyncHTTPHandler] = None,
+ ) -> AgentCreateResponse:
+ async_httpx_client = self._async_client(litellm_params, client)
+ headers = agents_api_config.validate_environment(
+ headers=extra_headers or {}, litellm_params=dict(litellm_params)
+ )
+ url, params = agents_api_config.transform_get_request(
+ name=name,
+ api_base=litellm_params.get("api_base"),
+ litellm_params=dict(litellm_params),
+ )
+ logging_obj.pre_call(
+ input=name,
+ api_key="",
+ additional_args={"api_base": url, "headers": headers},
+ )
+ try:
+ response = await async_httpx_client.get(
+ url=url, headers=headers, params=params
+ )
+ except Exception as e:
+ raise self._handle_error(e=e, provider_config=agents_api_config)
+
+ logging_obj.post_call(original_response=response.text, additional_args={})
+ return agents_api_config.transform_get_response(
+ raw_response=response, name=name
+ )
+
+ # ------------------------------------------------------------------ #
+ # DELETE #
+ # ------------------------------------------------------------------ #
+
+ def delete_agent(
+ self,
+ agents_api_config: BaseAgentsAPIConfig,
+ name: str,
+ litellm_params: GenericLiteLLMParams,
+ logging_obj: LiteLLMLoggingObj,
+ extra_headers: Optional[Dict[str, Any]] = None,
+ timeout: Optional[Union[float, httpx.Timeout]] = None,
+ client: Optional[HTTPHandler] = None,
+ _is_async: bool = False,
+ ) -> Union[AgentDeleteResult, Coroutine[Any, Any, AgentDeleteResult]]:
+ if _is_async:
+ return self.async_delete_agent(
+ agents_api_config=agents_api_config,
+ name=name,
+ litellm_params=litellm_params,
+ logging_obj=logging_obj,
+ extra_headers=extra_headers,
+ timeout=timeout,
+ )
+
+ sync_httpx_client = self._sync_client(litellm_params, client)
+ headers = agents_api_config.validate_environment(
+ headers=extra_headers or {}, litellm_params=dict(litellm_params)
+ )
+ url = agents_api_config.transform_delete_request(
+ name=name,
+ api_base=litellm_params.get("api_base"),
+ litellm_params=dict(litellm_params),
+ )
+ logging_obj.pre_call(
+ input=name,
+ api_key="",
+ additional_args={"api_base": url, "headers": headers},
+ )
+ try:
+ response = sync_httpx_client.delete(
+ url=url, headers=headers, timeout=timeout or request_timeout
+ )
+ except Exception as e:
+ raise self._handle_error(e=e, provider_config=agents_api_config)
+
+ logging_obj.post_call(original_response=response.text, additional_args={})
+ return agents_api_config.transform_delete_response(
+ raw_response=response, name=name
+ )
+
+ async def async_delete_agent(
+ self,
+ agents_api_config: BaseAgentsAPIConfig,
+ name: str,
+ litellm_params: GenericLiteLLMParams,
+ logging_obj: LiteLLMLoggingObj,
+ extra_headers: Optional[Dict[str, Any]] = None,
+ timeout: Optional[Union[float, httpx.Timeout]] = None,
+ client: Optional[AsyncHTTPHandler] = None,
+ ) -> AgentDeleteResult:
+ async_httpx_client = self._async_client(litellm_params, client)
+ headers = agents_api_config.validate_environment(
+ headers=extra_headers or {}, litellm_params=dict(litellm_params)
+ )
+ url = agents_api_config.transform_delete_request(
+ name=name,
+ api_base=litellm_params.get("api_base"),
+ litellm_params=dict(litellm_params),
+ )
+ logging_obj.pre_call(
+ input=name,
+ api_key="",
+ additional_args={"api_base": url, "headers": headers},
+ )
+ try:
+ response = await async_httpx_client.delete(
+ url=url, headers=headers, timeout=timeout or request_timeout
+ )
+ except Exception as e:
+ raise self._handle_error(e=e, provider_config=agents_api_config)
+
+ logging_obj.post_call(original_response=response.text, additional_args={})
+ return agents_api_config.transform_delete_response(
+ raw_response=response, name=name
+ )
+
+ # ------------------------------------------------------------------ #
+ # LIST VERSIONS #
+ # ------------------------------------------------------------------ #
+
+ def list_agent_versions(
+ self,
+ agents_api_config: BaseAgentsAPIConfig,
+ name: str,
+ litellm_params: GenericLiteLLMParams,
+ logging_obj: LiteLLMLoggingObj,
+ extra_headers: Optional[Dict[str, Any]] = None,
+ timeout: Optional[Union[float, httpx.Timeout]] = None,
+ client: Optional[HTTPHandler] = None,
+ _is_async: bool = False,
+ ) -> Union[AgentVersionsResponse, Coroutine[Any, Any, AgentVersionsResponse]]:
+ if _is_async:
+ return self.async_list_agent_versions(
+ agents_api_config=agents_api_config,
+ name=name,
+ litellm_params=litellm_params,
+ logging_obj=logging_obj,
+ extra_headers=extra_headers,
+ timeout=timeout,
+ )
+
+ sync_httpx_client = self._sync_client(litellm_params, client)
+ headers = agents_api_config.validate_environment(
+ headers=extra_headers or {}, litellm_params=dict(litellm_params)
+ )
+ url, params = agents_api_config.transform_list_versions_request(
+ name=name,
+ api_base=litellm_params.get("api_base"),
+ litellm_params=dict(litellm_params),
+ )
+ logging_obj.pre_call(
+ input=name,
+ api_key="",
+ additional_args={"api_base": url, "headers": headers},
+ )
+ try:
+ response = sync_httpx_client.get(url=url, headers=headers, params=params)
+ except Exception as e:
+ raise self._handle_error(e=e, provider_config=agents_api_config)
+
+ logging_obj.post_call(original_response=response.text, additional_args={})
+ return agents_api_config.transform_list_versions_response(
+ raw_response=response, name=name
+ )
+
+ async def async_list_agent_versions(
+ self,
+ agents_api_config: BaseAgentsAPIConfig,
+ name: str,
+ litellm_params: GenericLiteLLMParams,
+ logging_obj: LiteLLMLoggingObj,
+ extra_headers: Optional[Dict[str, Any]] = None,
+ timeout: Optional[Union[float, httpx.Timeout]] = None,
+ client: Optional[AsyncHTTPHandler] = None,
+ ) -> AgentVersionsResponse:
+ async_httpx_client = self._async_client(litellm_params, client)
+ headers = agents_api_config.validate_environment(
+ headers=extra_headers or {}, litellm_params=dict(litellm_params)
+ )
+ url, params = agents_api_config.transform_list_versions_request(
+ name=name,
+ api_base=litellm_params.get("api_base"),
+ litellm_params=dict(litellm_params),
+ )
+ logging_obj.pre_call(
+ input=name,
+ api_key="",
+ additional_args={"api_base": url, "headers": headers},
+ )
+ try:
+ response = await async_httpx_client.get(
+ url=url, headers=headers, params=params
+ )
+ except Exception as e:
+ raise self._handle_error(e=e, provider_config=agents_api_config)
+
+ logging_obj.post_call(original_response=response.text, additional_args={})
+ return agents_api_config.transform_list_versions_response(
+ raw_response=response, name=name
+ )
+
+
+agents_http_handler = AgentsHTTPHandler()
diff --git a/litellm/interactions/agents/main.py b/litellm/interactions/agents/main.py
new file mode 100644
index 00000000000..f56c6f3ed5e
--- /dev/null
+++ b/litellm/interactions/agents/main.py
@@ -0,0 +1,522 @@
+"""
+LiteLLM Agents API - Main Module
+
+Usage:
+ import litellm
+
+ # Create
+ response = litellm.interactions.agents.create(
+ name="waverunner",
+ custom_llm_provider="gemini",
+ api_key="...",
+ base_agent="gemini-2.5-flash",
+ instructions="You are a helpful assistant.",
+ )
+
+ # List
+ response = litellm.interactions.agents.list(api_key="...", custom_llm_provider="gemini")
+
+ # Get
+ response = litellm.interactions.agents.get(name="waverunner", api_key="...")
+
+ # Delete
+ result = litellm.interactions.agents.delete(name="waverunner", api_key="...")
+
+ # List versions
+ result = litellm.interactions.agents.list_versions(name="waverunner", api_key="...")
+
+ # Async versions: acreate, alist, aget, adelete, alist_versions
+"""
+
+import asyncio
+import contextvars
+from functools import partial
+from typing import Any, Coroutine, Dict, Optional, Union
+
+import httpx
+
+import litellm
+from litellm.interactions.agents.http_handler import agents_http_handler
+from litellm.interactions.agents.utils import get_provider_agents_api_config
+from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
+from litellm.types.agents import (
+ AgentCreateResponse,
+ AgentDeleteResult,
+ AgentListResponse,
+ AgentVersionsResponse,
+)
+from litellm.types.interactions import InteractionEnvironment
+from litellm.types.router import GenericLiteLLMParams
+from litellm.utils import client
+
+# ------------------------------------------------------------------ #
+# Shared helpers #
+# ------------------------------------------------------------------ #
+
+
+def _get_agents_api_config(custom_llm_provider: str):
+ config = get_provider_agents_api_config(custom_llm_provider)
+ if config is None:
+ raise litellm.BadRequestError(
+ message=(
+ f"Provider '{custom_llm_provider}' does not have a native "
+ "agents API. Use the proxy POST /v1/agents endpoint to store "
+ "agents locally."
+ ),
+ model="",
+ llm_provider=custom_llm_provider,
+ )
+ return config
+
+
+def _make_logging_obj(
+ kwargs: Dict[str, Any],
+ model: str,
+ custom_llm_provider: str,
+ call_type: str,
+ optional_params: Dict[str, Any],
+) -> LiteLLMLoggingObj:
+ litellm_logging_obj: LiteLLMLoggingObj = kwargs.get("litellm_logging_obj") # type: ignore
+ litellm_call_id: Optional[str] = kwargs.get("litellm_call_id", None)
+ litellm_logging_obj.update_from_kwargs(
+ kwargs=kwargs,
+ model=model,
+ optional_params=optional_params,
+ litellm_params={"litellm_call_id": litellm_call_id},
+ custom_llm_provider=custom_llm_provider,
+ )
+ return litellm_logging_obj
+
+
+# ================================================================== #
+# CREATE #
+# ================================================================== #
+
+
+@client
+async def acreate(
+ name: str,
+ base_agent: Optional[str] = None,
+ instructions: Optional[str] = None,
+ base_environment: Optional[InteractionEnvironment] = None,
+ custom_llm_provider: Optional[str] = None,
+ extra_headers: Optional[Dict[str, Any]] = None,
+ extra_body: Optional[Dict[str, Any]] = None,
+ timeout: Optional[Union[float, httpx.Timeout]] = None,
+ **kwargs,
+) -> AgentCreateResponse:
+ """Async: Create a managed agent on the provider side."""
+ local_vars = locals()
+ try:
+ loop = asyncio.get_event_loop()
+ kwargs["acreate_agent"] = True
+ func = partial(
+ create,
+ name=name,
+ base_agent=base_agent,
+ instructions=instructions,
+ base_environment=base_environment,
+ custom_llm_provider=custom_llm_provider or "gemini",
+ extra_headers=extra_headers,
+ extra_body=extra_body,
+ timeout=timeout,
+ **kwargs,
+ )
+ ctx = contextvars.copy_context()
+ init_response = await loop.run_in_executor(None, partial(ctx.run, func))
+ if asyncio.iscoroutine(init_response):
+ return await init_response
+ return init_response
+ except Exception as e:
+ raise litellm.exception_type(
+ model=name,
+ custom_llm_provider=custom_llm_provider or "gemini",
+ original_exception=e,
+ completion_kwargs=local_vars,
+ extra_kwargs=kwargs,
+ )
+
+
+@client
+def create(
+ name: str,
+ base_agent: Optional[str] = None,
+ instructions: Optional[str] = None,
+ base_environment: Optional[InteractionEnvironment] = None,
+ custom_llm_provider: Optional[str] = None,
+ extra_headers: Optional[Dict[str, Any]] = None,
+ extra_body: Optional[Dict[str, Any]] = None,
+ timeout: Optional[Union[float, httpx.Timeout]] = None,
+ **kwargs,
+) -> Union[AgentCreateResponse, Coroutine[Any, Any, AgentCreateResponse]]:
+ """
+ Sync: Create a managed agent on the provider side.
+
+ Args:
+ name: Name for the agent (required).
+ base_agent: Base agent to derive from (e.g. "waverunner").
+ instructions: System instructions for the agent.
+ base_environment: Environment to fork from — an env_id string or a
+ dict like ``{"type": "remote", "sources": [...]}``.
+ custom_llm_provider: Provider to use, e.g. "gemini".
+ extra_headers: Additional HTTP headers.
+ extra_body: Additional request body fields.
+ timeout: Request timeout.
+ **kwargs: Forwarded to GenericLiteLLMParams (api_key, api_base, etc.).
+ """
+ local_vars = locals()
+ custom_llm_provider = (
+ custom_llm_provider or kwargs.get("custom_llm_provider") or "gemini"
+ )
+ try:
+ _is_async = kwargs.pop("acreate_agent", False) is True
+ if base_agent is not None:
+ kwargs["base_agent"] = base_agent
+ if instructions is not None:
+ kwargs["instructions"] = instructions
+ if base_environment is not None:
+ kwargs["base_environment"] = base_environment
+ kwargs.setdefault("custom_llm_provider", custom_llm_provider)
+ litellm_params = GenericLiteLLMParams(**kwargs)
+ logging_obj = _make_logging_obj(
+ kwargs, name, custom_llm_provider, "create_agent", {}
+ )
+ config = _get_agents_api_config(custom_llm_provider)
+ return agents_http_handler.create_agent(
+ agents_api_config=config,
+ name=name,
+ litellm_params=litellm_params,
+ logging_obj=logging_obj,
+ extra_headers=extra_headers,
+ extra_body=extra_body,
+ timeout=timeout,
+ _is_async=_is_async,
+ )
+ except Exception as e:
+ raise litellm.exception_type(
+ model=name,
+ custom_llm_provider=custom_llm_provider,
+ original_exception=e,
+ completion_kwargs=local_vars,
+ extra_kwargs=kwargs,
+ )
+
+
+# ================================================================== #
+# LIST #
+# ================================================================== #
+
+
+@client
+async def alist(
+ custom_llm_provider: Optional[str] = None,
+ extra_headers: Optional[Dict[str, Any]] = None,
+ timeout: Optional[Union[float, httpx.Timeout]] = None,
+ **kwargs,
+) -> AgentListResponse:
+ """Async: List all agents on the provider side."""
+ local_vars = locals()
+ try:
+ loop = asyncio.get_event_loop()
+ kwargs["alist_agents"] = True
+ func = partial(
+ list,
+ custom_llm_provider=custom_llm_provider or "gemini",
+ extra_headers=extra_headers,
+ timeout=timeout,
+ **kwargs,
+ )
+ ctx = contextvars.copy_context()
+ init_response = await loop.run_in_executor(None, partial(ctx.run, func))
+ if asyncio.iscoroutine(init_response):
+ return await init_response
+ return init_response
+ except Exception as e:
+ raise litellm.exception_type(
+ model="",
+ custom_llm_provider=custom_llm_provider or "gemini",
+ original_exception=e,
+ completion_kwargs=local_vars,
+ extra_kwargs=kwargs,
+ )
+
+
+@client
+def list(
+ custom_llm_provider: Optional[str] = None,
+ extra_headers: Optional[Dict[str, Any]] = None,
+ timeout: Optional[Union[float, httpx.Timeout]] = None,
+ **kwargs,
+) -> Union[AgentListResponse, Coroutine[Any, Any, AgentListResponse]]:
+ """Sync: List all agents on the provider side."""
+ local_vars = locals()
+ custom_llm_provider = (
+ custom_llm_provider or kwargs.get("custom_llm_provider") or "gemini"
+ )
+ try:
+ _is_async = kwargs.pop("alist_agents", False) is True
+ kwargs.setdefault("custom_llm_provider", custom_llm_provider)
+ litellm_params = GenericLiteLLMParams(**kwargs)
+ logging_obj = _make_logging_obj(
+ kwargs, "", custom_llm_provider, "list_agents", {}
+ )
+ config = _get_agents_api_config(custom_llm_provider)
+ return agents_http_handler.list_agents(
+ agents_api_config=config,
+ litellm_params=litellm_params,
+ logging_obj=logging_obj,
+ extra_headers=extra_headers,
+ timeout=timeout,
+ _is_async=_is_async,
+ )
+ except Exception as e:
+ raise litellm.exception_type(
+ model="",
+ custom_llm_provider=custom_llm_provider,
+ original_exception=e,
+ completion_kwargs=local_vars,
+ extra_kwargs=kwargs,
+ )
+
+
+# ================================================================== #
+# GET #
+# ================================================================== #
+
+
+@client
+async def aget(
+ name: str,
+ custom_llm_provider: Optional[str] = None,
+ extra_headers: Optional[Dict[str, Any]] = None,
+ timeout: Optional[Union[float, httpx.Timeout]] = None,
+ **kwargs,
+) -> AgentCreateResponse:
+ """Async: Get a specific agent by name."""
+ local_vars = locals()
+ try:
+ loop = asyncio.get_event_loop()
+ kwargs["aget_agent"] = True
+ func = partial(
+ get,
+ name=name,
+ custom_llm_provider=custom_llm_provider or "gemini",
+ extra_headers=extra_headers,
+ timeout=timeout,
+ **kwargs,
+ )
+ ctx = contextvars.copy_context()
+ init_response = await loop.run_in_executor(None, partial(ctx.run, func))
+ if asyncio.iscoroutine(init_response):
+ return await init_response
+ return init_response
+ except Exception as e:
+ raise litellm.exception_type(
+ model=name,
+ custom_llm_provider=custom_llm_provider or "gemini",
+ original_exception=e,
+ completion_kwargs=local_vars,
+ extra_kwargs=kwargs,
+ )
+
+
+@client
+def get(
+ name: str,
+ custom_llm_provider: Optional[str] = None,
+ extra_headers: Optional[Dict[str, Any]] = None,
+ timeout: Optional[Union[float, httpx.Timeout]] = None,
+ **kwargs,
+) -> Union[AgentCreateResponse, Coroutine[Any, Any, AgentCreateResponse]]:
+ """Sync: Get a specific agent by name."""
+ local_vars = locals()
+ custom_llm_provider = (
+ custom_llm_provider or kwargs.get("custom_llm_provider") or "gemini"
+ )
+ try:
+ _is_async = kwargs.pop("aget_agent", False) is True
+ kwargs.setdefault("custom_llm_provider", custom_llm_provider)
+ litellm_params = GenericLiteLLMParams(**kwargs)
+ logging_obj = _make_logging_obj(
+ kwargs, name, custom_llm_provider, "get_agent", {"name": name}
+ )
+ config = _get_agents_api_config(custom_llm_provider)
+ return agents_http_handler.get_agent(
+ agents_api_config=config,
+ name=name,
+ litellm_params=litellm_params,
+ logging_obj=logging_obj,
+ extra_headers=extra_headers,
+ timeout=timeout,
+ _is_async=_is_async,
+ )
+ except Exception as e:
+ raise litellm.exception_type(
+ model=name,
+ custom_llm_provider=custom_llm_provider,
+ original_exception=e,
+ completion_kwargs=local_vars,
+ extra_kwargs=kwargs,
+ )
+
+
+# ================================================================== #
+# DELETE #
+# ================================================================== #
+
+
+@client
+async def adelete(
+ name: str,
+ custom_llm_provider: Optional[str] = None,
+ extra_headers: Optional[Dict[str, Any]] = None,
+ timeout: Optional[Union[float, httpx.Timeout]] = None,
+ **kwargs,
+) -> AgentDeleteResult:
+ """Async: Delete a specific agent by name."""
+ local_vars = locals()
+ try:
+ loop = asyncio.get_event_loop()
+ kwargs["adelete_agent"] = True
+ func = partial(
+ delete,
+ name=name,
+ custom_llm_provider=custom_llm_provider or "gemini",
+ extra_headers=extra_headers,
+ timeout=timeout,
+ **kwargs,
+ )
+ ctx = contextvars.copy_context()
+ init_response = await loop.run_in_executor(None, partial(ctx.run, func))
+ if asyncio.iscoroutine(init_response):
+ return await init_response
+ return init_response
+ except Exception as e:
+ raise litellm.exception_type(
+ model=name,
+ custom_llm_provider=custom_llm_provider or "gemini",
+ original_exception=e,
+ completion_kwargs=local_vars,
+ extra_kwargs=kwargs,
+ )
+
+
+@client
+def delete(
+ name: str,
+ custom_llm_provider: Optional[str] = None,
+ extra_headers: Optional[Dict[str, Any]] = None,
+ timeout: Optional[Union[float, httpx.Timeout]] = None,
+ **kwargs,
+) -> Union[AgentDeleteResult, Coroutine[Any, Any, AgentDeleteResult]]:
+ """Sync: Delete a specific agent by name."""
+ local_vars = locals()
+ custom_llm_provider = (
+ custom_llm_provider or kwargs.get("custom_llm_provider") or "gemini"
+ )
+ try:
+ _is_async = kwargs.pop("adelete_agent", False) is True
+ kwargs.setdefault("custom_llm_provider", custom_llm_provider)
+ litellm_params = GenericLiteLLMParams(**kwargs)
+ logging_obj = _make_logging_obj(
+ kwargs, name, custom_llm_provider, "delete_agent", {"name": name}
+ )
+ config = _get_agents_api_config(custom_llm_provider)
+ return agents_http_handler.delete_agent(
+ agents_api_config=config,
+ name=name,
+ litellm_params=litellm_params,
+ logging_obj=logging_obj,
+ extra_headers=extra_headers,
+ timeout=timeout,
+ _is_async=_is_async,
+ )
+ except Exception as e:
+ raise litellm.exception_type(
+ model=name,
+ custom_llm_provider=custom_llm_provider,
+ original_exception=e,
+ completion_kwargs=local_vars,
+ extra_kwargs=kwargs,
+ )
+
+
+# ================================================================== #
+# LIST VERSIONS #
+# ================================================================== #
+
+
+@client
+async def alist_versions(
+ name: str,
+ custom_llm_provider: Optional[str] = None,
+ extra_headers: Optional[Dict[str, Any]] = None,
+ timeout: Optional[Union[float, httpx.Timeout]] = None,
+ **kwargs,
+) -> AgentVersionsResponse:
+ """Async: List versions of a specific agent."""
+ local_vars = locals()
+ try:
+ loop = asyncio.get_event_loop()
+ kwargs["alist_agent_versions"] = True
+ func = partial(
+ list_versions,
+ name=name,
+ custom_llm_provider=custom_llm_provider or "gemini",
+ extra_headers=extra_headers,
+ timeout=timeout,
+ **kwargs,
+ )
+ ctx = contextvars.copy_context()
+ init_response = await loop.run_in_executor(None, partial(ctx.run, func))
+ if asyncio.iscoroutine(init_response):
+ return await init_response
+ return init_response
+ except Exception as e:
+ raise litellm.exception_type(
+ model=name,
+ custom_llm_provider=custom_llm_provider or "gemini",
+ original_exception=e,
+ completion_kwargs=local_vars,
+ extra_kwargs=kwargs,
+ )
+
+
+@client
+def list_versions(
+ name: str,
+ custom_llm_provider: Optional[str] = None,
+ extra_headers: Optional[Dict[str, Any]] = None,
+ timeout: Optional[Union[float, httpx.Timeout]] = None,
+ **kwargs,
+) -> Union[AgentVersionsResponse, Coroutine[Any, Any, AgentVersionsResponse]]:
+ """Sync: List versions of a specific agent."""
+ local_vars = locals()
+ custom_llm_provider = (
+ custom_llm_provider or kwargs.get("custom_llm_provider") or "gemini"
+ )
+ try:
+ _is_async = kwargs.pop("alist_agent_versions", False) is True
+ kwargs.setdefault("custom_llm_provider", custom_llm_provider)
+ litellm_params = GenericLiteLLMParams(**kwargs)
+ logging_obj = _make_logging_obj(
+ kwargs, name, custom_llm_provider, "list_agent_versions", {"name": name}
+ )
+ config = _get_agents_api_config(custom_llm_provider)
+ return agents_http_handler.list_agent_versions(
+ agents_api_config=config,
+ name=name,
+ litellm_params=litellm_params,
+ logging_obj=logging_obj,
+ extra_headers=extra_headers,
+ timeout=timeout,
+ _is_async=_is_async,
+ )
+ except Exception as e:
+ raise litellm.exception_type(
+ model=name,
+ custom_llm_provider=custom_llm_provider,
+ original_exception=e,
+ completion_kwargs=local_vars,
+ extra_kwargs=kwargs,
+ )
diff --git a/litellm/interactions/agents/utils.py b/litellm/interactions/agents/utils.py
new file mode 100644
index 00000000000..d16a9597f53
--- /dev/null
+++ b/litellm/interactions/agents/utils.py
@@ -0,0 +1,23 @@
+"""
+Utility functions for the Agents API SDK.
+"""
+
+from typing import Optional
+
+from litellm.llms.base_llm.agents.transformation import BaseAgentsAPIConfig
+
+
+def get_provider_agents_api_config(
+ custom_llm_provider: Optional[str],
+) -> Optional[BaseAgentsAPIConfig]:
+ """
+ Return a provider-specific BaseAgentsAPIConfig if the provider has a
+ native agent-creation API, or None otherwise.
+ """
+ from litellm.types.utils import LlmProviders
+
+ if custom_llm_provider == LlmProviders.GEMINI.value:
+ from litellm.llms.gemini.agents.transformation import GeminiAgentsConfig
+
+ return GeminiAgentsConfig()
+ return None
diff --git a/litellm/interactions/http_handler.py b/litellm/interactions/http_handler.py
index 7fead07043f..695da2be89a 100644
--- a/litellm/interactions/http_handler.py
+++ b/litellm/interactions/http_handler.py
@@ -41,27 +41,55 @@ from litellm.types.interactions import (
from litellm.types.router import GenericLiteLLMParams
-class InteractionsHTTPHandler:
+class _BaseHTTPHandler:
+ """
+ Shared HTTP infrastructure for LiteLLM handler classes.
+
+ Provides common client resolution and error-mapping helpers so that
+ handler subclasses (InteractionsHTTPHandler, AgentsHTTPHandler, …) do
+ not duplicate this boilerplate.
+ """
+
+ def _handle_error(self, e: Exception, provider_config: Any) -> Exception:
+ if isinstance(e, httpx.HTTPStatusError):
+ return provider_config.get_error_class(
+ error_message=e.response.text,
+ status_code=e.response.status_code,
+ headers=dict(e.response.headers),
+ )
+ return e
+
+ def _sync_client(
+ self,
+ litellm_params: GenericLiteLLMParams,
+ client: Optional[HTTPHandler],
+ ) -> HTTPHandler:
+ return client or _get_httpx_client(
+ params={"ssl_verify": litellm_params.get("ssl_verify", None)}
+ )
+
+ def _async_client(
+ self,
+ litellm_params: GenericLiteLLMParams,
+ client: Optional[AsyncHTTPHandler],
+ ) -> AsyncHTTPHandler:
+ # GenericLiteLLMParams.get uses getattr; an unset field is None, not the default.
+ custom_llm_provider = litellm_params.get("custom_llm_provider") or "gemini"
+ return client or get_async_httpx_client(
+ llm_provider=litellm.LlmProviders(custom_llm_provider),
+ params={"ssl_verify": litellm_params.get("ssl_verify", None)},
+ )
+
+
+class InteractionsHTTPHandler(_BaseHTTPHandler):
"""
HTTP handler for Interactions API requests.
"""
- def _handle_error(
- self,
- e: Exception,
- provider_config: BaseInteractionsAPIConfig,
- ) -> Exception:
- """Handle errors from HTTP requests."""
- if isinstance(e, httpx.HTTPStatusError):
- error_message = e.response.text
- status_code = e.response.status_code
- headers = dict(e.response.headers)
- return provider_config.get_error_class(
- error_message=error_message,
- status_code=status_code,
- headers=headers,
- )
- return e
+ # _handle_error is inherited from _BaseHTTPHandler (accepts Any provider_config).
+ # AgentsHTTPHandler also extends this class and passes BaseAgentsAPIConfig, which
+ # is structurally compatible but a different type — keeping the override here with
+ # BaseInteractionsAPIConfig would cause type errors in the subclass.
# =========================================================
# CREATE INTERACTION
diff --git a/litellm/interactions/litellm_responses_transformation/streaming_iterator.py b/litellm/interactions/litellm_responses_transformation/streaming_iterator.py
index 72a3afbc3c5..4a3eb63084e 100644
--- a/litellm/interactions/litellm_responses_transformation/streaming_iterator.py
+++ b/litellm/interactions/litellm_responses_transformation/streaming_iterator.py
@@ -2,7 +2,17 @@
Streaming iterator for transforming Responses API stream to Interactions API stream.
"""
-from typing import Any, AsyncIterator, Dict, Iterator, Optional, cast
+from collections import deque
+from typing import (
+ Any,
+ AsyncIterator,
+ Deque,
+ Dict,
+ Iterator,
+ List,
+ Optional,
+ cast,
+)
from litellm.responses.streaming_iterator import (
BaseResponsesAPIStreamingIterator,
@@ -29,7 +39,13 @@ class LiteLLMResponsesInteractionsStreamingIterator:
This class handles both sync and async iteration, transforming Responses API
streaming events (output.text.delta, response.completed, etc.) to Interactions
- API streaming events (content.delta, interaction.complete, etc.).
+ API streaming events.
+
+ Schema selection:
+ - New schema (default, use_legacy_interactions_schema=False):
+ interaction.created -> step.start -> step.delta ... -> step.stop -> interaction.completed
+ - Legacy schema (use_legacy_interactions_schema=True, remove after June 8 2026):
+ interaction.start -> content.start -> content.delta ... -> content.stop -> interaction.complete
"""
def __init__(
@@ -41,6 +57,8 @@ class LiteLLMResponsesInteractionsStreamingIterator:
custom_llm_provider: Optional[str] = None,
litellm_metadata: Optional[Dict[str, Any]] = None,
):
+ import litellm
+
self.model = model
self.responses_stream_iterator = litellm_custom_stream_wrapper
self.request_input = request_input
@@ -51,66 +69,156 @@ class LiteLLMResponsesInteractionsStreamingIterator:
self.collected_text = ""
self.sent_interaction_start = False
self.sent_content_start = False
+ # Capture the schema flag once at construction time so all events
+ # emitted by this stream use a consistent schema, even if the global
+ # flag is mutated mid-stream (e.g. by a config reload).
+ self._use_legacy: bool = litellm.use_legacy_interactions_schema
+ # Buffer of events that have been derived from upstream chunks but not
+ # yet returned to the caller. A single Responses API chunk may expand
+ # into multiple Interactions API events (e.g. the first text delta
+ # produces interaction.created + step.start + step.delta), and the
+ # terminal sequence on stream end may also span multiple events
+ # (step.stop + interaction.completed).
+ self._pending_events: Deque[InteractionsAPIStreamingResponse] = deque()
+ # Tracks whether we've already emitted a terminal completion event so
+ # the StopIteration fallback path doesn't double-emit.
+ self._sent_completion_event = False
+ # ID resolved from the first upstream chunk (item_id on a text delta or
+ # response.id on response.created). Persisted so the EOF terminal
+ # events stay correlated with the start events delivered earlier.
+ self._interaction_id: Optional[str] = None
- def _transform_responses_chunk_to_interactions_chunk(
- self,
- responses_chunk: ResponsesAPIStreamingResponse,
- ) -> Optional[InteractionsAPIStreamingResponse]:
+ # ------------------------------------------------------------------
+ # Event builders
+ # ------------------------------------------------------------------
+
+ def _build_interaction_start_event(
+ self, interaction_id: str
+ ) -> InteractionsAPIStreamingResponse:
+ event_type = "interaction.start" if self._use_legacy else "interaction.created"
+ return InteractionsAPIStreamingResponse(
+ event_type=event_type,
+ id=interaction_id,
+ object="interaction",
+ status="in_progress",
+ model=self.model,
+ )
+
+ def _build_content_start_event(
+ self, interaction_id: str
+ ) -> InteractionsAPIStreamingResponse:
+ if self._use_legacy:
+ return InteractionsAPIStreamingResponse(
+ event_type="content.start",
+ id=interaction_id,
+ object="content",
+ delta={"type": "text", "text": ""},
+ )
+ return InteractionsAPIStreamingResponse(
+ event_type="step.start",
+ index=0,
+ step={"type": "model_output", "content": []},
+ )
+
+ def _build_text_delta_event(
+ self, interaction_id: str, delta_text: str
+ ) -> InteractionsAPIStreamingResponse:
+ if self._use_legacy:
+ return InteractionsAPIStreamingResponse(
+ event_type="content.delta",
+ id=interaction_id,
+ object="content",
+ delta={"type": "text", "text": delta_text},
+ )
+ return InteractionsAPIStreamingResponse(
+ event_type="step.delta",
+ index=0,
+ delta={"type": "text", "text": delta_text},
+ )
+
+ def _build_content_stop_event(
+ self, interaction_id: Optional[str]
+ ) -> InteractionsAPIStreamingResponse:
+ if self._use_legacy:
+ return InteractionsAPIStreamingResponse(
+ event_type="content.stop",
+ id=interaction_id,
+ object="content",
+ delta={"type": "text", "text": self.collected_text},
+ )
+ return InteractionsAPIStreamingResponse(
+ event_type="step.stop",
+ index=0,
+ )
+
+ def _build_completion_event(
+ self, response_id: str
+ ) -> InteractionsAPIStreamingResponse:
+ if self._use_legacy:
+ return InteractionsAPIStreamingResponse(
+ event_type="interaction.complete",
+ id=response_id,
+ object="interaction",
+ status="completed",
+ model=self.model,
+ outputs=[{"type": "text", "text": self.collected_text}],
+ )
+ return InteractionsAPIStreamingResponse(
+ event_type="interaction.completed",
+ id=response_id,
+ object="interaction",
+ status="completed",
+ model=self.model,
+ steps=[
+ {
+ "type": "model_output",
+ "content": [{"type": "text", "text": self.collected_text}],
+ }
+ ],
+ )
+
+ # ------------------------------------------------------------------
+ # Per-chunk transform (returns a list of events to enqueue)
+ # ------------------------------------------------------------------
+
+ def _events_for_chunk(
+ self, responses_chunk: ResponsesAPIStreamingResponse
+ ) -> List[InteractionsAPIStreamingResponse]:
"""
- Transform a Responses API streaming chunk to an Interactions API streaming chunk.
+ Translate a single upstream Responses API chunk into the list of
+ Interactions API events it should produce.
- Responses API events:
- - output.text.delta -> content.delta
- - response.completed -> interaction.complete
-
- Interactions API events:
- - interaction.start
- - content.start
- - content.delta
- - content.stop
- - interaction.complete
+ Returning a list (rather than a single event) lets a chunk emit any
+ synthetic start events that haven't been sent yet *together with* the
+ actual delta event, so we never silently drop the chunk's payload.
"""
if not responses_chunk:
- return None
+ return []
- # Handle OutputTextDeltaEvent -> content.delta
+ # Text delta: emit any missing start events, then the delta itself.
if isinstance(responses_chunk, OutputTextDeltaEvent):
delta_text = (
responses_chunk.delta if isinstance(responses_chunk.delta, str) else ""
)
self.collected_text += delta_text
+ interaction_id = (
+ getattr(responses_chunk, "item_id", None) or f"interaction_{id(self)}"
+ )
+ if self._interaction_id is None:
+ self._interaction_id = interaction_id
- # Send interaction.start if not sent
+ events: List[InteractionsAPIStreamingResponse] = []
if not self.sent_interaction_start:
self.sent_interaction_start = True
- return InteractionsAPIStreamingResponse(
- event_type="interaction.start",
- id=getattr(responses_chunk, "item_id", None)
- or f"interaction_{id(self)}",
- object="interaction",
- status="in_progress",
- model=self.model,
- )
-
- # Send content.start if not sent
+ events.append(self._build_interaction_start_event(interaction_id))
if not self.sent_content_start:
self.sent_content_start = True
- return InteractionsAPIStreamingResponse(
- event_type="content.start",
- id=getattr(responses_chunk, "item_id", None),
- object="content",
- delta={"type": "text", "text": ""},
- )
+ events.append(self._build_content_start_event(interaction_id))
+ events.append(self._build_text_delta_event(interaction_id, delta_text))
+ return events
- # Send content.delta
- return InteractionsAPIStreamingResponse(
- event_type="content.delta",
- id=getattr(responses_chunk, "item_id", None),
- object="content",
- delta={"text": delta_text},
- )
-
- # Handle ResponseCreatedEvent or ResponseInProgressEvent -> interaction.start
+ # Response created / in-progress: synthesize interaction start if we
+ # haven't already sent one.
if isinstance(responses_chunk, (ResponseCreatedEvent, ResponseInProgressEvent)):
if not self.sent_interaction_start:
self.sent_interaction_start = True
@@ -118,169 +226,136 @@ class LiteLLMResponsesInteractionsStreamingIterator:
getattr(responses_chunk.response, "id", None)
if hasattr(responses_chunk, "response")
else None
- )
- return InteractionsAPIStreamingResponse(
- event_type="interaction.start",
- id=response_id or f"interaction_{id(self)}",
- object="interaction",
- status="in_progress",
- model=self.model,
- )
+ ) or f"interaction_{id(self)}"
+ if self._interaction_id is None:
+ self._interaction_id = response_id
+ return [self._build_interaction_start_event(response_id)]
+ return []
- # Handle ResponseCompletedEvent -> interaction.complete
+ # Response completed: emit step.stop (if content was started) followed
+ # by the terminal completion event. Prefer the interaction id already
+ # established by earlier events so consumers can correlate the start
+ # and completion events by id (response.id may differ from the item_id
+ # used to derive the initial id when the stream starts directly with a
+ # text delta).
if isinstance(responses_chunk, ResponseCompletedEvent):
self.finished = True
response = responses_chunk.response
-
- # Send content.stop first if content was started
- if self.sent_content_start:
- # Note: We'll send this in the iterator, not here
- pass
-
- # Send interaction.complete
- return InteractionsAPIStreamingResponse(
- event_type="interaction.complete",
- id=getattr(response, "id", None) or f"interaction_{id(self)}",
- object="interaction",
- status="completed",
- model=self.model,
- outputs=[
- {
- "type": "text",
- "text": self.collected_text,
- }
- ],
+ response_id = (
+ self._interaction_id
+ or getattr(response, "id", None)
+ or f"interaction_{id(self)}"
)
- # For other event types, return None (skip)
- return None
+ terminal: List[InteractionsAPIStreamingResponse] = []
+ if self.sent_content_start:
+ terminal.append(self._build_content_stop_event(response_id))
+ terminal.append(self._build_completion_event(response_id))
+ self._sent_completion_event = True
+ return terminal
+
+ return []
+
+ def _build_terminal_events_on_eof(
+ self,
+ ) -> List[InteractionsAPIStreamingResponse]:
+ """
+ Build the events to flush when the upstream stream ends without a
+ ResponseCompletedEvent. Ensures consumers always observe a terminal
+ interaction.completed/interaction.complete carrying the full text.
+ """
+ if self._sent_completion_event:
+ return []
+
+ fallback_id = self._interaction_id or f"interaction_{id(self)}"
+ terminal: List[InteractionsAPIStreamingResponse] = []
+ if self.sent_content_start:
+ terminal.append(self._build_content_stop_event(fallback_id))
+ if self.sent_interaction_start or self.collected_text:
+ terminal.append(self._build_completion_event(fallback_id))
+ self._sent_completion_event = True
+ return terminal
+
+ # ------------------------------------------------------------------
+ # Iteration
+ # ------------------------------------------------------------------
def __iter__(self) -> Iterator[InteractionsAPIStreamingResponse]:
- """Sync iterator implementation."""
return self
def __next__(self) -> InteractionsAPIStreamingResponse:
- """Get next chunk in sync mode."""
+ if self._pending_events:
+ return self._pending_events.popleft()
+
if self.finished:
raise StopIteration
- # Check if we have a pending interaction.complete to send
- if hasattr(self, "_pending_interaction_complete"):
- pending: InteractionsAPIStreamingResponse = getattr(
- self, "_pending_interaction_complete"
- )
- delattr(self, "_pending_interaction_complete")
- return pending
-
- # Use a loop instead of recursion to avoid stack overflow
sync_iterator = cast(
SyncResponsesAPIStreamingIterator, self.responses_stream_iterator
)
while True:
try:
- # Get next chunk from responses API stream
chunk = next(sync_iterator)
-
- # Transform chunk (chunk is already a ResponsesAPIStreamingResponse)
- transformed = self._transform_responses_chunk_to_interactions_chunk(
- chunk
- )
-
- if transformed:
- # If we finished and content was started, send content.stop before interaction.complete
- if (
- self.finished
- and self.sent_content_start
- and transformed.event_type == "interaction.complete"
- ):
- # Send content.stop first
- content_stop = InteractionsAPIStreamingResponse(
- event_type="content.stop",
- id=transformed.id,
- object="content",
- delta={"type": "text", "text": self.collected_text},
- )
- # Store the interaction.complete to send next
- self._pending_interaction_complete = transformed
- return content_stop
- return transformed
-
- # If no transformation, continue to next chunk (loop continues)
-
except StopIteration:
self.finished = True
+ self._pending_events.extend(self._build_terminal_events_on_eof())
+ if self._pending_events:
+ return self._pending_events.popleft()
+ raise
- # Send final events if needed
- if self.sent_content_start:
- return InteractionsAPIStreamingResponse(
- event_type="content.stop",
- object="content",
- delta={"type": "text", "text": self.collected_text},
- )
-
- raise StopIteration
+ events = self._events_for_chunk(chunk)
+ if events:
+ self._pending_events.extend(events)
+ return self._pending_events.popleft()
def __aiter__(self) -> AsyncIterator[InteractionsAPIStreamingResponse]:
- """Async iterator implementation."""
return self
async def __anext__(self) -> InteractionsAPIStreamingResponse:
- """Get next chunk in async mode."""
+ if self._pending_events:
+ return self._pending_events.popleft()
+
if self.finished:
raise StopAsyncIteration
- # Check if we have a pending interaction.complete to send
- if hasattr(self, "_pending_interaction_complete"):
- pending: InteractionsAPIStreamingResponse = getattr(
- self, "_pending_interaction_complete"
- )
- delattr(self, "_pending_interaction_complete")
- return pending
-
- # Use a loop instead of recursion to avoid stack overflow
async_iterator = cast(
ResponsesAPIStreamingIterator, self.responses_stream_iterator
)
while True:
try:
- # Get next chunk from responses API stream
chunk = await async_iterator.__anext__()
-
- # Transform chunk (chunk is already a ResponsesAPIStreamingResponse)
- transformed = self._transform_responses_chunk_to_interactions_chunk(
- chunk
- )
-
- if transformed:
- # If we finished and content was started, send content.stop before interaction.complete
- if (
- self.finished
- and self.sent_content_start
- and transformed.event_type == "interaction.complete"
- ):
- # Send content.stop first
- content_stop = InteractionsAPIStreamingResponse(
- event_type="content.stop",
- id=transformed.id,
- object="content",
- delta={"type": "text", "text": self.collected_text},
- )
- # Store the interaction.complete to send next
- self._pending_interaction_complete = transformed
- return content_stop
- return transformed
-
- # If no transformation, continue to next chunk (loop continues)
-
except StopAsyncIteration:
self.finished = True
+ self._pending_events.extend(self._build_terminal_events_on_eof())
+ if self._pending_events:
+ return self._pending_events.popleft()
+ raise
- # Send final events if needed
- if self.sent_content_start:
- return InteractionsAPIStreamingResponse(
- event_type="content.stop",
- object="content",
- delta={"type": "text", "text": self.collected_text},
- )
+ events = self._events_for_chunk(chunk)
+ if events:
+ self._pending_events.extend(events)
+ return self._pending_events.popleft()
- raise StopAsyncIteration
+ # ------------------------------------------------------------------
+ # Backwards-compatible single-chunk transform (used by tests and any
+ # external callers that drove the iterator chunk-by-chunk pre-fix).
+ # ------------------------------------------------------------------
+
+ def _transform_responses_chunk_to_interactions_chunk(
+ self,
+ responses_chunk: ResponsesAPIStreamingResponse,
+ ) -> Optional[InteractionsAPIStreamingResponse]:
+ """
+ Compatibility shim: returns the *first* event produced for this chunk
+ and queues any remaining events on ``self._pending_events`` so they
+ are surfaced on subsequent calls/iterations.
+
+ Prefer ``_events_for_chunk`` in new code.
+ """
+ events = self._events_for_chunk(responses_chunk)
+ if not events:
+ return None
+ first = events[0]
+ if len(events) > 1:
+ self._pending_events.extend(events[1:])
+ return first
diff --git a/litellm/interactions/litellm_responses_transformation/transformation.py b/litellm/interactions/litellm_responses_transformation/transformation.py
index 100300af7b5..173d4ca8764 100644
--- a/litellm/interactions/litellm_responses_transformation/transformation.py
+++ b/litellm/interactions/litellm_responses_transformation/transformation.py
@@ -226,29 +226,37 @@ class LiteLLMResponsesInteractionsConfig:
- Map status
- Extract usage
"""
- # Extract text from outputs
- outputs = []
+ # Extract text from outputs and build both `outputs` (legacy) and `steps` (new schema).
+ outputs: List[Dict[str, Any]] = []
+ steps: List[Dict[str, Any]] = []
if hasattr(responses_response, "output") and responses_response.output:
for output_item in responses_response.output:
# Use getattr with None default to safely access content
content = getattr(output_item, "content", None)
if content is not None:
content_items = content if isinstance(content, list) else [content]
+ model_output_contents: List[Dict[str, Any]] = []
for content_item in content_items:
# Check if content_item has text attribute
text = getattr(content_item, "text", None)
if text is not None:
- outputs.append(
- {
- "type": "text",
- "text": text,
- }
- )
+ # Use independent dict instances so mutations to one
+ # of `outputs` / `steps` don't leak into the other.
+ outputs.append({"type": "text", "text": text})
+ model_output_contents.append({"type": "text", "text": text})
elif (
isinstance(content_item, dict)
and content_item.get("type") == "text"
):
- outputs.append(content_item)
+ outputs.append({**content_item})
+ model_output_contents.append({**content_item})
+ if model_output_contents:
+ steps.append(
+ {
+ "type": "model_output",
+ "content": model_output_contents,
+ }
+ )
# Convert created_at to ISO string
created_at = getattr(responses_response, "created_at", None)
@@ -270,12 +278,14 @@ class LiteLLMResponsesInteractionsConfig:
else:
interactions_status = status
- # Build interactions response
+ # Build interactions response — populate both `outputs` (legacy schema) and
+ # `steps` (new schema) so callers work regardless of which schema they expect.
interactions_response_dict: Dict[str, Any] = {
"id": getattr(responses_response, "id", ""),
"object": "interaction",
"status": interactions_status,
"outputs": outputs,
+ "steps": steps,
"model": model or getattr(responses_response, "model", ""),
"created": created,
}
diff --git a/litellm/interactions/main.py b/litellm/interactions/main.py
index ab429ef6db5..d99cc3d11c7 100644
--- a/litellm/interactions/main.py
+++ b/litellm/interactions/main.py
@@ -8,25 +8,25 @@ Per OpenAPI spec (https://ai.google.dev/static/api/interactions.openapi.json):
Usage:
import litellm
-
+
# Create an interaction with a model
response = litellm.interactions.create(
model="gemini-2.5-flash",
input="Hello, how are you?"
)
-
+
# Create an interaction with an agent
response = litellm.interactions.create(
agent="deep-research-pro-preview-12-2025",
input="Research the current state of cancer research"
)
-
+
# Async version
response = await litellm.interactions.acreate(...)
-
+
# Get an interaction
response = litellm.interactions.get(interaction_id="...")
-
+
# Delete an interaction
result = litellm.interactions.delete(interaction_id="...")
"""
@@ -48,6 +48,7 @@ from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
from litellm.types.interactions import (
CancelInteractionResult,
DeleteInteractionResult,
+ InteractionEnvironment,
InteractionInput,
InteractionsAPIResponse,
InteractionsAPIStreamingResponse,
@@ -80,6 +81,8 @@ async def acreate(
store: Optional[bool] = None,
# Background execution
background: Optional[bool] = None,
+ # Agent execution environment ("remote", env id, or remote config object)
+ environment: Optional[InteractionEnvironment] = None,
# Response format
response_modalities: Optional[List[str]] = None,
response_format: Optional[Dict[str, Any]] = None,
@@ -109,6 +112,10 @@ async def acreate(
stream: Whether to stream the response
store: Whether to store the response for later retrieval
background: Whether to run in background
+ environment: Agent execution environment — ``"remote"``, an existing env id
+ string, or a config object such as
+ ``{"type": "remote", "sources": [...]}`` /
+ ``{"type": "remote", "network": {...}}``
response_modalities: Requested response modalities (TEXT, IMAGE, AUDIO)
response_format: JSON schema for response format
response_mime_type: MIME type of the response
@@ -144,6 +151,7 @@ async def acreate(
stream=stream,
store=store,
background=background,
+ environment=environment,
response_modalities=response_modalities,
response_format=response_format,
response_mime_type=response_mime_type,
@@ -194,6 +202,8 @@ def create(
store: Optional[bool] = None,
# Background execution
background: Optional[bool] = None,
+ # Agent execution environment ("remote", env id, or remote config object)
+ environment: Optional[InteractionEnvironment] = None,
# Response format
response_modalities: Optional[List[str]] = None,
response_format: Optional[Dict[str, Any]] = None,
@@ -231,6 +241,10 @@ def create(
stream: Whether to stream the response
store: Whether to store the response for later retrieval
background: Whether to run in background
+ environment: Agent execution environment — ``"remote"``, an existing env id
+ string, or a config object such as
+ ``{"type": "remote", "sources": [...]}`` /
+ ``{"type": "remote", "network": {...}}``
response_modalities: Requested response modalities (TEXT, IMAGE, AUDIO)
response_format: JSON schema for response format
response_mime_type: MIME type of the response
@@ -252,7 +266,14 @@ def create(
litellm_params = GenericLiteLLMParams(**kwargs)
- if model:
+ # Routing logic:
+ # - agent provided (no model, or model accidentally set to agent name) → gemini
+ # - model provided → resolve provider via get_llm_provider (normal routing)
+ if agent and model == agent:
+ model = None
+ if agent and not model:
+ custom_llm_provider = custom_llm_provider or "gemini"
+ elif model:
model, custom_llm_provider, _, _ = litellm.get_llm_provider(
model=model,
custom_llm_provider=custom_llm_provider,
diff --git a/litellm/interactions/streaming_iterator.py b/litellm/interactions/streaming_iterator.py
index a5a7f9e06e5..561686a3e1b 100644
--- a/litellm/interactions/streaming_iterator.py
+++ b/litellm/interactions/streaming_iterator.py
@@ -101,10 +101,14 @@ class BaseInteractionsAPIStreamingIterator:
)
)
- # Store the completed response (check for status=completed)
- if (
- streaming_response
- and getattr(streaming_response, "status", None) == "completed"
+ # Store the completed response.
+ # Legacy schema signals completion via status="completed".
+ # New schema (Api-Revision: 2026-05-20) uses event_type="interaction.completed".
+ # Remove the legacy check after June 8, 2026.
+ if streaming_response and (
+ getattr(streaming_response, "status", None) == "completed"
+ or getattr(streaming_response, "event_type", None)
+ == "interaction.completed"
):
self.completed_response = streaming_response
self._handle_logging_completed_response()
diff --git a/litellm/interactions/utils.py b/litellm/interactions/utils.py
index 3a18ddf52fe..84437f4d3d8 100644
--- a/litellm/interactions/utils.py
+++ b/litellm/interactions/utils.py
@@ -15,6 +15,7 @@ INTERACTIONS_API_OPTIONAL_PARAMS = {
"stream",
"store",
"background",
+ "environment",
"response_modalities",
"response_format",
"response_mime_type",
diff --git a/litellm/litellm_core_utils/get_supported_openai_params.py b/litellm/litellm_core_utils/get_supported_openai_params.py
index 9d8bd7523db..b8cdc8210fc 100644
--- a/litellm/litellm_core_utils/get_supported_openai_params.py
+++ b/litellm/litellm_core_utils/get_supported_openai_params.py
@@ -11,6 +11,7 @@ def get_supported_openai_params( # noqa: PLR0915
request_type: Literal[
"chat_completion", "embeddings", "transcription"
] = "chat_completion",
+ base_model: Optional[str] = None,
) -> Optional[list]:
"""
Returns the supported openai params for a given model + provider
@@ -20,6 +21,11 @@ def get_supported_openai_params( # noqa: PLR0915
get_supported_openai_params(model="anthropic.claude-3", custom_llm_provider="bedrock")
```
+ Args:
+ base_model: For Azure, the true underlying model (e.g. ``"azure/gpt-5.2"``)
+ when the deployment name differs. Used for model-type detection so that
+ non-standard deployment names route to the correct config.
+
Returns:
- List if custom_llm_provider is mapped
- None if unmapped
@@ -32,17 +38,21 @@ def get_supported_openai_params( # noqa: PLR0915
if custom_llm_provider in LlmProvidersSet:
provider_config = litellm.ProviderConfigManager.get_provider_chat_config(
- model=model, provider=LlmProviders(custom_llm_provider)
+ model=model,
+ provider=LlmProviders(custom_llm_provider),
+ base_model=base_model,
)
elif custom_llm_provider.split("/")[0] in LlmProvidersSet:
provider_config = litellm.ProviderConfigManager.get_provider_chat_config(
- model=model, provider=LlmProviders(custom_llm_provider.split("/")[0])
+ model=model,
+ provider=LlmProviders(custom_llm_provider.split("/")[0]),
+ base_model=base_model,
)
else:
provider_config = None
if provider_config and request_type == "chat_completion":
- return provider_config.get_supported_openai_params(model=model)
+ return provider_config.get_supported_openai_params(model=base_model or model)
if custom_llm_provider == "bedrock":
return litellm.AmazonConverseConfig().get_supported_openai_params(model=model)
@@ -130,16 +140,23 @@ def get_supported_openai_params( # noqa: PLR0915
model=model
)
elif custom_llm_provider == "azure":
- if litellm.AzureOpenAIO1Config().is_o_series_model(model=model):
+ _azure_detection_model = base_model or model
+ if litellm.AzureOpenAIO1Config().is_o_series_model(
+ model=_azure_detection_model
+ ):
return litellm.AzureOpenAIO1Config().get_supported_openai_params(
- model=model
+ model=_azure_detection_model
)
- elif litellm.AzureOpenAIGPT5Config.is_model_gpt_5_model(model=model):
+ elif litellm.AzureOpenAIGPT5Config.is_model_gpt_5_model(
+ model=_azure_detection_model
+ ):
return litellm.AzureOpenAIGPT5Config().get_supported_openai_params(
- model=model
+ model=_azure_detection_model
)
else:
- return litellm.AzureOpenAIConfig().get_supported_openai_params(model=model)
+ return litellm.AzureOpenAIConfig().get_supported_openai_params(
+ model=_azure_detection_model
+ )
elif custom_llm_provider == "openrouter":
return litellm.OpenrouterConfig().get_supported_openai_params(model=model)
elif custom_llm_provider == "vercel_ai_gateway":
diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py
index 876f1b167db..63fa0e64695 100644
--- a/litellm/litellm_core_utils/litellm_logging.py
+++ b/litellm/litellm_core_utils/litellm_logging.py
@@ -994,10 +994,8 @@ class Logging(LiteLLMLoggingBaseClass):
try:
# [Non-blocking Extra Debug Information in metadata]
if turn_off_message_logging is True:
- _metadata["raw_request"] = (
- "redacted by litellm. \
+ _metadata["raw_request"] = "redacted by litellm. \
'litellm.turn_off_message_logging=True'"
- )
else:
curl_command = self._get_request_curl_command(
api_base=additional_args.get("api_base", ""),
@@ -1031,12 +1029,8 @@ class Logging(LiteLLMLoggingBaseClass):
error=str(e),
)
)
- _metadata["raw_request"] = (
- "Unable to Log \
- raw request: {}".format(
- str(e)
- )
- )
+ _metadata["raw_request"] = "Unable to Log \
+ raw request: {}".format(str(e))
if getattr(self, "logger_fn", None) and callable(self.logger_fn):
try:
self.logger_fn(
@@ -1769,9 +1763,12 @@ class Logging(LiteLLMLoggingBaseClass):
self.model_call_details["response_cost"] = 0.0
elif "response_cost" in hidden_params:
self.model_call_details["response_cost"] = hidden_params["response_cost"]
- elif self.model_call_details.get("response_cost") is not None:
+ elif (
+ existing_cost := self.model_call_details.get("response_cost")
+ ) is not None and existing_cost != 0:
# Preserve response_cost if already calculated (e.g., by pass-through
- # handlers like Gemini/Vertex which call completion_cost directly)
+ # handlers like Gemini/Vertex which call completion_cost directly).
+ # Do not preserve 0 from failure_handler on intermediate router retries.
pass
else:
self.model_call_details["response_cost"] = self._response_cost_calculator(
@@ -5143,13 +5140,17 @@ class StandardLoggingPayloadSetup:
) -> StandardLoggingPayloadErrorInformation:
from litellm.constants import MAXIMUM_TRACEBACK_LINES_TO_LOG
- # Check for 'code' first (used by ProxyException), then fall back to 'status_code' (used by LiteLLM exceptions)
- # Ensure error_code is always a string for Prisma Python JSON field compatibility
+ # ProxyException uses .code, LiteLLM exceptions use .status_code,
+ # httpx.HTTPStatusError exposes status only as .response.status_code.
+ # Stringified for Prisma JSON compatibility.
error_code_attr = getattr(original_exception, "code", None)
if error_code_attr is not None and str(error_code_attr) not in ("", "None"):
error_status: str = str(error_code_attr)
else:
status_code_attr = getattr(original_exception, "status_code", None)
+ if status_code_attr is None:
+ response_attr = getattr(original_exception, "response", None)
+ status_code_attr = getattr(response_attr, "status_code", None)
error_status = str(status_code_attr) if status_code_attr is not None else ""
error_class: str = (
str(original_exception.__class__.__name__) if original_exception else ""
diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py
index f417b4a5f61..32ae61d7f58 100644
--- a/litellm/litellm_core_utils/prompt_templates/common_utils.py
+++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py
@@ -20,6 +20,7 @@ from typing import (
cast,
)
+import litellm
from litellm import verbose_logger
from litellm.router_utils.batch_utils import InMemoryFile
from litellm.types.llms.openai import (
@@ -1170,9 +1171,16 @@ def migrate_file_to_image_url(
ChatCompletionImageUrlObject,
)
- file_id = message["file"].get("file_id")
- file_data = message["file"].get("file_data")
- format = message["file"].get("format")
+ file_sub = message.get("file")
+ if file_sub is None:
+ raise litellm.BadRequestError(
+ message="Content block has type='file' but is missing the required 'file' field",
+ model=None,
+ llm_provider=None,
+ )
+ file_id = file_sub.get("file_id")
+ file_data = file_sub.get("file_data")
+ format = file_sub.get("format")
if not file_id and not file_data:
raise ValueError("file_id and file_data are both None")
image_url_object = ChatCompletionImageObject(
@@ -1196,12 +1204,8 @@ def get_last_user_message(messages: List[AllMessageValues]) -> Optional[str]:
{"role": "assistant", "content": "I'm good, thank you!"},
{"role": "user", "content": "What is the weather in Tokyo?"},
]
- get_user_prompt(messages) -> "What is the weather in Tokyo?"
+ get_last_user_message(messages) -> "What is the weather in Tokyo?"
"""
- from litellm.litellm_core_utils.prompt_templates.common_utils import (
- convert_content_list_to_str,
- )
-
if not messages:
return None
diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py
index d40ca4e3597..f169f86079a 100644
--- a/litellm/litellm_core_utils/prompt_templates/factory.py
+++ b/litellm/litellm_core_utils/prompt_templates/factory.py
@@ -1233,6 +1233,7 @@ def infer_protocol_value(
def _gemini_tool_call_invoke_helper(
function_call_params: ChatCompletionToolCallFunctionChunk,
+ tool_call_id: Optional[str] = None,
) -> Optional[VertexFunctionCall]:
name = function_call_params.get("name", "") or ""
arguments = function_call_params.get("arguments", "")
@@ -1248,6 +1249,10 @@ def _gemini_tool_call_invoke_helper(
name=name,
args=arguments_dict,
)
+ if tool_call_id:
+ clean_id = tool_call_id.split(THOUGHT_SIGNATURE_SEPARATOR, 1)[0]
+ if clean_id:
+ function_call["id"] = clean_id
return function_call
@@ -1339,6 +1344,7 @@ def _get_dummy_thought_signature() -> str:
def convert_to_gemini_tool_call_invoke(
message: ChatCompletionAssistantMessage,
model: Optional[str] = None,
+ custom_llm_provider: Optional[str] = None,
) -> List[VertexPartType]:
"""
OpenAI tool invokes:
@@ -1384,12 +1390,26 @@ def convert_to_gemini_tool_call_invoke(
tool_calls = message.get("tool_calls", None)
function_call = message.get("function_call", None)
+ from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
+ VertexGeminiConfig,
+ )
+
+ forward_tool_call_id = bool(
+ model
+ and VertexGeminiConfig._forward_gemini_function_call_id(
+ model, custom_llm_provider
+ )
+ )
+
if tool_calls is not None:
for idx, tool in enumerate(tool_calls):
if "function" in tool:
gemini_function_call: Optional[VertexFunctionCall] = (
_gemini_tool_call_invoke_helper(
- function_call_params=tool["function"]
+ function_call_params=tool["function"],
+ tool_call_id=(
+ tool.get("id") if forward_tool_call_id else None
+ ),
)
)
if gemini_function_call is not None:
@@ -1429,10 +1449,6 @@ def convert_to_gemini_tool_call_invoke(
thought_signature = provider_fields.get("thought_signature")
# If no signature found and model is gemini-3, use dummy signature
- from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
- VertexGeminiConfig,
- )
-
if (
not thought_signature
and model
@@ -1462,6 +1478,8 @@ def convert_to_gemini_tool_call_invoke(
def convert_to_gemini_tool_call_result( # noqa: PLR0915
message: Union[ChatCompletionToolMessage, ChatCompletionFunctionMessage],
last_message_with_tool_calls: Optional[dict],
+ model: Optional[str] = None,
+ custom_llm_provider: Optional[str] = None,
) -> Union[VertexPartType, List[VertexPartType]]:
"""
OpenAI message with a tool result looks like:
@@ -1602,6 +1620,23 @@ def convert_to_gemini_tool_call_result( # noqa: PLR0915
):
name = tool.get("function", {}).get("name", "")
+ # Echo the OpenAI tool_call_id on functionResponse (strip thought-signature suffix).
+ # Only Google AI Studio Gemini 3+ accepts `id` on function_response parts.
+ # Vertex AI and older Gemini models reject the field with HTTP 400.
+ from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
+ VertexGeminiConfig,
+ )
+
+ gemini_call_id: Optional[str] = None
+ if model and VertexGeminiConfig._forward_gemini_function_call_id(
+ model, custom_llm_provider
+ ):
+ raw_tool_call_id = message.get("tool_call_id")
+ if raw_tool_call_id and isinstance(raw_tool_call_id, str):
+ stripped_id = raw_tool_call_id.split(THOUGHT_SIGNATURE_SEPARATOR, 1)[0]
+ if stripped_id:
+ gemini_call_id = stripped_id
+
if not name:
raise Exception(
"Missing corresponding tool call for tool response message. Received - message={}, last_message_with_tool_calls={}".format(
@@ -1632,6 +1667,8 @@ def convert_to_gemini_tool_call_result( # noqa: PLR0915
name=name,
response=response_data, # type: ignore
)
+ if gemini_call_id:
+ _function_response["id"] = gemini_call_id
# Create part with function_response, and optionally inline_data for images (Computer Use)
_part: VertexPartType = {"function_response": _function_response}
@@ -2057,9 +2094,16 @@ def anthropic_process_openai_file_message(
AnthropicMessagesContainerUploadParam,
]:
file_message = cast(ChatCompletionFileObject, message)
- file_data = file_message["file"].get("file_data")
- file_id = file_message["file"].get("file_id")
- format = file_message["file"].get("format")
+ file_sub = file_message.get("file")
+ if file_sub is None:
+ raise litellm.BadRequestError(
+ message="Content block has type='file' but is missing the required 'file' field",
+ model=None,
+ llm_provider="anthropic",
+ )
+ file_data = file_sub.get("file_data")
+ file_id = file_sub.get("file_id")
+ format = file_sub.get("format")
if file_data:
image_chunk = convert_to_anthropic_image_obj(
openai_image_url=file_data,
@@ -4879,7 +4923,13 @@ class BedrockConverseMessagesProcessor:
@staticmethod
def _process_file_message(message: ChatCompletionFileObject) -> BedrockContentBlock:
- file_message = message["file"]
+ file_message = message.get("file")
+ if file_message is None:
+ raise litellm.BadRequestError(
+ message="Content block has type='file' but is missing the required 'file' field",
+ model=None,
+ llm_provider="bedrock",
+ )
file_data = file_message.get("file_data")
file_id = file_message.get("file_id")
@@ -4900,7 +4950,13 @@ class BedrockConverseMessagesProcessor:
async def _async_process_file_message(
message: ChatCompletionFileObject,
) -> BedrockContentBlock:
- file_message = message["file"]
+ file_message = message.get("file")
+ if file_message is None:
+ raise litellm.BadRequestError(
+ message="Content block has type='file' but is missing the required 'file' field",
+ model=None,
+ llm_provider="bedrock",
+ )
file_data = file_message.get("file_data")
file_id = file_message.get("file_id")
format = file_message.get("format")
@@ -5534,9 +5590,7 @@ def default_response_schema_prompt(response_schema: dict) -> str:
prompt_str = """Use this JSON schema:
```json
{}
- ```""".format(
- response_schema
- )
+ ```""".format(response_schema)
return prompt_str
diff --git a/litellm/litellm_core_utils/specialty_caches/dynamic_logging_cache.py b/litellm/litellm_core_utils/specialty_caches/dynamic_logging_cache.py
index 13341f27a61..0a6a4e82c72 100644
--- a/litellm/litellm_core_utils/specialty_caches/dynamic_logging_cache.py
+++ b/litellm/litellm_core_utils/specialty_caches/dynamic_logging_cache.py
@@ -1,9 +1,9 @@
"""
This is a cache for LangfuseLoggers.
-Langfuse Python SDK initializes a thread for each client.
+Langfuse Python SDK initializes a thread for each client.
-This ensures we do
+This ensures we do
1. Proper cleanup of Langfuse initialized clients.
2. Re-use created langfuse clients.
"""
diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py
index 1ce80207552..0b56eb86d9c 100644
--- a/litellm/llms/anthropic/chat/transformation.py
+++ b/litellm/llms/anthropic/chat/transformation.py
@@ -1506,9 +1506,21 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
optional_params["metadata"] = {"user_id": value}
elif param == "thinking":
optional_params["thinking"] = value
- elif param == "reasoning_effort" and isinstance(value, str):
+ elif param == "reasoning_effort":
+ # Accept both string ("low") and dict ({"effort": "low",
+ # "summary": "concise"}). The Responses->Chat parser keeps the
+ # full dict when `summary` is set (see #25359), so a dict here
+ # is the standard shape Otto/OpenAI-Responses-Bridge callers
+ # send. Coerce to the effort string before mapping — same
+ # shape-tolerance the GPT-5 path already implements in
+ # `_normalize_reasoning_effort_for_chat_completion`.
+ effort_value = value
+ if isinstance(effort_value, dict):
+ effort_value = effort_value.get("effort")
+ if not isinstance(effort_value, str):
+ continue
mapped_thinking = AnthropicConfig._map_reasoning_effort(
- reasoning_effort=value,
+ reasoning_effort=effort_value,
model=model,
llm_provider=self.custom_llm_provider or "anthropic",
)
@@ -1519,12 +1531,12 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
optional_params["thinking"] = mapped_thinking
if AnthropicConfig._is_adaptive_thinking_model(model):
mapped_effort = REASONING_EFFORT_TO_OUTPUT_CONFIG_EFFORT.get(
- value
+ effort_value
)
if mapped_effort is None:
AnthropicConfig._raise_invalid_reasoning_effort(
model=model,
- value=value,
+ value=effort_value,
llm_provider=self.custom_llm_provider or "anthropic",
)
optional_params["output_config"] = {"effort": mapped_effort}
diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py
index 0e198daf089..51a1e739a0f 100644
--- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py
+++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py
@@ -1476,7 +1476,7 @@ class LiteLLMAnthropicMessagesAdapter:
for choice in choices:
if choice.delta.content is not None and len(choice.delta.content) > 0:
text += choice.delta.content
- if choice.delta.tool_calls is not None:
+ if choice.delta.tool_calls:
partial_json = ""
for tool in choice.delta.tool_calls:
if (
diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/agentic_streaming_iterator.py b/litellm/llms/anthropic/experimental_pass_through/messages/agentic_streaming_iterator.py
index d0780c82d06..d693d50b8e5 100644
--- a/litellm/llms/anthropic/experimental_pass_through/messages/agentic_streaming_iterator.py
+++ b/litellm/llms/anthropic/experimental_pass_through/messages/agentic_streaming_iterator.py
@@ -13,7 +13,6 @@ from typing import Any, AsyncIterator, Dict, List, Optional, cast
from litellm._logging import verbose_logger
-
# ---------------------------------------------------------------------------
# SSE parsing helpers (module-level to keep the class lean)
# ---------------------------------------------------------------------------
diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py
index 009ba6ef306..14e06e047ea 100644
--- a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py
+++ b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py
@@ -293,6 +293,12 @@ async def anthropic_messages(
api_base=api_base,
client=client,
custom_llm_provider=custom_llm_provider,
+ # messages were already empty-text-block sanitized at the top of this
+ # function and are NOT reassigned before this dispatch, so the handler
+ # can skip its (otherwise redundant) second full-messages scan. Passed
+ # explicitly (not via **kwargs) so it only affects this direct
+ # dispatch -- interceptor / sync entry points still sanitize.
+ _litellm_messages_presanitized=True,
**kwargs,
)
ctx = contextvars.copy_context()
@@ -351,10 +357,14 @@ def anthropic_messages_handler(
"""
from litellm.types.utils import LlmProviders
- # Sanitize empty text blocks here too so the sync entry point
+ # Sanitize empty text blocks so the sync entry point
# (litellm.messages.create -> anthropic_messages_handler) gets the same
- # protection as the async wrapper. Idempotent when called twice.
- messages = strip_empty_text_blocks_from_anthropic_messages(messages)
+ # protection as the async wrapper. The async wrapper already sanitized and
+ # does not reassign messages before dispatch, so it sets
+ # ``_litellm_messages_presanitized`` to skip this redundant second
+ # full-messages scan. Pop it so it never leaks into provider params.
+ if not kwargs.pop("_litellm_messages_presanitized", False):
+ messages = strip_empty_text_blocks_from_anthropic_messages(messages)
metadata = validate_anthropic_api_metadata(metadata)
diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py
index 35495d59610..15f404d3f53 100644
--- a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py
+++ b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py
@@ -312,7 +312,10 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
)
####### get required params for all anthropic messages requests ######
- verbose_logger.debug(f"TRANSFORMATION DEBUG - Messages: {messages}")
+ # Lazy %s: the f-string previously stringified the entire messages
+ # payload on every request regardless of log level (a full scan of the
+ # request body on the hot path). Defer it to when DEBUG is enabled.
+ verbose_logger.debug("TRANSFORMATION DEBUG - Messages: %s", messages)
# Auto-strip advisor blocks from history if advisor tool is absent.
# Prevents Anthropic 400: advisor_tool_result in history requires advisor tool.
diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/utils.py b/litellm/llms/anthropic/experimental_pass_through/messages/utils.py
index fa951ebd2e5..88832fb3f63 100644
--- a/litellm/llms/anthropic/experimental_pass_through/messages/utils.py
+++ b/litellm/llms/anthropic/experimental_pass_through/messages/utils.py
@@ -1,4 +1,5 @@
-from typing import Any, Dict, List, cast, get_type_hints
+from functools import lru_cache
+from typing import Any, Dict, FrozenSet, List, cast, get_type_hints
from litellm.types.llms.anthropic import AnthropicMessagesRequestOptionalParams
from litellm.types.llms.anthropic_messages.anthropic_response import (
@@ -6,6 +7,18 @@ from litellm.types.llms.anthropic_messages.anthropic_response import (
)
+@lru_cache(maxsize=1)
+def _anthropic_messages_optional_param_keys() -> FrozenSet[str]:
+ """
+ Valid AnthropicMessagesRequestOptionalParams keys.
+
+ ``typing.get_type_hints`` is ~80us/call and this TypedDict is static, so
+ resolving it once per process instead of once per request removes a fixed
+ full-pass cost from the /v1/messages request-parse path.
+ """
+ return frozenset(get_type_hints(AnthropicMessagesRequestOptionalParams).keys())
+
+
class AnthropicMessagesRequestUtils:
@staticmethod
def get_requested_anthropic_messages_optional_param(
@@ -20,7 +33,7 @@ class AnthropicMessagesRequestUtils:
Returns:
AnthropicMessagesRequestOptionalParams instance with only the valid parameters
"""
- valid_keys = get_type_hints(AnthropicMessagesRequestOptionalParams).keys()
+ valid_keys = _anthropic_messages_optional_param_keys()
filtered_params = {
k: v for k, v in params.items() if k in valid_keys and v is not None
}
diff --git a/litellm/llms/azure/audio_transcription/__init__.py b/litellm/llms/azure/audio_transcription/__init__.py
new file mode 100644
index 00000000000..cedd0c6dbeb
--- /dev/null
+++ b/litellm/llms/azure/audio_transcription/__init__.py
@@ -0,0 +1,3 @@
+from .transformation import AzureSpeechAudioTranscriptionConfig
+
+__all__ = ["AzureSpeechAudioTranscriptionConfig"]
diff --git a/litellm/llms/azure/audio_transcription/transformation.py b/litellm/llms/azure/audio_transcription/transformation.py
new file mode 100644
index 00000000000..e478c8ebf35
--- /dev/null
+++ b/litellm/llms/azure/audio_transcription/transformation.py
@@ -0,0 +1,224 @@
+"""
+Azure AI Speech (Cognitive Services) speech-to-text transformation.
+
+Maps OpenAI-compatible audio transcription calls to Azure Speech REST
+recognition for short audio.
+"""
+
+from typing import Any, Dict, List, Optional, Union
+from urllib.parse import urlencode, urlparse
+
+import httpx
+
+from litellm.litellm_core_utils.audio_utils.utils import process_audio_file
+from litellm.llms.base_llm.audio_transcription.transformation import (
+ AudioTranscriptionRequestData,
+ BaseAudioTranscriptionConfig,
+)
+from litellm.llms.base_llm.chat.transformation import BaseLLMException
+from litellm.types.llms.openai import (
+ AllMessageValues,
+ OpenAIAudioTranscriptionOptionalParams,
+)
+from litellm.secret_managers.main import get_secret_str
+from litellm.types.utils import FileTypes, TranscriptionResponse
+
+
+class AzureSpeechAudioTranscriptionException(BaseLLMException):
+ pass
+
+
+class AzureSpeechAudioTranscriptionConfig(BaseAudioTranscriptionConfig):
+ """
+ Configuration for Azure AI Speech (Cognitive Services) STT.
+
+ Reference:
+ https://learn.microsoft.com/en-us/azure/ai-services/speech-service/rest-speech-to-text-short
+ """
+
+ COGNITIVE_SERVICES_DOMAIN = "api.cognitive.microsoft.com"
+ STT_SPEECH_DOMAIN = "stt.speech.microsoft.com"
+ STT_ENDPOINT_PATH = "/speech/recognition/conversation/cognitiveservices/v1"
+ DEFAULT_LANGUAGE = "en-US"
+
+ def get_supported_openai_params(
+ self, model: str
+ ) -> List[OpenAIAudioTranscriptionOptionalParams]:
+ return ["language", "response_format"]
+
+ def map_openai_params(
+ self,
+ non_default_params: dict,
+ optional_params: dict,
+ model: str,
+ drop_params: bool,
+ ) -> dict:
+ supported_params = self.get_supported_openai_params(model=model)
+ for key, value in non_default_params.items():
+ if key in supported_params:
+ optional_params[key] = value
+ return optional_params
+
+ def validate_environment(
+ self,
+ headers: dict,
+ model: str,
+ messages: List[AllMessageValues],
+ optional_params: dict,
+ litellm_params: dict,
+ api_key: Optional[str] = None,
+ api_base: Optional[str] = None,
+ ) -> dict:
+ api_key = api_key or get_secret_str("AZURE_SPEECH_API_KEY")
+ if not api_key:
+ raise AzureSpeechAudioTranscriptionException(
+ message="api_key is required for Azure AI Speech transcription.",
+ status_code=401,
+ )
+
+ validated_headers = headers.copy()
+ validated_headers["Ocp-Apim-Subscription-Key"] = api_key
+ validated_headers["Content-Type"] = validated_headers.get(
+ "Content-Type", "audio/wav"
+ )
+ validated_headers["Accept"] = "application/json"
+ return validated_headers
+
+ def get_complete_url(
+ self,
+ api_base: Optional[str],
+ api_key: Optional[str],
+ model: str,
+ optional_params: dict,
+ litellm_params: dict,
+ stream: Optional[bool] = None,
+ ) -> str:
+ api_base = api_base or get_secret_str("AZURE_SPEECH_API_BASE")
+ if api_base is None:
+ raise AzureSpeechAudioTranscriptionException(
+ message=(
+ "api_base is required for Azure AI Speech transcription. "
+ "Use a Cognitive Services endpoint like "
+ "https://{region}.api.cognitive.microsoft.com or an STT "
+ "endpoint like https://{region}.stt.speech.microsoft.com."
+ ),
+ status_code=400,
+ )
+
+ base_url = self._resolve_stt_base_url(api_base=api_base)
+ query_params = {
+ "language": optional_params.get("language", self.DEFAULT_LANGUAGE),
+ "format": self._get_azure_response_format(
+ optional_params.get("response_format")
+ ),
+ }
+ return f"{base_url}{self.STT_ENDPOINT_PATH}?{urlencode(query_params)}"
+
+ def transform_audio_transcription_request(
+ self,
+ model: str,
+ audio_file: FileTypes,
+ optional_params: dict,
+ litellm_params: dict,
+ ) -> AudioTranscriptionRequestData:
+ processed_audio = process_audio_file(audio_file)
+ return AudioTranscriptionRequestData(
+ data=processed_audio.file_content,
+ files=None,
+ content_type=processed_audio.content_type,
+ )
+
+ def transform_audio_transcription_response(
+ self,
+ raw_response: httpx.Response,
+ ) -> TranscriptionResponse:
+ response_json = raw_response.json()
+ recognition_status = response_json.get("RecognitionStatus")
+ if recognition_status is not None and recognition_status != "Success":
+ raise AzureSpeechAudioTranscriptionException(
+ message=(
+ "Azure AI Speech transcription failed with "
+ f"RecognitionStatus={recognition_status}."
+ ),
+ status_code=raw_response.status_code,
+ headers=raw_response.headers,
+ )
+
+ text = self._extract_text(response_json)
+ response = TranscriptionResponse(text=text)
+ response._hidden_params = response_json
+ return response
+
+ def get_error_class(
+ self, error_message: str, status_code: int, headers: Union[dict, httpx.Headers]
+ ) -> BaseLLMException:
+ return AzureSpeechAudioTranscriptionException(
+ message=error_message,
+ status_code=status_code,
+ headers=headers,
+ )
+
+ def _resolve_stt_base_url(self, api_base: str) -> str:
+ api_base = api_base.rstrip("/")
+ parsed_url = urlparse(api_base)
+ hostname = parsed_url.hostname or ""
+
+ if self._is_cognitive_services_endpoint(hostname=hostname):
+ region = self._extract_region_from_hostname(
+ hostname=hostname, domain=self.COGNITIVE_SERVICES_DOMAIN
+ )
+ return self._build_stt_base_url(region=region)
+
+ if self._is_stt_endpoint(hostname=hostname):
+ return f"{parsed_url.scheme}://{hostname}"
+
+ if self._is_azure_openai_endpoint(hostname=hostname):
+ raise AzureSpeechAudioTranscriptionException(
+ message=(
+ "Azure AI Speech transcription requires a Cognitive Services "
+ "or STT Speech endpoint, not an Azure OpenAI endpoint."
+ ),
+ status_code=400,
+ )
+
+ return api_base
+
+ def _is_cognitive_services_endpoint(self, hostname: str) -> bool:
+ return hostname == self.COGNITIVE_SERVICES_DOMAIN or hostname.endswith(
+ f".{self.COGNITIVE_SERVICES_DOMAIN}"
+ )
+
+ def _is_stt_endpoint(self, hostname: str) -> bool:
+ return hostname == self.STT_SPEECH_DOMAIN or hostname.endswith(
+ f".{self.STT_SPEECH_DOMAIN}"
+ )
+
+ def _is_azure_openai_endpoint(self, hostname: str) -> bool:
+ return hostname.endswith(".openai.azure.com")
+
+ def _extract_region_from_hostname(self, hostname: str, domain: str) -> str:
+ if hostname.endswith(f".{domain}"):
+ return hostname[: -len(f".{domain}")]
+ return ""
+
+ def _build_stt_base_url(self, region: str) -> str:
+ if region:
+ return f"https://{region}.{self.STT_SPEECH_DOMAIN}"
+ return f"https://{self.STT_SPEECH_DOMAIN}"
+
+ def _get_azure_response_format(self, response_format: Optional[str]) -> str:
+ if response_format == "verbose_json":
+ return "detailed"
+ return "simple"
+
+ def _extract_text(self, response_json: Dict[str, Any]) -> str:
+ if isinstance(response_json.get("DisplayText"), str):
+ return response_json["DisplayText"]
+
+ nbest = response_json.get("NBest")
+ if isinstance(nbest, list) and nbest:
+ best = nbest[0]
+ if isinstance(best, dict):
+ return best.get("Display") or best.get("Lexical") or ""
+
+ return ""
diff --git a/litellm/llms/azure/azure.py b/litellm/llms/azure/azure.py
index 9291269d153..734b8ecef16 100644
--- a/litellm/llms/azure/azure.py
+++ b/litellm/llms/azure/azure.py
@@ -239,7 +239,9 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM):
)
data = {"model": None, "messages": messages, **optional_params}
- elif litellm.AzureOpenAIGPT5Config.is_model_gpt_5_model(model=model):
+ elif litellm.AzureOpenAIGPT5Config.is_model_gpt_5_model(
+ model=litellm_params.get("base_model") or model
+ ):
data = litellm.AzureOpenAIGPT5Config().transform_request(
model=model,
messages=messages,
diff --git a/litellm/llms/azure/chat/o_series_transformation.py b/litellm/llms/azure/chat/o_series_transformation.py
index cae7513245c..0a73597a4e4 100644
--- a/litellm/llms/azure/chat/o_series_transformation.py
+++ b/litellm/llms/azure/chat/o_series_transformation.py
@@ -4,10 +4,10 @@ Support for o1 and o3 model families
https://platform.openai.com/docs/guides/reasoning
Translations handled by LiteLLM:
-- modalities: image => drop param (if user opts in to dropping param)
-- role: system ==> translate to role 'user'
-- streaming => faked by LiteLLM
-- Tools, response_format => drop param (if user opts in to dropping param)
+- modalities: image => drop param (if user opts in to dropping param)
+- role: system ==> translate to role 'user'
+- streaming => faked by LiteLLM
+- Tools, response_format => drop param (if user opts in to dropping param)
- Logprobs => drop param (if user opts in to dropping param)
- Temperature => drop param (if user opts in to dropping param)
"""
diff --git a/litellm/llms/azure/containers/transformation.py b/litellm/llms/azure/containers/transformation.py
index 586b2e379a0..cd897511585 100644
--- a/litellm/llms/azure/containers/transformation.py
+++ b/litellm/llms/azure/containers/transformation.py
@@ -1,9 +1,16 @@
from typing import Optional
+from urllib.parse import parse_qs, urlparse, urlunparse
from litellm.llms.azure.common_utils import BaseAzureLLM
from litellm.llms.openai.containers.transformation import OpenAIContainerConfig
from litellm.types.router import GenericLiteLLMParams
+# Endpoint-specific path suffixes that may appear in a deployment's api_base
+# (e.g. the responses endpoint URL is stored as api_base for Azure models).
+# Strip these before building the containers URL so we always start from the
+# resource root (https://resource.cognitiveservices.azure.com).
+_AZURE_ENDPOINT_PATHS = ("/openai/responses",)
+
class AzureContainerConfig(OpenAIContainerConfig):
"""
@@ -27,6 +34,27 @@ class AzureContainerConfig(OpenAIContainerConfig):
litellm_params=GenericLiteLLMParams(api_key=api_key),
)
+ @staticmethod
+ def _normalize_api_base(api_base: Optional[str]) -> Optional[str]:
+ """Strip endpoint-specific path suffixes from api_base to get the resource root."""
+ if not api_base:
+ return api_base
+ parsed = urlparse(api_base)
+ path = parsed.path.rstrip("/")
+ for ep in _AZURE_ENDPOINT_PATHS:
+ if path.endswith(ep):
+ return urlunparse(
+ (parsed.scheme, parsed.netloc, path[: -len(ep)], "", "", "")
+ )
+ return api_base
+
+ @staticmethod
+ def _extract_api_version(api_base: Optional[str]) -> Optional[str]:
+ """Return the api-version query param from api_base if present."""
+ if not api_base:
+ return None
+ return parse_qs(urlparse(api_base).query).get("api-version", [None])[0]
+
def get_complete_url(
self,
api_base: Optional[str],
@@ -39,10 +67,19 @@ class AzureContainerConfig(OpenAIContainerConfig):
{endpoint}/openai/v1/containers
when api_version is 'v1', 'latest', or 'preview'; otherwise:
{endpoint}/openai/containers
+
+ The deployment's api_base may be the responses endpoint URL
+ (e.g. .../openai/responses?api-version=2025-04-01-preview). We
+ prefer the api-version embedded there over the deployment's
+ api_version field, which may point to an older chat API version.
"""
+ effective_params = dict(litellm_params)
+ api_version_from_base = self._extract_api_version(api_base)
+ if api_version_from_base:
+ effective_params["api_version"] = api_version_from_base
return BaseAzureLLM._get_base_azure_url(
- api_base=api_base,
- litellm_params=litellm_params,
+ api_base=self._normalize_api_base(api_base),
+ litellm_params=effective_params,
route="/openai/containers",
default_api_version="v1",
)
diff --git a/litellm/llms/azure_ai/embed/cohere_transformation.py b/litellm/llms/azure_ai/embed/cohere_transformation.py
index 64433c21b61..bbbfb60fbde 100644
--- a/litellm/llms/azure_ai/embed/cohere_transformation.py
+++ b/litellm/llms/azure_ai/embed/cohere_transformation.py
@@ -1,5 +1,5 @@
"""
-Transformation logic from OpenAI /v1/embeddings format to Azure AI Cohere's /v1/embed.
+Transformation logic from OpenAI /v1/embeddings format to Azure AI Cohere's /v1/embed.
Why separate file? Make it easy to see how transformation works
diff --git a/litellm/llms/azure_ai/rerank/transformation.py b/litellm/llms/azure_ai/rerank/transformation.py
index b5993040ea0..f64133afa8b 100644
--- a/litellm/llms/azure_ai/rerank/transformation.py
+++ b/litellm/llms/azure_ai/rerank/transformation.py
@@ -1,5 +1,5 @@
"""
-Translate between Cohere's `/rerank` format and Azure AI's `/rerank` format.
+Translate between Cohere's `/rerank` format and Azure AI's `/rerank` format.
"""
from typing import Optional
diff --git a/litellm/llms/base_llm/agents/__init__.py b/litellm/llms/base_llm/agents/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/litellm/llms/base_llm/agents/transformation.py b/litellm/llms/base_llm/agents/transformation.py
new file mode 100644
index 00000000000..508e54cb7ab
--- /dev/null
+++ b/litellm/llms/base_llm/agents/transformation.py
@@ -0,0 +1,165 @@
+"""
+Base transformation class for provider-side Agents API.
+
+Providers that have a native agents CRUD API (e.g. Gemini v1beta/agents)
+subclass BaseAgentsAPIConfig and implement the abstract methods.
+
+The HTTP calls are handled by AgentsHTTPHandler — this class is pure
+transform logic (same separation as BaseInteractionsAPIConfig /
+InteractionsHTTPHandler).
+"""
+
+from abc import ABC, abstractmethod
+from typing import Any, Dict, Optional, Tuple, Union
+
+import httpx
+
+from litellm.types.agents import (
+ AgentCreateResponse,
+ AgentDeleteResult,
+ AgentListResponse,
+ AgentVersionsResponse,
+)
+
+
+class BaseAgentsAPIConfig(ABC):
+ """
+ Minimal interface for providers that expose a native agents CRUD API.
+ """
+
+ # ------------------------------------------------------------------ #
+ # CREATE #
+ # ------------------------------------------------------------------ #
+
+ @abstractmethod
+ def get_complete_url(
+ self,
+ api_base: Optional[str],
+ litellm_params: Dict[str, Any],
+ ) -> str:
+ """Return the full URL for POST /agents (create)."""
+
+ @abstractmethod
+ def validate_environment(
+ self,
+ headers: Dict[str, str],
+ litellm_params: Dict[str, Any],
+ ) -> Dict[str, str]:
+ """Validate credentials and return auth headers."""
+
+ @abstractmethod
+ def transform_create_request(
+ self,
+ name: str,
+ litellm_params: Dict[str, Any],
+ ) -> Dict[str, Any]:
+ """Map name + litellm_params to the provider's create-agent body."""
+
+ @abstractmethod
+ def transform_create_response(
+ self,
+ raw_response: httpx.Response,
+ name: str,
+ ) -> AgentCreateResponse:
+ """Parse create response. Raise on non-2xx."""
+
+ # ------------------------------------------------------------------ #
+ # LIST #
+ # ------------------------------------------------------------------ #
+
+ @abstractmethod
+ def transform_list_request(
+ self,
+ api_base: Optional[str],
+ litellm_params: Dict[str, Any],
+ ) -> Tuple[str, Dict[str, Any]]:
+ """Return (url, query_params) for GET /agents."""
+
+ @abstractmethod
+ def transform_list_response(
+ self,
+ raw_response: httpx.Response,
+ ) -> AgentListResponse:
+ """Parse list-agents response. Raise on non-2xx."""
+
+ # ------------------------------------------------------------------ #
+ # GET #
+ # ------------------------------------------------------------------ #
+
+ @abstractmethod
+ def transform_get_request(
+ self,
+ name: str,
+ api_base: Optional[str],
+ litellm_params: Dict[str, Any],
+ ) -> Tuple[str, Dict[str, Any]]:
+ """Return (url, query_params) for GET /agents/{name}."""
+
+ @abstractmethod
+ def transform_get_response(
+ self,
+ raw_response: httpx.Response,
+ name: str,
+ ) -> AgentCreateResponse:
+ """Parse get-agent response. Raise on non-2xx."""
+
+ # ------------------------------------------------------------------ #
+ # DELETE #
+ # ------------------------------------------------------------------ #
+
+ @abstractmethod
+ def transform_delete_request(
+ self,
+ name: str,
+ api_base: Optional[str],
+ litellm_params: Dict[str, Any],
+ ) -> str:
+ """Return the URL for DELETE /agents/{name}."""
+
+ @abstractmethod
+ def transform_delete_response(
+ self,
+ raw_response: httpx.Response,
+ name: str,
+ ) -> AgentDeleteResult:
+ """Parse delete-agent response. Raise on non-2xx."""
+
+ # ------------------------------------------------------------------ #
+ # LIST VERSIONS #
+ # ------------------------------------------------------------------ #
+
+ @abstractmethod
+ def transform_list_versions_request(
+ self,
+ name: str,
+ api_base: Optional[str],
+ litellm_params: Dict[str, Any],
+ ) -> Tuple[str, Dict[str, Any]]:
+ """Return (url, query_params) for GET /agents/{name}/versions."""
+
+ @abstractmethod
+ def transform_list_versions_response(
+ self,
+ raw_response: httpx.Response,
+ name: str,
+ ) -> AgentVersionsResponse:
+ """Parse list-versions response. Raise on non-2xx."""
+
+ # ------------------------------------------------------------------ #
+ # ERROR HANDLING #
+ # ------------------------------------------------------------------ #
+
+ def get_error_class(
+ self,
+ error_message: str,
+ status_code: int,
+ headers: Union[dict, httpx.Headers],
+ ) -> Exception:
+ """Map HTTP error status codes to provider-specific exceptions."""
+ from litellm.llms.base_llm.chat.transformation import BaseLLMException
+
+ return BaseLLMException(
+ status_code=status_code,
+ message=error_message,
+ headers=headers,
+ )
diff --git a/litellm/llms/base_llm/ocr/transformation.py b/litellm/llms/base_llm/ocr/transformation.py
index b7f4d8e3b2d..263e0c094ce 100644
--- a/litellm/llms/base_llm/ocr/transformation.py
+++ b/litellm/llms/base_llm/ocr/transformation.py
@@ -54,6 +54,7 @@ class OCRUsageInfo(LiteLLMPydanticObjectBase):
"""Usage information from OCR response."""
pages_processed: Optional[int] = None
+ credits: Optional[float] = None
doc_size_bytes: Optional[int] = None
model_config = {"extra": "allow"}
diff --git a/litellm/llms/bedrock/base_aws_llm.py b/litellm/llms/bedrock/base_aws_llm.py
index 9dd2b055a12..b659c1b0a0a 100644
--- a/litellm/llms/bedrock/base_aws_llm.py
+++ b/litellm/llms/bedrock/base_aws_llm.py
@@ -44,6 +44,12 @@ else:
# (e.g. "us-east-1", "eu-west-2", "us-gov-west-1", "cn-north-1").
_VALID_AWS_REGION_PATTERN = re.compile(r"\A[a-z0-9-]+\Z")
+# Regional STS hostnames, e.g. sts.eu-west-1.amazonaws.com or
+# vpce-xxx.sts.eu-west-1.vpce.amazonaws.com
+_STS_REGION_FROM_ENDPOINT_PATTERN = re.compile(
+ r"(?:^|\.)sts(?:-fips)?\.([a-z0-9-]+)\.(?:amazonaws\.com(?:\.cn)?|vpce\.amazonaws\.com)"
+)
+
class Boto3CredentialsInfo(BaseModel):
credentials: Credentials
@@ -450,6 +456,24 @@ class BaseAWSLLM:
model_id = BaseAWSLLM.encode_model_id(model_id=model_id)
else:
model_id = model
+ # Strip LiteLLM routing prefixes (e.g. "bedrock/", "invoke/",
+ # "bedrock/invoke/", "bedrock/converse/") that are not part of the
+ # actual Bedrock model ID. The converse path already does this; the
+ # invoke path must do the same so that ARN models such as
+ # bedrock/arn:aws:bedrock:…:inference-profile/global.anthropic.…
+ # are not forwarded verbatim to the Bedrock API, which would produce
+ # a malformed URL and cause botocore's EventStreamBuffer to receive
+ # a JSON error body instead of a binary event-stream — surfaced as a
+ # misleading ChecksumMismatch (0x223a7b22 == ':{"').
+ # Use strip_bedrock_routing_prefix (no break) so compound prefixes
+ # like "bedrock/invoke/arn:..." are fully stripped in one call.
+ from litellm.llms.bedrock.common_utils import strip_bedrock_routing_prefix
+
+ model_id = strip_bedrock_routing_prefix(model_id)
+ # URL-encode ARNs so colons and slashes are safe in the URL path.
+ if model_id.startswith("arn:"):
+ model_id = BaseAWSLLM.encode_model_id(model_id=model_id)
+ return model_id
model_id = model_id.replace("invoke/", "", 1)
if provider == "llama" and "llama/" in model_id:
@@ -633,6 +657,40 @@ class BaseAWSLLM:
"Region names must contain only lowercase letters, digits, and hyphens."
)
+ @staticmethod
+ def _parse_sts_region_from_endpoint(
+ aws_sts_endpoint: Optional[str],
+ ) -> Optional[str]:
+ """Extract region from sts.{region}.amazonaws.com or vpce-x.sts.{region}.vpce.amazonaws.com."""
+ if not aws_sts_endpoint:
+ return None
+ host = urllib.parse.urlparse(aws_sts_endpoint).hostname or ""
+ match = _STS_REGION_FROM_ENDPOINT_PATTERN.search(host)
+ return match.group(1) if match else None
+
+ @staticmethod
+ def _resolve_sts_region(aws_sts_endpoint: Optional[str] = None) -> Optional[str]:
+ """STS signing region: parsed from aws_sts_endpoint else AWS_REGION / AWS_DEFAULT_REGION."""
+ return (
+ BaseAWSLLM._parse_sts_region_from_endpoint(aws_sts_endpoint)
+ or os.getenv("AWS_REGION")
+ or os.getenv("AWS_DEFAULT_REGION")
+ )
+
+ def _build_sts_client_kwargs(
+ self,
+ aws_sts_endpoint: Optional[str] = None,
+ ssl_verify: Optional[Union[bool, str]] = None,
+ ) -> dict:
+ """STS client kwargs with aligned endpoint_url and region_name (SigV4)."""
+ kwargs: dict = {"verify": self._get_ssl_verify(ssl_verify)}
+ if aws_sts_endpoint is not None:
+ kwargs["endpoint_url"] = aws_sts_endpoint
+ sts_region = self._resolve_sts_region(aws_sts_endpoint)
+ if sts_region is not None:
+ kwargs["region_name"] = sts_region
+ return kwargs
+
def get_aws_region_name_for_non_llm_api_calls(
self,
aws_region_name: Optional[str] = None,
@@ -787,11 +845,6 @@ class BaseAWSLLM:
f"IN Web Identity Token: {aws_web_identity_token} | Role Name: {aws_role_name} | Session Name: {aws_session_name}"
)
- if aws_sts_endpoint is None:
- sts_endpoint = f"https://sts.{aws_region_name}.amazonaws.com"
- else:
- sts_endpoint = aws_sts_endpoint
-
oidc_token = get_secret(aws_web_identity_token)
if oidc_token is None:
@@ -800,13 +853,13 @@ class BaseAWSLLM:
status_code=401,
)
+ sts_client_kwargs = self._build_sts_client_kwargs(
+ aws_sts_endpoint=aws_sts_endpoint,
+ ssl_verify=ssl_verify,
+ )
+
with tracer.trace("boto3.client(sts)"):
- sts_client = boto3.client(
- "sts",
- region_name=aws_region_name,
- endpoint_url=sts_endpoint,
- verify=self._get_ssl_verify(ssl_verify),
- )
+ sts_client = boto3.client("sts", **sts_client_kwargs)
# https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html
# https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/sts/client/assume_role_with_web_identity.html
@@ -847,7 +900,6 @@ class BaseAWSLLM:
irsa_role_arn: str,
aws_role_name: str,
aws_session_name: str,
- region: str,
web_identity_token_file: str,
aws_external_id: Optional[str] = None,
aws_sts_endpoint: Optional[str] = None,
@@ -862,12 +914,10 @@ class BaseAWSLLM:
with open(web_identity_token_file, "r") as f:
web_identity_token = f.read().strip()
- irsa_sts_kwargs: dict = {
- "region_name": region,
- "verify": self._get_ssl_verify(ssl_verify),
- }
- if aws_sts_endpoint is not None:
- irsa_sts_kwargs["endpoint_url"] = aws_sts_endpoint
+ irsa_sts_kwargs = self._build_sts_client_kwargs(
+ aws_sts_endpoint=aws_sts_endpoint,
+ ssl_verify=ssl_verify,
+ )
# Create an STS client without credentials
with tracer.trace("boto3.client(sts) for manual IRSA"):
@@ -924,7 +974,6 @@ class BaseAWSLLM:
self,
aws_role_name: str,
aws_session_name: str,
- region: str,
aws_external_id: Optional[str] = None,
aws_sts_endpoint: Optional[str] = None,
ssl_verify: Optional[Union[bool, str]] = None,
@@ -932,12 +981,10 @@ class BaseAWSLLM:
"""Handle same-account role assumption for IRSA."""
import boto3
- irsa_sts_kwargs: dict = {
- "region_name": region,
- "verify": self._get_ssl_verify(ssl_verify),
- }
- if aws_sts_endpoint is not None:
- irsa_sts_kwargs["endpoint_url"] = aws_sts_endpoint
+ irsa_sts_kwargs = self._build_sts_client_kwargs(
+ aws_sts_endpoint=aws_sts_endpoint,
+ ssl_verify=ssl_verify,
+ )
verbose_logger.debug("Same account role assumption, using automatic IRSA")
with tracer.trace("boto3.client(sts) with automatic IRSA"):
@@ -1010,12 +1057,6 @@ class BaseAWSLLM:
web_identity_token_file = os.getenv("AWS_WEB_IDENTITY_TOKEN_FILE")
irsa_role_arn = os.getenv("AWS_ROLE_ARN")
- region = (
- aws_region_name
- or os.getenv("AWS_REGION")
- or os.getenv("AWS_DEFAULT_REGION")
- )
-
# If we have IRSA environment variables and no explicit credentials,
# we need to use the web identity token flow
if (
@@ -1031,16 +1072,12 @@ class BaseAWSLLM:
)
try:
- # Use passed-in region when set, else env, else default (align with AssumeRole path)
- region = region or "us-east-1"
-
# Check if we need to do cross-account role assumption
if aws_role_name != irsa_role_arn:
sts_response = self._handle_irsa_cross_account(
irsa_role_arn,
aws_role_name,
aws_session_name,
- region,
web_identity_token_file,
aws_external_id,
aws_sts_endpoint=aws_sts_endpoint,
@@ -1050,7 +1087,6 @@ class BaseAWSLLM:
sts_response = self._handle_irsa_same_account(
aws_role_name,
aws_session_name,
- region,
aws_external_id,
aws_sts_endpoint=aws_sts_endpoint,
ssl_verify=ssl_verify,
@@ -1074,11 +1110,10 @@ class BaseAWSLLM:
# In EKS/IRSA environments, use ambient credentials (no explicit keys needed)
# This allows the web identity token to work automatically
- sts_client_kwargs: dict = {"verify": self._get_ssl_verify(ssl_verify)}
- if region is not None:
- sts_client_kwargs["region_name"] = region
- if aws_sts_endpoint is not None:
- sts_client_kwargs["endpoint_url"] = aws_sts_endpoint
+ sts_client_kwargs = self._build_sts_client_kwargs(
+ aws_sts_endpoint=aws_sts_endpoint,
+ ssl_verify=ssl_verify,
+ )
if aws_access_key_id is None and aws_secret_access_key is None:
with tracer.trace("boto3.client(sts)"):
sts_client = boto3.client("sts", **sts_client_kwargs)
diff --git a/litellm/llms/bedrock/batches/transformation.py b/litellm/llms/bedrock/batches/transformation.py
index 0602b1c2f62..620bc91732d 100644
--- a/litellm/llms/bedrock/batches/transformation.py
+++ b/litellm/llms/bedrock/batches/transformation.py
@@ -5,6 +5,7 @@ from typing import Any, Dict, List, Literal, Optional, Union, cast
from httpx import Headers, Response
+from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.llms.base_llm.batches.transformation import BaseBatchesConfig
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.secret_managers.main import get_secret_str
@@ -263,9 +264,32 @@ class BedrockBatchesConfig(BaseAWSLLM, BaseBatchesConfig):
cancelling_at=None,
cancelled_at=None,
request_counts=None,
- metadata=original_request.get("metadata", {}),
+ metadata=self._get_openai_compatible_batch_metadata(
+ original_request.get("metadata", {})
+ ),
)
+ @staticmethod
+ def _get_openai_compatible_batch_metadata(metadata: Any) -> Dict[str, str]:
+ """
+ OpenAI Batch metadata only accepts string values.
+ """
+ if not isinstance(metadata, dict):
+ return {}
+
+ sanitized_metadata: Dict[str, str] = {}
+ for key, value in metadata.items():
+ if key == "standard_logging_guardrail_information" or value is None:
+ continue
+
+ str_key = str(key)
+ if isinstance(value, str):
+ sanitized_metadata[str_key] = value
+ else:
+ sanitized_metadata[str_key] = safe_dumps(value)
+
+ return sanitized_metadata
+
def transform_retrieve_batch_request(
self,
batch_id: str,
diff --git a/litellm/llms/bedrock/chat/invoke_agent/transformation.py b/litellm/llms/bedrock/chat/invoke_agent/transformation.py
index e4072c24557..c88fa32b6a0 100644
--- a/litellm/llms/bedrock/chat/invoke_agent/transformation.py
+++ b/litellm/llms/bedrock/chat/invoke_agent/transformation.py
@@ -299,9 +299,9 @@ class AmazonInvokeAgentConfig(BaseConfig, BaseAWSLLM):
)
def _get_response_stream_shape(self):
- from litellm.llms.bedrock.common_utils import BEDROCK_RESPONSE_STREAM_SHAPE
+ from litellm.llms.bedrock.common_utils import get_bedrock_response_stream_shape
- return BEDROCK_RESPONSE_STREAM_SHAPE
+ return get_bedrock_response_stream_shape()
def _extract_response_content(self, events: InvokeAgentEventList) -> str:
"""Extract the final response content from parsed events."""
diff --git a/litellm/llms/bedrock/chat/invoke_handler.py b/litellm/llms/bedrock/chat/invoke_handler.py
index 92ca75db95b..7a9916f1f31 100644
--- a/litellm/llms/bedrock/chat/invoke_handler.py
+++ b/litellm/llms/bedrock/chat/invoke_handler.py
@@ -68,9 +68,9 @@ from litellm.utils import CustomStreamWrapper, get_secret
from ..base_aws_llm import BaseAWSLLM
from ..common_utils import (
- BEDROCK_RESPONSE_STREAM_SHAPE,
BedrockError,
ModelResponseIterator,
+ get_bedrock_response_stream_shape,
get_bedrock_tool_name,
)
@@ -1828,7 +1828,8 @@ class AWSEventStreamDecoder:
yield self._chunk_parser(chunk_data=_data)
def _parse_message_from_event(self, event) -> Optional[str]:
- if BEDROCK_RESPONSE_STREAM_SHAPE is None:
+ response_stream_shape = get_bedrock_response_stream_shape()
+ if response_stream_shape is None:
raise BedrockError(
status_code=500,
message=(
@@ -1837,9 +1838,7 @@ class AWSEventStreamDecoder:
),
)
response_dict = event.to_response_dict()
- parsed_response = self.parser.parse(
- response_dict, BEDROCK_RESPONSE_STREAM_SHAPE
- )
+ parsed_response = self.parser.parse(response_dict, response_stream_shape)
if response_dict["status_code"] != 200:
decoded_body = response_dict["body"].decode()
diff --git a/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py b/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py
index c883ab68dff..d9599b8b9c4 100644
--- a/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py
+++ b/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py
@@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Any, List, Optional
import httpx
from litellm.anthropic_beta_headers_manager import filter_and_transform_beta_headers
+from litellm.litellm_core_utils.litellm_logging import verbose_logger
from litellm.litellm_core_utils.prompt_templates.factory import (
convert_to_anthropic_image_obj,
)
@@ -22,6 +23,7 @@ from litellm.llms.bedrock.common_utils import (
from litellm.types.llms.anthropic import ANTHROPIC_TOOL_SEARCH_BETA_HEADER
from litellm.types.llms.openai import AllMessageValues
from litellm.types.utils import ModelResponse
+from litellm.utils import _supports_factory
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
@@ -169,6 +171,24 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig):
anthropic_request.pop("model", None)
anthropic_request.pop("stream", None)
anthropic_request.pop("output_format", None)
+ if not (
+ _supports_factory(
+ model=model,
+ custom_llm_provider="bedrock",
+ key="supports_output_config",
+ )
+ or AnthropicConfig._model_supports_effort_param(model)
+ ):
+ if anthropic_request.pop("output_config", None) is not None:
+ verbose_logger.warning(
+ "Bedrock Invoke: stripping unsupported `output_config` for "
+ "model=%s — neither `supports_output_config` nor any "
+ "`supports_*_reasoning_effort` flag is set in "
+ "model_prices_and_context_window.json. Add the capability "
+ "flag to the model JSON entry if this model accepts "
+ "`output_config`.",
+ model,
+ )
if "anthropic_version" not in anthropic_request:
anthropic_request["anthropic_version"] = self.anthropic_version
diff --git a/litellm/llms/bedrock/claude_platform/common_utils.py b/litellm/llms/bedrock/claude_platform/common_utils.py
index 121221518c8..3abb8710de7 100644
--- a/litellm/llms/bedrock/claude_platform/common_utils.py
+++ b/litellm/llms/bedrock/claude_platform/common_utils.py
@@ -4,7 +4,6 @@ import litellm
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
from litellm.secret_managers.main import get_secret_str
-
CLAUDE_PLATFORM_SERVICE_NAME: Literal["aws-external-anthropic"] = (
"aws-external-anthropic"
)
diff --git a/litellm/llms/bedrock/common_utils.py b/litellm/llms/bedrock/common_utils.py
index 0256d5d4b95..4f4729e4019 100644
--- a/litellm/llms/bedrock/common_utils.py
+++ b/litellm/llms/bedrock/common_utils.py
@@ -4,6 +4,7 @@ from __future__ import annotations
Common utilities used across bedrock chat/embedding/image generation
"""
+import functools
import json
import os
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union
@@ -963,10 +964,8 @@ def _load_bedrock_response_stream_shape():
"""
Load the ResponseStream shape from botocore's bundled bedrock-runtime schema.
- Called once at module import time; the result is stored in
- ``BEDROCK_RESPONSE_STREAM_SHAPE`` and reused for the process lifetime.
Returns ``None`` if botocore is unavailable or the service model cannot be
- loaded, so the module still imports cleanly.
+ loaded.
"""
try:
from botocore.loaders import Loader
@@ -977,15 +976,22 @@ def _load_bedrock_response_stream_shape():
return ServiceModel(service_dict).shape_for("ResponseStream")
except Exception as e:
verbose_logger.warning(
- "litellm: could not pre-load bedrock-runtime response stream shape "
+ "litellm: could not load bedrock-runtime response stream shape "
"— Bedrock event-stream decoding will be unavailable. Error: %s",
e,
)
return None
-# Eagerly resolved once per process — avoids per-instance or per-request disk I/O.
-BEDROCK_RESPONSE_STREAM_SHAPE = _load_bedrock_response_stream_shape()
+@functools.lru_cache(maxsize=1)
+def get_bedrock_response_stream_shape():
+ """
+ Lazily load and cache the bedrock-runtime ResponseStream shape for the process.
+
+ Avoids importing botocore (and logging warnings) unless Bedrock event-stream
+ decoding is actually needed.
+ """
+ return _load_bedrock_response_stream_shape()
class BedrockEventStreamDecoderBase:
@@ -999,7 +1005,8 @@ class BedrockEventStreamDecoderBase:
self.parser = EventStreamJSONParser()
def _parse_message_from_event(self, event) -> Optional[str]:
- if BEDROCK_RESPONSE_STREAM_SHAPE is None:
+ response_stream_shape = get_bedrock_response_stream_shape()
+ if response_stream_shape is None:
raise BedrockError(
status_code=500,
message=(
@@ -1008,9 +1015,7 @@ class BedrockEventStreamDecoderBase:
),
)
response_dict = event.to_response_dict()
- parsed_response = self.parser.parse(
- response_dict, BEDROCK_RESPONSE_STREAM_SHAPE
- )
+ parsed_response = self.parser.parse(response_dict, response_stream_shape)
if response_dict["status_code"] != 200:
decoded_body = response_dict["body"].decode()
diff --git a/litellm/llms/bedrock/embed/amazon_titan_g1_transformation.py b/litellm/llms/bedrock/embed/amazon_titan_g1_transformation.py
index 2747551af81..64a79b73273 100644
--- a/litellm/llms/bedrock/embed/amazon_titan_g1_transformation.py
+++ b/litellm/llms/bedrock/embed/amazon_titan_g1_transformation.py
@@ -1,5 +1,5 @@
"""
-Transformation logic from OpenAI /v1/embeddings format to Bedrock Amazon Titan G1 /invoke format.
+Transformation logic from OpenAI /v1/embeddings format to Bedrock Amazon Titan G1 /invoke format.
Why separate file? Make it easy to see how transformation works
diff --git a/litellm/llms/bedrock/embed/cohere_transformation.py b/litellm/llms/bedrock/embed/cohere_transformation.py
index d00cb74aae0..9570ff1a14c 100644
--- a/litellm/llms/bedrock/embed/cohere_transformation.py
+++ b/litellm/llms/bedrock/embed/cohere_transformation.py
@@ -1,5 +1,5 @@
"""
-Transformation logic from OpenAI /v1/embeddings format to Bedrock Cohere /invoke format.
+Transformation logic from OpenAI /v1/embeddings format to Bedrock Cohere /invoke format.
Why separate file? Make it easy to see how transformation works
"""
@@ -22,7 +22,7 @@ class BedrockCohereEmbeddingConfig:
) -> dict:
for k, v in non_default_params.items():
if k == "encoding_format":
- optional_params["embedding_types"] = v
+ optional_params["embedding_types"] = v if isinstance(v, list) else [v]
elif k == "dimensions":
optional_params["output_dimension"] = v
return optional_params
diff --git a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py
index 151e0e404a0..69b61298d33 100644
--- a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py
+++ b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py
@@ -45,6 +45,7 @@ from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import GenericStreamingChunk
from litellm.types.utils import GenericStreamingChunk as GChunk
from litellm.types.utils import ModelResponseStream
+from litellm.utils import _supports_factory
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
@@ -557,7 +558,29 @@ class AmazonAnthropicClaudeMessagesConfig(
anthropic_messages_request=anthropic_messages_request,
)
- # 5a. Remove `custom` field from tools (Bedrock doesn't support it)
+ # 5a. Bedrock Invoke supports output_config (effort) for Claude 4.6+ models,
+ # but older models do not — strip it to avoid request rejection.
+ # Ref: https://github.com/BerriAI/litellm/issues/22797
+ if not (
+ _supports_factory(
+ model=model,
+ custom_llm_provider="bedrock",
+ key="supports_output_config",
+ )
+ or AnthropicConfig._model_supports_effort_param(model)
+ ):
+ if anthropic_messages_request.pop("output_config", None) is not None:
+ verbose_logger.warning(
+ "Bedrock Invoke: stripping unsupported `output_config` for "
+ "model=%s — neither `supports_output_config` nor any "
+ "`supports_*_reasoning_effort` flag is set in "
+ "model_prices_and_context_window.json. Add the capability "
+ "flag to the model JSON entry if this model accepts "
+ "`output_config`.",
+ model,
+ )
+
+ # 5b. Remove `custom` field from tools (Bedrock doesn't support it)
# Claude Code sends `custom: {defer_loading: true}` on tool definitions,
# which causes Bedrock to reject the request with "Extra inputs are not permitted"
# Ref: https://github.com/BerriAI/litellm/issues/22847
diff --git a/litellm/llms/bedrock_mantle/chat/transformation.py b/litellm/llms/bedrock_mantle/chat/transformation.py
index e413bb22b2d..81a56030a5c 100644
--- a/litellm/llms/bedrock_mantle/chat/transformation.py
+++ b/litellm/llms/bedrock_mantle/chat/transformation.py
@@ -16,7 +16,6 @@ from litellm.secret_managers.main import get_secret_str
from ...openai_like.chat.transformation import OpenAILikeChatConfig
-
BEDROCK_MANTLE_DEFAULT_REGION = "us-east-1"
diff --git a/litellm/llms/chatgpt/responses/transformation.py b/litellm/llms/chatgpt/responses/transformation.py
index 66acd933416..56b61b66c84 100644
--- a/litellm/llms/chatgpt/responses/transformation.py
+++ b/litellm/llms/chatgpt/responses/transformation.py
@@ -1,7 +1,5 @@
-import json
-from typing import Any, Optional
+from typing import Any, Dict, Optional
-from litellm.constants import STREAM_SSE_DONE_STRING
from litellm.exceptions import AuthenticationError
from litellm.litellm_core_utils.core_helpers import process_response_headers
from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import (
@@ -9,13 +7,17 @@ from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response impo
)
from litellm.llms.openai.common_utils import OpenAIError
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
+from litellm.responses.sse_output_recovery import (
+ parse_sse_json_chunk,
+ record_output_item_chunk,
+ record_output_text_chunk,
+)
from litellm.types.llms.openai import (
ResponsesAPIResponse,
ResponsesAPIStreamEvents,
)
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import LlmProviders
-from litellm.utils import CustomStreamWrapper
from ..authenticator import Authenticator
from ..common_utils import (
@@ -111,86 +113,139 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig):
raw_response: Any,
logging_obj: Any,
):
- content_type = (raw_response.headers or {}).get("content-type", "")
body_text = raw_response.text or ""
- if "text/event-stream" not in content_type.lower():
- trimmed_body = body_text.lstrip()
- if not (
- trimmed_body.startswith("event:")
- or trimmed_body.startswith("data:")
- or "\nevent:" in body_text
- or "\ndata:" in body_text
- ):
- return super().transform_response_api_response(
- model=model,
- raw_response=raw_response,
- logging_obj=logging_obj,
- )
+ if not self._should_parse_as_sse(
+ raw_response=raw_response, body_text=body_text
+ ):
+ return super().transform_response_api_response(
+ model=model,
+ raw_response=raw_response,
+ logging_obj=logging_obj,
+ )
logging_obj.post_call(
original_response=raw_response.text,
additional_args={"complete_input_dict": {}},
)
- completed_response = None
- error_message = None
- for chunk in body_text.splitlines():
- stripped_chunk = CustomStreamWrapper._strip_sse_data_from_chunk(chunk)
- if not stripped_chunk:
- continue
- stripped_chunk = stripped_chunk.strip()
- if not stripped_chunk:
- continue
- if stripped_chunk == STREAM_SSE_DONE_STRING:
- break
- try:
- parsed_chunk = json.loads(stripped_chunk)
- except json.JSONDecodeError:
- continue
- if not isinstance(parsed_chunk, dict):
- continue
- event_type = parsed_chunk.get("type")
- if event_type == ResponsesAPIStreamEvents.RESPONSE_COMPLETED:
- response_payload = parsed_chunk.get("response")
- if isinstance(response_payload, dict):
- response_payload = dict(response_payload)
- if "created_at" in response_payload:
- response_payload["created_at"] = _safe_convert_created_field(
- response_payload["created_at"]
- )
- try:
- completed_response = ResponsesAPIResponse(**response_payload)
- except Exception:
- completed_response = ResponsesAPIResponse.model_construct(
- **response_payload
- )
- break
- if event_type in (
- ResponsesAPIStreamEvents.RESPONSE_FAILED,
- ResponsesAPIStreamEvents.ERROR,
- ):
- error_obj = parsed_chunk.get("error") or (
- parsed_chunk.get("response") or {}
- ).get("error")
- if error_obj is not None:
- if isinstance(error_obj, dict):
- error_message = error_obj.get("message") or str(error_obj)
- else:
- error_message = str(error_obj)
-
+ completed_response, error_message = self._extract_completed_response_from_sse(
+ body_text=body_text
+ )
if completed_response is None:
raise OpenAIError(
message=error_message or raw_response.text,
status_code=raw_response.status_code,
)
+ self._attach_response_headers(
+ completed_response=completed_response, raw_response=raw_response
+ )
+ return completed_response
+
+ def _should_parse_as_sse(self, raw_response: Any, body_text: str) -> bool:
+ content_type = (raw_response.headers or {}).get("content-type", "")
+ if "text/event-stream" in content_type.lower():
+ return True
+ trimmed_body = body_text.lstrip()
+ return bool(
+ trimmed_body.startswith("event:")
+ or trimmed_body.startswith("data:")
+ or "\nevent:" in body_text
+ or "\ndata:" in body_text
+ )
+
+ def _extract_completed_response_from_sse(
+ self, body_text: str
+ ) -> tuple[Optional[ResponsesAPIResponse], Optional[str]]:
+ completed_response = None
+ error_message = None
+ streamed_output_items: Dict[int, dict] = {}
+ text_only_output_items: Dict[int, dict] = {}
+ for chunk in body_text.splitlines():
+ parsed_chunk = parse_sse_json_chunk(chunk)
+ if parsed_chunk is None:
+ continue
+
+ event_type = parsed_chunk.get("type")
+ if event_type == ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE:
+ record_output_item_chunk(
+ parsed_chunk=parsed_chunk,
+ output_items=streamed_output_items,
+ )
+ continue
+
+ if event_type == ResponsesAPIStreamEvents.OUTPUT_TEXT_DONE:
+ record_output_text_chunk(
+ parsed_chunk=parsed_chunk,
+ output_items=streamed_output_items,
+ text_only_items=text_only_output_items,
+ )
+ continue
+
+ if event_type == ResponsesAPIStreamEvents.RESPONSE_COMPLETED:
+ # Real OUTPUT_ITEM_DONE events take precedence at any given
+ # output_index, but text-only items at indices without a
+ # matching OUTPUT_ITEM_DONE must still be preserved (e.g.
+ # providers that emit only OUTPUT_TEXT_DONE for some indices).
+ merged_items: Dict[int, dict] = {**text_only_output_items}
+ merged_items.update(streamed_output_items)
+ completed_response = self._build_completed_response_from_chunk(
+ parsed_chunk=parsed_chunk,
+ streamed_output_items=merged_items,
+ )
+ break
+
+ if event_type in (
+ ResponsesAPIStreamEvents.RESPONSE_FAILED,
+ ResponsesAPIStreamEvents.ERROR,
+ ):
+ extracted_error = self._extract_error_message(parsed_chunk)
+ if extracted_error is not None:
+ error_message = extracted_error
+
+ return completed_response, error_message
+
+ def _build_completed_response_from_chunk(
+ self, parsed_chunk: Dict[str, Any], streamed_output_items: Dict[int, dict]
+ ) -> Optional[ResponsesAPIResponse]:
+ response_payload = parsed_chunk.get("response")
+ if not isinstance(response_payload, dict):
+ return None
+ response_payload = dict(response_payload)
+ if not response_payload.get("output") and streamed_output_items:
+ response_payload["output"] = [
+ item for _, item in sorted(streamed_output_items.items())
+ ]
+ if "created_at" in response_payload:
+ response_payload["created_at"] = _safe_convert_created_field(
+ response_payload["created_at"]
+ )
+ try:
+ return ResponsesAPIResponse(**response_payload)
+ except Exception:
+ return ResponsesAPIResponse.model_construct(**response_payload)
+
+ def _extract_error_message(self, parsed_chunk: Dict[str, Any]) -> Optional[str]:
+ error_obj = parsed_chunk.get("error") or (
+ parsed_chunk.get("response") or {}
+ ).get("error")
+ if error_obj is None:
+ return None
+ if isinstance(error_obj, dict):
+ return error_obj.get("message") or str(error_obj)
+ return str(error_obj)
+
+ def _attach_response_headers(
+ self,
+ completed_response: ResponsesAPIResponse,
+ raw_response: Any,
+ ) -> None:
raw_headers = dict(raw_response.headers)
processed_headers = process_response_headers(raw_headers)
if not hasattr(completed_response, "_hidden_params"):
setattr(completed_response, "_hidden_params", {})
completed_response._hidden_params["additional_headers"] = processed_headers
completed_response._hidden_params["headers"] = raw_headers
- return completed_response
def get_complete_url(
self,
diff --git a/litellm/llms/cohere/embed/handler.py b/litellm/llms/cohere/embed/handler.py
index 3ab8baf7ba8..81b6a1c7aec 100644
--- a/litellm/llms/cohere/embed/handler.py
+++ b/litellm/llms/cohere/embed/handler.py
@@ -1,5 +1,5 @@
"""
-Legacy /v1/embedding handler for Bedrock Cohere.
+Legacy /v1/embedding handler for Bedrock Cohere.
"""
import json
diff --git a/litellm/llms/cohere/embed/v1_transformation.py b/litellm/llms/cohere/embed/v1_transformation.py
index feca9cb5b88..82c901e7eca 100644
--- a/litellm/llms/cohere/embed/v1_transformation.py
+++ b/litellm/llms/cohere/embed/v1_transformation.py
@@ -110,15 +110,35 @@ class CohereEmbeddingConfig:
additional_args={"complete_input_dict": data},
original_response=response_json,
)
+ return self._populate_embedding_response(
+ response_json=response_json,
+ model_response=model_response,
+ model=model,
+ encoding=encoding,
+ input=input,
+ )
+
+ def _populate_embedding_response(
+ self,
+ response_json: dict,
+ model_response: EmbeddingResponse,
+ model: str,
+ encoding: Any,
+ input: list,
+ ) -> EmbeddingResponse:
"""
- response
+ Parse a Cohere embed response body into an OpenAI-style EmbeddingResponse.
+
+ Split out from `_transform_response` so callers that already log
+ `post_call` themselves (e.g. SageMaker's embedding handler) can reuse
+ the parsing without triggering a second `post_call`.
+
+ Response shape:
{
'object': "list",
- 'data': [
-
- ]
- 'model',
- 'usage'
+ 'data': [...],
+ 'model',
+ 'usage',
}
"""
embeddings = response_json["embeddings"]
@@ -149,9 +169,6 @@ class CohereEmbeddingConfig:
model_response.object = "list"
model_response.data = output_data
model_response.model = model
- input_tokens = 0
- for text in input:
- input_tokens += len(encoding.encode(text))
setattr(
model_response,
diff --git a/litellm/llms/custom_httpx/container_handler.py b/litellm/llms/custom_httpx/container_handler.py
index 599cd705ebf..501390d840b 100644
--- a/litellm/llms/custom_httpx/container_handler.py
+++ b/litellm/llms/custom_httpx/container_handler.py
@@ -257,14 +257,19 @@ class GenericContainerHandler:
returns_binary = endpoint_config.get("returns_binary", False)
is_multipart = endpoint_config.get("is_multipart", False)
+ # An empty dict passed as `params` to httpx strips any existing query
+ # string from the URL (e.g. ?api-version=...). Use None instead so
+ # httpx leaves the URL's own query string intact.
+ effective_params = query_params or None
+
try:
if method == "GET":
response = http_client.get(
- url=url, headers=headers, params=query_params
+ url=url, headers=headers, params=effective_params
)
elif method == "DELETE":
response = http_client.delete(
- url=url, headers=headers, params=query_params
+ url=url, headers=headers, params=effective_params
)
elif method == "POST":
if is_multipart and "file" in kwargs:
@@ -272,11 +277,11 @@ class GenericContainerHandler:
kwargs["file"], headers
)
response = http_client.post(
- url=url, headers=headers, params=query_params, files=files
+ url=url, headers=headers, params=effective_params, files=files
)
else:
response = http_client.post(
- url=url, headers=headers, params=query_params
+ url=url, headers=headers, params=effective_params
)
else:
raise ValueError(f"Unsupported HTTP method: {method}")
@@ -376,14 +381,19 @@ class GenericContainerHandler:
returns_binary = endpoint_config.get("returns_binary", False)
is_multipart = endpoint_config.get("is_multipart", False)
+ # An empty dict passed as `params` to httpx strips any existing query
+ # string from the URL (e.g. ?api-version=...). Use None instead so
+ # httpx leaves the URL's own query string intact.
+ effective_params = query_params or None
+
try:
if method == "GET":
response = await http_client.get(
- url=url, headers=headers, params=query_params
+ url=url, headers=headers, params=effective_params
)
elif method == "DELETE":
response = await http_client.delete(
- url=url, headers=headers, params=query_params
+ url=url, headers=headers, params=effective_params
)
elif method == "POST":
if is_multipart and "file" in kwargs:
@@ -391,11 +401,11 @@ class GenericContainerHandler:
kwargs["file"], headers
)
response = await http_client.post(
- url=url, headers=headers, params=query_params, files=files
+ url=url, headers=headers, params=effective_params, files=files
)
else:
response = await http_client.post(
- url=url, headers=headers, params=query_params
+ url=url, headers=headers, params=effective_params
)
else:
raise ValueError(f"Unsupported HTTP method: {method}")
diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py
index 2ff63cc2d7f..c9ab3c648ac 100644
--- a/litellm/llms/custom_httpx/llm_http_handler.py
+++ b/litellm/llms/custom_httpx/llm_http_handler.py
@@ -890,6 +890,18 @@ class BaseLLMHTTPHandler:
headers=headers,
)
+ # Some providers (e.g. OCI) require request signing after the body is built.
+ # The default BaseConfig.sign_request returns (headers, None) — a no-op for
+ # providers that don't need signing.
+ headers, signed_body = provider_config.sign_request(
+ headers=headers,
+ optional_params=optional_params,
+ request_data=data,
+ api_base=api_base,
+ api_key=api_key,
+ model=model,
+ )
+
## LOGGING
logging_obj.pre_call(
input=input,
@@ -916,6 +928,7 @@ class BaseLLMHTTPHandler:
client=client,
optional_params=optional_params,
litellm_params=litellm_params,
+ signed_body=signed_body,
)
if client is None or not isinstance(client, HTTPHandler):
@@ -926,12 +939,20 @@ class BaseLLMHTTPHandler:
sync_httpx_client = client
try:
- response = sync_httpx_client.post(
- url=api_base,
- headers=headers,
- data=json.dumps(data),
- timeout=timeout,
- )
+ if signed_body is not None:
+ response = sync_httpx_client.post(
+ url=api_base,
+ headers=headers,
+ data=signed_body,
+ timeout=timeout,
+ )
+ else:
+ response = sync_httpx_client.post(
+ url=api_base,
+ headers=headers,
+ data=json.dumps(data),
+ timeout=timeout,
+ )
except Exception as e:
raise self._handle_error(
e=e,
@@ -964,6 +985,7 @@ class BaseLLMHTTPHandler:
api_key: Optional[str] = None,
timeout: Optional[Union[float, httpx.Timeout]] = None,
client: Optional[Union[HTTPHandler, AsyncHTTPHandler]] = None,
+ signed_body: Optional[bytes] = None,
) -> EmbeddingResponse:
if client is None or not isinstance(client, AsyncHTTPHandler):
async_httpx_client = get_async_httpx_client(
@@ -974,12 +996,20 @@ class BaseLLMHTTPHandler:
async_httpx_client = client
try:
- response = await async_httpx_client.post(
- url=api_base,
- headers=headers,
- json=request_data,
- timeout=timeout,
- )
+ if signed_body is not None:
+ response = await async_httpx_client.post(
+ url=api_base,
+ headers=headers,
+ data=signed_body,
+ timeout=timeout,
+ )
+ else:
+ response = await async_httpx_client.post(
+ url=api_base,
+ headers=headers,
+ json=request_data,
+ timeout=timeout,
+ )
except Exception as e:
raise self._handle_error(e=e, provider_config=provider_config)
@@ -1177,6 +1207,8 @@ class BaseLLMHTTPHandler:
data = transformed_result.data
files = transformed_result.files
+ if transformed_result.content_type is not None:
+ headers["Content-Type"] = transformed_result.content_type
## LOGGING
logging_obj.pre_call(
@@ -1409,6 +1441,8 @@ class BaseLLMHTTPHandler:
document=document,
optional_params=optional_params,
headers=headers,
+ api_key=api_key,
+ api_base=api_base,
)
# All providers return OCRRequestData
@@ -1477,6 +1511,8 @@ class BaseLLMHTTPHandler:
document=document,
optional_params=optional_params,
headers=headers,
+ api_key=api_key,
+ api_base=api_base,
)
# All providers return OCRRequestData
@@ -1852,7 +1888,9 @@ class BaseLLMHTTPHandler:
async_httpx_client: AsyncHTTPHandler,
request_url: str,
headers: dict,
- signed_json_body: Optional[bytes],
+ # str when the caller passes a pre-serialized (unsigned) body to avoid
+ # re-dumping; bytes when a provider signed the request (e.g. Bedrock).
+ signed_json_body: Optional[Union[str, bytes]],
request_body: dict,
stream: bool,
logging_obj: LiteLLMLoggingObj,
@@ -2043,8 +2081,18 @@ class BaseLLMHTTPHandler:
model=model,
)
+ # The request body was serialized once for the pre-call log input and
+ # again for the wire (json.dumps is O(payload), large for long-context
+ # Claude Code history). Serialize once and reuse for both. Only when
+ # the provider didn't sign the request (sign_request no-op for the
+ # native anthropic path -> signed_json_body is None); signed providers
+ # (e.g. Bedrock) keep their signed body untouched. The HTTP-error
+ # retry path mutates + re-signs the body, so it still re-serializes
+ # internally -- this only deduplicates the success path.
+ request_body_json = json.dumps(request_body)
+
logging_obj.pre_call(
- input=[{"role": "user", "content": json.dumps(request_body)}],
+ input=[{"role": "user", "content": request_body_json}],
api_key="",
additional_args={
"complete_input_dict": request_body,
@@ -2057,7 +2105,9 @@ class BaseLLMHTTPHandler:
async_httpx_client=async_httpx_client,
request_url=request_url,
headers=headers,
- signed_json_body=signed_json_body,
+ signed_json_body=(
+ signed_json_body if signed_json_body is not None else request_body_json
+ ),
request_body=request_body,
stream=stream or False,
logging_obj=logging_obj,
@@ -2079,6 +2129,14 @@ class BaseLLMHTTPHandler:
litellm_logging_obj=logging_obj,
)
+ if not self._has_agentic_completion_hook(logging_obj):
+ # No callback overrides async_should_run_agentic_loop, so the
+ # agentic wrapper's only effect would be buffering every chunk
+ # and rebuilding the response from SSE at end-of-stream to call
+ # hooks that all return (False, {}). Stream through directly and
+ # skip that per-chunk + end-of-stream overhead.
+ return completion_stream
+
from litellm.llms.anthropic.experimental_pass_through.messages.agentic_streaming_iterator import (
AgenticAnthropicStreamingIterator,
)
@@ -4586,6 +4644,51 @@ class BaseLLMHTTPHandler:
fingerprints = list(kwargs.get("_agentic_loop_fingerprints", []) or [])
return depth, max(max_loops, 1), fingerprints
+ @staticmethod
+ def _has_agentic_completion_hook(logging_obj: Any) -> bool:
+ """
+ True if any registered callback actually overrides
+ ``async_should_run_agentic_loop`` (the gate every agentic hook goes
+ through). The base ``CustomLogger`` implementation returns
+ ``(False, {})``, so when nothing overrides it the agentic
+ post-processing is a guaranteed no-op and the streaming wrapper that
+ buffers + rebuilds the whole response from SSE just to call it can be
+ skipped entirely.
+
+ Function-identity comparison (not a leaf ``__dict__`` check) so an
+ override inherited through any intermediate class is still detected --
+ a false negative here would silently disable agentic features.
+
+ String entries in ``litellm.callbacks`` (e.g. ``"datadog"``) are
+ resolved to their ``CustomLogger`` instance via
+ ``get_custom_logger_compatible_class`` -- same pattern as
+ ``ProxyLogging._callback_capabilities`` -- so a string-registered
+ agentic callback is detected too.
+ """
+ from litellm.integrations.custom_logger import CustomLogger
+ from litellm.litellm_core_utils.litellm_logging import (
+ get_custom_logger_compatible_class,
+ )
+
+ base_func = CustomLogger.async_should_run_agentic_loop
+ callbacks = litellm.callbacks + (
+ getattr(logging_obj, "dynamic_success_callbacks", None) or []
+ )
+ for cb in callbacks:
+ if isinstance(cb, str):
+ resolved = get_custom_logger_compatible_class(cb) # type: ignore[arg-type]
+ if resolved is None:
+ continue
+ cb = resolved
+ if not isinstance(cb, CustomLogger):
+ continue
+ cb_func = getattr(type(cb), "async_should_run_agentic_loop", base_func)
+ if getattr(cb_func, "__func__", cb_func) is not getattr(
+ base_func, "__func__", base_func
+ ):
+ return True
+ return False
+
@staticmethod
def _check_agentic_loop_safety(
tool_calls: Any,
@@ -7834,7 +7937,7 @@ class BaseLLMHTTPHandler:
response = sync_httpx_client.get(
url=url,
headers=headers,
- params=params,
+ params=params or None,
)
return container_provider_config.transform_container_list_response(
@@ -7911,7 +8014,7 @@ class BaseLLMHTTPHandler:
response = await async_httpx_client.get(
url=url,
headers=headers,
- params=params,
+ params=params or None,
)
return container_provider_config.transform_container_list_response(
@@ -8001,7 +8104,7 @@ class BaseLLMHTTPHandler:
response = sync_httpx_client.get(
url=url,
headers=headers,
- params=params,
+ params=params or None,
)
return container_provider_config.transform_container_retrieve_response(
@@ -8078,7 +8181,7 @@ class BaseLLMHTTPHandler:
response = await async_httpx_client.get(
url=url,
headers=headers,
- params=params,
+ params=params or None,
)
return container_provider_config.transform_container_retrieve_response(
@@ -8168,7 +8271,7 @@ class BaseLLMHTTPHandler:
response = sync_httpx_client.delete(
url=url,
headers=headers,
- params=params,
+ params=params or None,
)
return container_provider_config.transform_container_delete_response(
@@ -8245,7 +8348,7 @@ class BaseLLMHTTPHandler:
response = await async_httpx_client.delete(
url=url,
headers=headers,
- params=params,
+ params=params or None,
)
return container_provider_config.transform_container_delete_response(
@@ -8341,7 +8444,7 @@ class BaseLLMHTTPHandler:
response = sync_httpx_client.get(
url=url,
headers=headers,
- params=params,
+ params=params or None,
)
return container_provider_config.transform_container_file_list_response(
@@ -8420,7 +8523,7 @@ class BaseLLMHTTPHandler:
response = await async_httpx_client.get(
url=url,
headers=headers,
- params=params,
+ params=params or None,
)
return container_provider_config.transform_container_file_list_response(
@@ -8508,7 +8611,7 @@ class BaseLLMHTTPHandler:
response = sync_httpx_client.get(
url=url,
headers=headers,
- params=params,
+ params=params or None,
)
return container_provider_config.transform_container_file_content_response(
@@ -8584,7 +8687,7 @@ class BaseLLMHTTPHandler:
response = await async_httpx_client.get(
url=url,
headers=headers,
- params=params,
+ params=params or None,
)
return container_provider_config.transform_container_file_content_response(
diff --git a/litellm/llms/custom_httpx/mock_transport.py b/litellm/llms/custom_httpx/mock_transport.py
index c9844753e0e..ad93cc134ee 100644
--- a/litellm/llms/custom_httpx/mock_transport.py
+++ b/litellm/llms/custom_httpx/mock_transport.py
@@ -13,7 +13,6 @@ from typing import Tuple
import httpx
-
# ---------------------------------------------------------------------------
# Pre-built response templates
# ---------------------------------------------------------------------------
diff --git a/litellm/llms/dashscope/common_utils.py b/litellm/llms/dashscope/common_utils.py
new file mode 100644
index 00000000000..b3b89cbbebf
--- /dev/null
+++ b/litellm/llms/dashscope/common_utils.py
@@ -0,0 +1,28 @@
+"""
+Common utilities for the DashScope LLM provider.
+"""
+
+from typing import Optional
+
+import httpx
+
+from litellm.llms.base_llm.chat.transformation import BaseLLMException
+
+
+class DashScopeError(BaseLLMException):
+ """Exception class for DashScope provider errors."""
+
+ def __init__(
+ self,
+ status_code: int,
+ message: str,
+ headers: Optional[httpx.Headers] = None,
+ ):
+ self.status_code = status_code
+ self.message = message
+ self.headers = headers or httpx.Headers()
+ super().__init__(
+ status_code=status_code,
+ message=message,
+ headers=dict(self.headers),
+ )
diff --git a/litellm/llms/dashscope/cost_calculator.py b/litellm/llms/dashscope/cost_calculator.py
index 9b3e3851162..8bb7f605b82 100644
--- a/litellm/llms/dashscope/cost_calculator.py
+++ b/litellm/llms/dashscope/cost_calculator.py
@@ -1,5 +1,5 @@
"""
-Cost calculator for Dashscope Chat models.
+Cost calculator for Dashscope Chat models.
Handles tiered pricing and prompt caching scenarios.
"""
diff --git a/litellm/llms/dashscope/embed/__init__.py b/litellm/llms/dashscope/embed/__init__.py
new file mode 100644
index 00000000000..4962b1f3251
--- /dev/null
+++ b/litellm/llms/dashscope/embed/__init__.py
@@ -0,0 +1,7 @@
+"""
+DashScope Embedding Module
+"""
+
+from .transformation import DashScopeEmbeddingConfig
+
+__all__ = ["DashScopeEmbeddingConfig"]
diff --git a/litellm/llms/dashscope/embed/transformation.py b/litellm/llms/dashscope/embed/transformation.py
new file mode 100644
index 00000000000..5bc0e5ca817
--- /dev/null
+++ b/litellm/llms/dashscope/embed/transformation.py
@@ -0,0 +1,191 @@
+"""
+Transformation logic from OpenAI /v1/embeddings format to DashScope's /v1/embeddings format.
+
+Supports
+- text-embedding-v4
+- text-embedding-v3
+
+Endpoint
+- https://dashscope.aliyuncs.com/compatible-mode/v1/embeddings
+
+Docs - https://help.aliyun.com/zh/model-studio/text-embedding-synchronous-api
+"""
+
+from typing import List, Optional, Union
+
+import httpx
+
+from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
+from litellm.llms.base_llm.chat.transformation import BaseLLMException
+from litellm.llms.base_llm.embedding.transformation import BaseEmbeddingConfig
+from litellm.secret_managers.main import get_secret_str
+from litellm.types.llms.openai import AllEmbeddingInputValues, AllMessageValues
+from litellm.types.utils import EmbeddingResponse, Usage
+
+from ..common_utils import DashScopeError
+
+DEFAULT_API_BASE = "https://dashscope.aliyuncs.com/compatible-mode/v1"
+
+
+class DashScopeEmbeddingConfig(BaseEmbeddingConfig):
+ """
+ Reference: https://help.aliyun.com/zh/model-studio/text-embedding-synchronous-api
+
+ DashScope exposes an OpenAI-compatible /v1/embeddings endpoint, so the
+ request and response shapes are nearly identical to OpenAI's.
+ """
+
+ def __init__(self) -> None:
+ pass
+
+ def get_supported_openai_params(self, model: str) -> List[str]:
+ # DashScope's compatible-mode embeddings API accepts the same params as OpenAI.
+ # `dimensions` / `encoding_format` are only honored by text-embedding-v3 / v4;
+ # earlier versions silently ignore them server-side.
+ return ["dimensions", "encoding_format", "user"]
+
+ def map_openai_params(
+ self,
+ non_default_params: dict,
+ optional_params: dict,
+ model: str,
+ drop_params: bool = False,
+ ) -> dict:
+ supported = self.get_supported_openai_params(model)
+ for k, v in non_default_params.items():
+ if v is None:
+ continue
+ if k in supported:
+ optional_params[k] = v
+ # unsupported params are dropped when drop_params=True;
+ # the upstream _check_valid_arg already raised UnsupportedParamsError
+ # for drop_params=False before this method is called.
+ return optional_params
+
+ def validate_environment(
+ self,
+ headers: dict,
+ model: str,
+ messages: List[AllMessageValues],
+ optional_params: dict,
+ litellm_params: dict,
+ api_key: Optional[str] = None,
+ api_base: Optional[str] = None,
+ ) -> dict:
+ if api_key is None:
+ api_key = get_secret_str("DASHSCOPE_API_KEY")
+ if api_key is None:
+ raise ValueError(
+ "DashScope API key is required. Set 'DASHSCOPE_API_KEY' env var or pass api_key explicitly."
+ )
+ default_headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {api_key}",
+ }
+ return {**default_headers, **headers}
+
+ def get_complete_url(
+ self,
+ api_base: Optional[str],
+ api_key: Optional[str],
+ model: str,
+ optional_params: dict,
+ litellm_params: dict,
+ stream: Optional[bool] = None,
+ ) -> str:
+ base = api_base or get_secret_str("DASHSCOPE_API_BASE") or DEFAULT_API_BASE
+ base = base.rstrip("/")
+ if base.endswith("/embeddings"):
+ return base
+ return f"{base}/embeddings"
+
+ def transform_embedding_request(
+ self,
+ model: str,
+ input: AllEmbeddingInputValues,
+ optional_params: dict,
+ headers: dict,
+ ) -> dict:
+ data: dict = {
+ "model": model,
+ "input": input,
+ }
+ for key in ("dimensions", "encoding_format", "user"):
+ value = optional_params.get(key)
+ if value is not None:
+ data[key] = value
+ return data
+
+ def transform_embedding_response(
+ self,
+ model: str,
+ raw_response: httpx.Response,
+ model_response: EmbeddingResponse,
+ logging_obj: LiteLLMLoggingObj,
+ api_key: Optional[str],
+ request_data: dict,
+ optional_params: dict,
+ litellm_params: dict,
+ ) -> EmbeddingResponse:
+ try:
+ response_json = raw_response.json()
+ except Exception as e:
+ raise DashScopeError(
+ status_code=raw_response.status_code,
+ message=f"Failed to parse DashScope response as JSON: {str(e)}",
+ )
+
+ logging_obj.post_call(
+ input=request_data.get("input"),
+ api_key=api_key,
+ additional_args={"complete_input_dict": request_data},
+ original_response=response_json,
+ )
+
+ if "error" in response_json:
+ error = response_json["error"]
+ message = (
+ error.get("message", str(error))
+ if isinstance(error, dict)
+ else str(error)
+ )
+ raise DashScopeError(
+ status_code=raw_response.status_code,
+ message=message,
+ )
+
+ model_response.object = "list"
+ model_response.data = response_json.get("data", [])
+ model_response.model = response_json.get("model", model)
+
+ usage = response_json.get("usage") or {}
+ prompt_tokens = usage.get("prompt_tokens", 0)
+ total_tokens = usage.get("total_tokens", prompt_tokens)
+ setattr(
+ model_response,
+ "usage",
+ Usage(
+ prompt_tokens=prompt_tokens,
+ completion_tokens=0,
+ total_tokens=total_tokens,
+ ),
+ )
+
+ if "id" in response_json:
+ setattr(model_response, "id", response_json["id"])
+
+ return model_response
+
+ def get_error_class(
+ self,
+ error_message: str,
+ status_code: int,
+ headers: Union[dict, httpx.Headers],
+ ) -> BaseLLMException:
+ if isinstance(headers, dict):
+ headers = httpx.Headers(headers)
+ return DashScopeError(
+ status_code=status_code,
+ message=error_message,
+ headers=headers,
+ )
diff --git a/litellm/llms/dashscope/rerank/__init__.py b/litellm/llms/dashscope/rerank/__init__.py
new file mode 100644
index 00000000000..2a1401f6dc0
--- /dev/null
+++ b/litellm/llms/dashscope/rerank/__init__.py
@@ -0,0 +1,7 @@
+"""
+DashScope Rerank Module
+"""
+
+from .transformation import DashScopeRerankConfig
+
+__all__ = ["DashScopeRerankConfig"]
diff --git a/litellm/llms/dashscope/rerank/transformation.py b/litellm/llms/dashscope/rerank/transformation.py
new file mode 100644
index 00000000000..629f3cf4af7
--- /dev/null
+++ b/litellm/llms/dashscope/rerank/transformation.py
@@ -0,0 +1,241 @@
+"""
+Transformation logic for DashScope's OpenAI-compatible /v1/reranks API.
+
+Supports
+- qwen3-rerank
+
+(Other DashScope rerankers — gte-rerank-v2 / qwen3-vl-rerank — share the same
+endpoint but have not been validated against this transformer. Behavior with
+those models is undefined.)
+
+Endpoint
+- https://dashscope.aliyuncs.com/compatible-api/v1/reranks
+
+Note: chat/embed live under `/compatible-mode/v1/`, but DashScope's rerank
+route is exposed under `/compatible-api/v1/reranks` per the docs. Override
+with `DASHSCOPE_API_BASE_RERANK` to point at a different host or path.
+
+Empirically, qwen3-rerank accepts `return_documents=true` and echoes
+`results[].document.text` back, even though the public docs list the flag
+as supported only for gte-rerank-v2 / qwen3-vl-rerank.
+
+Docs - https://help.aliyun.com/zh/model-studio/text-rerank-api
+"""
+
+from typing import Any, Dict, List, Optional, Union
+
+import httpx
+
+from litellm._uuid import uuid
+from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
+from litellm.llms.base_llm.chat.transformation import BaseLLMException
+from litellm.llms.base_llm.rerank.transformation import BaseRerankConfig
+from litellm.secret_managers.main import get_secret_str
+from litellm.types.rerank import (
+ OptionalRerankParams,
+ RerankBilledUnits,
+ RerankResponse,
+ RerankResponseMeta,
+ RerankTokens,
+)
+
+from ..common_utils import DashScopeError
+
+DEFAULT_RERANK_URL = "https://dashscope.aliyuncs.com/compatible-api/v1/reranks"
+
+
+class DashScopeRerankConfig(BaseRerankConfig):
+ """
+ Reference: https://help.aliyun.com/zh/model-studio/text-rerank-api
+
+ Targets DashScope's qwen3-rerank model. Request fields: model, query,
+ documents, top_n, return_documents. Response: results[].index,
+ results[].relevance_score, optionally results[].document.text (when
+ return_documents=true), plus a top-level usage.total_tokens counter.
+ """
+
+ def __init__(self) -> None:
+ pass
+
+ def get_complete_url(
+ self,
+ api_base: Optional[str],
+ model: str,
+ optional_params: Optional[dict] = None,
+ ) -> str:
+ if api_base is None:
+ api_base = get_secret_str("DASHSCOPE_API_BASE_RERANK") or DEFAULT_RERANK_URL
+
+ if api_base == DEFAULT_RERANK_URL:
+ return DEFAULT_RERANK_URL
+
+ cleaned = api_base.rstrip("/")
+ if cleaned.endswith("/reranks") or cleaned.endswith("/rerank"):
+ return cleaned
+
+ if cleaned.endswith("/v1"):
+ return f"{cleaned}/reranks"
+
+ # Unknown base: append /reranks rather than silently ignoring the caller's api_base.
+ return f"{cleaned}/reranks"
+
+ def validate_environment(
+ self,
+ headers: dict,
+ model: str,
+ api_key: Optional[str] = None,
+ optional_params: Optional[dict] = None,
+ ) -> dict:
+ if api_key is None:
+ api_key = get_secret_str("DASHSCOPE_API_KEY")
+ if api_key is None:
+ raise ValueError(
+ "DashScope API key is required. Set 'DASHSCOPE_API_KEY' env var or pass api_key explicitly."
+ )
+
+ default_headers = {
+ "Authorization": f"Bearer {api_key}",
+ "accept": "application/json",
+ "content-type": "application/json",
+ }
+ return {**default_headers, **headers}
+
+ def get_supported_cohere_rerank_params(self, model: str) -> list:
+ return ["query", "documents", "top_n", "return_documents"]
+
+ def map_cohere_rerank_params(
+ self,
+ non_default_params: Optional[dict],
+ model: str,
+ drop_params: bool,
+ query: str,
+ documents: List[Union[str, Dict[str, Any]]],
+ custom_llm_provider: Optional[str] = None,
+ top_n: Optional[int] = None,
+ rank_fields: Optional[List[str]] = None,
+ return_documents: Optional[bool] = True,
+ max_chunks_per_doc: Optional[int] = None,
+ max_tokens_per_doc: Optional[int] = None,
+ ) -> Dict:
+ # qwen3-rerank accepts query/documents/top_n/return_documents. The
+ # rest (rank_fields, max_*_per_doc) are silently dropped.
+ params: OptionalRerankParams = OptionalRerankParams(
+ query=query,
+ documents=documents,
+ )
+ if top_n is not None:
+ params["top_n"] = top_n
+ if return_documents is not None:
+ params["return_documents"] = return_documents
+ return dict(params)
+
+ def transform_rerank_request(
+ self,
+ model: str,
+ optional_rerank_params: Dict,
+ headers: dict,
+ litellm_params: Optional[dict] = None,
+ ) -> dict:
+ if "query" not in optional_rerank_params:
+ raise ValueError("query is required for DashScope rerank")
+ if "documents" not in optional_rerank_params:
+ raise ValueError("documents is required for DashScope rerank")
+
+ request: Dict[str, Any] = {
+ "model": model,
+ "query": optional_rerank_params["query"],
+ "documents": optional_rerank_params["documents"],
+ }
+ if optional_rerank_params.get("top_n") is not None:
+ request["top_n"] = optional_rerank_params["top_n"]
+ if optional_rerank_params.get("return_documents") is not None:
+ request["return_documents"] = optional_rerank_params["return_documents"]
+ return request
+
+ def transform_rerank_response(
+ self,
+ model: str,
+ raw_response: httpx.Response,
+ model_response: RerankResponse,
+ logging_obj: LiteLLMLoggingObj,
+ api_key: Optional[str] = None,
+ request_data: Optional[dict] = None,
+ optional_params: Optional[dict] = None,
+ litellm_params: Optional[dict] = None,
+ ) -> RerankResponse:
+ request_data = request_data or {}
+ optional_params = optional_params or {}
+ litellm_params = litellm_params or {}
+ try:
+ response_json = raw_response.json()
+ except Exception:
+ raise DashScopeError(
+ status_code=raw_response.status_code,
+ message=raw_response.text,
+ )
+
+ logging_obj.post_call(
+ input=request_data.get("query"),
+ api_key=api_key,
+ additional_args={"complete_input_dict": request_data},
+ original_response=response_json,
+ )
+
+ # DashScope error envelope: {"code": "...", "message": "...", "request_id": "..."}
+ if "code" in response_json and "results" not in response_json:
+ raise DashScopeError(
+ status_code=raw_response.status_code,
+ message=response_json.get("message", str(response_json)),
+ )
+
+ results = response_json.get("results")
+ if results is None:
+ raise DashScopeError(
+ status_code=raw_response.status_code,
+ message=f"No results in DashScope rerank response: {response_json}",
+ )
+
+ # qwen3-rerank returns:
+ # {"index": int, "relevance_score": float}
+ # plus, when return_documents=true was sent:
+ # "document": {"text": "..."}
+ # which already matches LiteLLM's RerankResponseDocument shape.
+ transformed_results: List[dict] = []
+ for r in results:
+ item: Dict[str, Any] = {
+ "index": r["index"],
+ "relevance_score": r["relevance_score"],
+ }
+ doc = r.get("document")
+ if isinstance(doc, dict):
+ item["document"] = doc
+ elif isinstance(doc, str):
+ # Defensive: spec says dict, but normalize string-shaped echoes.
+ item["document"] = {"text": doc}
+ transformed_results.append(item)
+
+ usage = response_json.get("usage") or {}
+ total_tokens = usage.get("total_tokens")
+ billed_units = RerankBilledUnits(total_tokens=total_tokens)
+ tokens = RerankTokens(input_tokens=total_tokens)
+ meta = RerankResponseMeta(billed_units=billed_units, tokens=tokens)
+
+ return RerankResponse(
+ id=response_json.get("id") or str(uuid.uuid4()),
+ results=transformed_results, # type: ignore
+ meta=meta,
+ )
+
+ def get_error_class(
+ self,
+ error_message: str,
+ status_code: int,
+ headers: Union[dict, httpx.Headers],
+ ) -> BaseLLMException:
+ if isinstance(headers, dict):
+ headers = httpx.Headers(headers)
+ return DashScopeError(
+ status_code=status_code,
+ message=error_message,
+ headers=headers,
+ )
diff --git a/litellm/llms/datarobot/chat/transformation.py b/litellm/llms/datarobot/chat/transformation.py
index 23ce63c25b2..f81e2420930 100644
--- a/litellm/llms/datarobot/chat/transformation.py
+++ b/litellm/llms/datarobot/chat/transformation.py
@@ -1,5 +1,5 @@
"""
-Support for OpenAI's `/v1/chat/completions` endpoint.
+Support for OpenAI's `/v1/chat/completions` endpoint.
Calls done in OpenAI/openai.py as DataRobot is openai-compatible.
"""
diff --git a/litellm/llms/deepinfra/rerank/transformation.py b/litellm/llms/deepinfra/rerank/transformation.py
index 276735f4758..e4bfbcb2513 100644
--- a/litellm/llms/deepinfra/rerank/transformation.py
+++ b/litellm/llms/deepinfra/rerank/transformation.py
@@ -1,5 +1,5 @@
"""
-Translate between Cohere's `/rerank` format and Deepinfra's `/rerank` format.
+Translate between Cohere's `/rerank` format and Deepinfra's `/rerank` format.
"""
from typing import Any, Dict, List, Optional, Union
diff --git a/litellm/llms/deepseek/chat/transformation.py b/litellm/llms/deepseek/chat/transformation.py
index 5cd8d119542..7ed3e484535 100644
--- a/litellm/llms/deepseek/chat/transformation.py
+++ b/litellm/llms/deepseek/chat/transformation.py
@@ -2,13 +2,15 @@
Translates from OpenAI's `/v1/chat/completions` to DeepSeek's `/v1/chat/completions`
"""
-from typing import Any, Coroutine, List, Literal, Optional, Tuple, Union, overload
+from typing import Any, Coroutine, List, Literal, Optional, Tuple, Union, cast, overload
+import litellm
from litellm.litellm_core_utils.prompt_templates.common_utils import (
handle_messages_with_content_list_to_str_conversion,
)
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import AllMessageValues
+from litellm.utils import supports_reasoning
from ...openai.chat.gpt_transformation import OpenAIGPTConfig
@@ -62,6 +64,48 @@ class DeepSeekChatConfig(OpenAIGPTConfig):
return optional_params
+ def _fill_reasoning_content(
+ self, messages: List[AllMessageValues]
+ ) -> List[AllMessageValues]:
+ """
+ DeepSeek thinking mode requires `reasoning_content` to be passed back on
+ every assistant message in multi-turn conversations. If it is missing,
+ the API returns:
+ "The reasoning_content in the thinking mode must be passed back to the API."
+
+ For each assistant message that is missing `reasoning_content`:
+ 1. Promote it from `provider_specific_fields["reasoning_content"]` if present
+ (LiteLLM stores provider-specific response fields there).
+ 2. Otherwise inject a single space — the minimum value the API accepts.
+ """
+ result: List[AllMessageValues] = []
+ for msg in messages:
+ if msg.get("role") == "assistant" and not msg.get("reasoning_content"):
+ patched = dict(cast(dict, msg))
+ provider_fields = patched.get("provider_specific_fields") or {}
+ stored = provider_fields.get("reasoning_content")
+ if stored:
+ patched["reasoning_content"] = stored
+ cleaned = dict(provider_fields)
+ cleaned.pop("reasoning_content", None)
+ patched["provider_specific_fields"] = cleaned
+ else:
+ litellm.verbose_logger.warning(
+ "DeepSeek thinking mode: assistant message is missing "
+ "`reasoning_content` and none was saved in "
+ "`provider_specific_fields`. A single-space placeholder "
+ "is being injected to satisfy API validation, but the "
+ "model will receive a blank reasoning chain for this turn, "
+ "which may silently degrade multi-turn response quality. "
+ "Preserve `reasoning_content` from the original assistant "
+ "response when building multi-turn conversation history."
+ )
+ patched["reasoning_content"] = " "
+ result.append(cast(AllMessageValues, patched))
+ else:
+ result.append(msg)
+ return result
+
@overload
def _transform_messages(
self, messages: List[AllMessageValues], model: str, is_async: Literal[True]
@@ -91,6 +135,66 @@ class DeepSeekChatConfig(OpenAIGPTConfig):
messages=messages, model=model, is_async=False
)
+ def _thinking_mode_active(self, model: str, optional_params: dict) -> bool:
+ """
+ Returns True only when thinking mode is actually active for this request:
+ - model supports reasoning (capability check)
+ - user explicitly passed thinking={"type": "enabled"} (opt-in check)
+ """
+ return (
+ supports_reasoning(model=model, custom_llm_provider="deepseek")
+ and (optional_params.get("thinking") or {}).get("type") == "enabled"
+ )
+
+ def transform_request(
+ self,
+ model: str,
+ messages: List[AllMessageValues],
+ optional_params: dict,
+ litellm_params: dict,
+ headers: dict,
+ ) -> dict:
+ """
+ Ensures `reasoning_content` is forwarded on assistant messages for
+ multi-turn thinking-mode conversations (issue #28045).
+
+ Only runs when thinking mode is actually active - guarded by both
+ supports_reasoning() (model capability) and optional_params["thinking"]
+ (user explicitly enabled it), preventing spurious injection on models
+ like deepseek-v3.2 that support thinking as opt-in but not always-on.
+ """
+ if self._thinking_mode_active(model=model, optional_params=optional_params):
+ messages = self._fill_reasoning_content(messages)
+ return super().transform_request(
+ model=model,
+ messages=messages,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ headers=headers,
+ )
+
+ async def async_transform_request(
+ self,
+ model: str,
+ messages: List[AllMessageValues],
+ optional_params: dict,
+ litellm_params: dict,
+ headers: dict,
+ ) -> dict:
+ """
+ Async equivalent of transform_request — applies the same reasoning_content
+ fix for multi-turn thinking-mode conversations.
+ """
+ if self._thinking_mode_active(model=model, optional_params=optional_params):
+ messages = self._fill_reasoning_content(messages)
+ return await super().async_transform_request(
+ model=model,
+ messages=messages,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ headers=headers,
+ )
+
def _get_openai_compatible_provider_info(
self, api_base: Optional[str], api_key: Optional[str]
) -> Tuple[Optional[str], Optional[str]]:
diff --git a/litellm/llms/deepseek/cost_calculator.py b/litellm/llms/deepseek/cost_calculator.py
index 0f4490cb3df..e652ebeac54 100644
--- a/litellm/llms/deepseek/cost_calculator.py
+++ b/litellm/llms/deepseek/cost_calculator.py
@@ -1,5 +1,5 @@
"""
-Cost calculator for DeepSeek Chat models.
+Cost calculator for DeepSeek Chat models.
Handles prompt caching scenario.
"""
diff --git a/litellm/llms/deepseek/messages/transformation.py b/litellm/llms/deepseek/messages/transformation.py
new file mode 100644
index 00000000000..ad60478960e
--- /dev/null
+++ b/litellm/llms/deepseek/messages/transformation.py
@@ -0,0 +1,133 @@
+"""
+DeepSeek Anthropic-compatible messages transformation config.
+"""
+
+from typing import Any, Dict, List, Optional, Tuple
+
+import litellm
+from litellm.llms.anthropic.experimental_pass_through.messages.transformation import (
+ AnthropicMessagesConfig,
+)
+from litellm.secret_managers.main import get_secret_str
+from litellm.types.router import GenericLiteLLMParams
+
+
+class DeepSeekAnthropicMessagesConfig(AnthropicMessagesConfig):
+ """
+ DeepSeek exposes an Anthropic-compatible Messages API at
+ https://api.deepseek.com/anthropic.
+
+ It accepts the native Anthropic Messages conversation shape, including
+ thinking blocks in assistant history, but rejects Anthropic's explicit
+ custom-tool discriminator (`{"type": "custom"}`).
+ """
+
+ @property
+ def custom_llm_provider(self) -> Optional[str]:
+ return "deepseek"
+
+ @staticmethod
+ def get_api_key(api_key: Optional[str] = None) -> Optional[str]:
+ return api_key or get_secret_str("DEEPSEEK_API_KEY") or litellm.api_key
+
+ @staticmethod
+ def get_api_base(api_base: Optional[str] = None) -> str:
+ return (
+ api_base
+ or get_secret_str("DEEPSEEK_ANTHROPIC_API_BASE")
+ or get_secret_str("DEEPSEEK_API_BASE")
+ or "https://api.deepseek.com/anthropic"
+ )
+
+ def validate_anthropic_messages_environment(
+ self,
+ headers: dict,
+ model: str,
+ messages: List[Any],
+ optional_params: dict,
+ litellm_params: dict,
+ api_key: Optional[str] = None,
+ api_base: Optional[str] = None,
+ ) -> Tuple[dict, Optional[str]]:
+ dynamic_api_key = self.get_api_key(api_key=api_key)
+
+ if (
+ "x-api-key" not in headers
+ and "authorization" not in headers
+ and dynamic_api_key is not None
+ ):
+ headers["x-api-key"] = dynamic_api_key
+
+ if "anthropic-version" not in headers:
+ headers["anthropic-version"] = "2023-06-01"
+ if "content-type" not in headers:
+ headers["content-type"] = "application/json"
+
+ headers = self._update_headers_with_anthropic_beta(
+ headers=headers,
+ optional_params=optional_params,
+ custom_llm_provider=self.custom_llm_provider or "deepseek",
+ )
+
+ return headers, api_base
+
+ def get_complete_url(
+ self,
+ api_base: Optional[str],
+ api_key: Optional[str],
+ model: str,
+ optional_params: dict,
+ litellm_params: dict,
+ stream: Optional[bool] = None,
+ ) -> str:
+ base_url = self.get_api_base(api_base=api_base).rstrip("/")
+
+ if base_url.endswith("/v1/messages") and "/anthropic/" in base_url:
+ return base_url
+ if base_url.endswith("/v1/messages"):
+ base_url = base_url[: -len("/v1/messages")]
+ if base_url.endswith("/v1"):
+ base_url = base_url[: -len("/v1")]
+ if base_url.endswith("/beta"):
+ base_url = base_url[: -len("/beta")]
+
+ if not base_url.endswith("/anthropic") and "/anthropic/" not in base_url:
+ base_url = f"{base_url}/anthropic"
+
+ return f"{base_url}/v1/messages"
+
+ @staticmethod
+ def _sanitize_tools_for_deepseek(tools: Any) -> Any:
+ if not isinstance(tools, list):
+ return tools
+
+ sanitized_tools = []
+ for tool in tools:
+ if isinstance(tool, dict) and tool.get("type") == "custom":
+ sanitized_tool = dict(tool)
+ sanitized_tool.pop("type", None)
+ sanitized_tools.append(sanitized_tool)
+ else:
+ sanitized_tools.append(tool)
+ return sanitized_tools
+
+ def transform_anthropic_messages_request(
+ self,
+ model: str,
+ messages: List[Dict],
+ anthropic_messages_optional_request_params: Dict,
+ litellm_params: GenericLiteLLMParams,
+ headers: dict,
+ ) -> Dict:
+ anthropic_messages_request = super().transform_anthropic_messages_request(
+ model=model,
+ messages=messages,
+ anthropic_messages_optional_request_params=anthropic_messages_optional_request_params,
+ litellm_params=litellm_params,
+ headers=headers,
+ )
+ if "tools" in anthropic_messages_request:
+ anthropic_messages_request["tools"] = self._sanitize_tools_for_deepseek(
+ anthropic_messages_request["tools"]
+ )
+ return anthropic_messages_request
diff --git a/litellm/llms/elevenlabs/text_to_speech/transformation.py b/litellm/llms/elevenlabs/text_to_speech/transformation.py
index 6a59911701b..612fc687ef9 100644
--- a/litellm/llms/elevenlabs/text_to_speech/transformation.py
+++ b/litellm/llms/elevenlabs/text_to_speech/transformation.py
@@ -22,7 +22,6 @@ from litellm.types.utils import all_litellm_params
from ..common_utils import ElevenLabsException
-
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.types.llms.openai import HttpxBinaryResponseContent
diff --git a/litellm/llms/fireworks_ai/chat/transformation.py b/litellm/llms/fireworks_ai/chat/transformation.py
index eaf01c5fe18..d39adf0b6f4 100644
--- a/litellm/llms/fireworks_ai/chat/transformation.py
+++ b/litellm/llms/fireworks_ai/chat/transformation.py
@@ -4,6 +4,7 @@ from typing import Any, List, Literal, Optional, Tuple, Union, cast
import httpx
import litellm
+from litellm._logging import verbose_logger
from litellm._uuid import uuid
from litellm.constants import RESPONSE_FORMAT_TOOL_NAME
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
@@ -26,6 +27,7 @@ from litellm.types.utils import (
ProviderSpecificModelInfo,
)
from litellm.utils import (
+ get_model_cost_mutation_generation,
supports_function_calling,
supports_reasoning,
supports_tool_choice,
@@ -112,6 +114,19 @@ class FireworksAIConfig(OpenAIGPTConfig):
# Only add tools for models that support function calling
if supports_function_calling(model=model, custom_llm_provider="fireworks_ai"):
supported_params.append("tools")
+ supported_params.append("parallel_tool_calls")
+ else:
+ # Historically every Fireworks model advertised tool support, so a
+ # JSON entry that flips `supports_function_calling` to false will
+ # silently drop `tools` from requests. Surface this so users can
+ # tell why their tool calls suddenly stop working.
+ verbose_logger.debug(
+ "fireworks_ai model %r is marked as not supporting "
+ "function calling in model_prices_and_context_window.json; "
+ "`tools` and `parallel_tool_calls` will be dropped from the "
+ "request.",
+ model,
+ )
# Only add tool_choice for models that explicitly support it
if supports_tool_choice(model=model, custom_llm_provider="fireworks_ai"):
@@ -251,34 +266,100 @@ class FireworksAIConfig(OpenAIGPTConfig):
return messages
- def get_provider_info(self, model: str) -> ProviderSpecificModelInfo:
- # Models that support reasoning_effort
- reasoning_supported_models = [
- "qwen3-8b",
- "qwen3-32b",
- "qwen3-coder-480b-a35b-instruct",
- "deepseek-v3p1",
- "deepseek-v3p2",
- "glm-4p5",
- "glm-4p5-air",
- "glm-4p6",
- "gpt-oss-120b",
- "gpt-oss-20b",
+ # Cached index of fireworks_ai/* entries from litellm.model_cost. Building
+ # this index requires a full scan of model_cost (tens of thousands of
+ # entries), so we memoize it. The cache key is (id(model_cost),
+ # mutation_generation): the generation counter is bumped on every
+ # register_model / reload path, so add+remove or in-place value
+ # replacement (which can leave id and len unchanged) still invalidates.
+ _fireworks_index_cache: Optional[Tuple[int, int, List[Tuple[str, dict]]]] = None
+
+ @classmethod
+ def _get_fireworks_index(cls) -> List[Tuple[str, dict]]:
+ model_cost = litellm.model_cost
+ signature = (id(model_cost), get_model_cost_mutation_generation())
+ cached = cls._fireworks_index_cache
+ if (
+ cached is not None
+ and cached[0] == signature[0]
+ and cached[1] == signature[1]
+ ):
+ return cached[2]
+
+ index: List[Tuple[str, dict]] = []
+ for key, model_info in model_cost.items():
+ if not key.startswith("fireworks_ai/"):
+ continue
+ if not isinstance(model_info, dict):
+ continue
+ key_short = key[len("fireworks_ai/") :]
+ if key_short.startswith("accounts/fireworks/models/"):
+ key_short = key_short[len("accounts/fireworks/models/") :]
+ if not key_short:
+ continue
+ index.append((key_short, model_info))
+
+ cls._fireworks_index_cache = (signature[0], signature[1], index)
+ return index
+
+ @staticmethod
+ def _matches_on_hyphen_boundary(short_name: str, key_short: str) -> bool:
+ """Return True if `key_short` appears in `short_name` aligned to
+ hyphen-separated word boundaries (or end-of-string). This avoids
+ spurious substring matches like `"some-model"` matching
+ `"awesome-model"`."""
+ if short_name == key_short:
+ return True
+ if short_name.startswith(key_short + "-"):
+ return True
+ if short_name.endswith("-" + key_short):
+ return True
+ return ("-" + key_short + "-") in short_name
+
+ def _get_model_cost_capability(self, model: str, capability: str) -> Optional[bool]:
+ short_name = model
+ if short_name.startswith("fireworks_ai/"):
+ short_name = short_name[len("fireworks_ai/") :]
+ if short_name.startswith("accounts/fireworks/models/"):
+ short_name = short_name[len("accounts/fireworks/models/") :]
+
+ candidate_keys = [
+ model,
+ f"fireworks_ai/{short_name}",
+ f"fireworks_ai/accounts/fireworks/models/{short_name}",
]
- # Normalize model name - remove prefix if present
- normalized_model = model
- if model.startswith("fireworks_ai/"):
- normalized_model = model.replace("fireworks_ai/", "")
- if normalized_model.startswith("accounts/fireworks/models/"):
- normalized_model = normalized_model.replace(
- "accounts/fireworks/models/", ""
- )
+ for candidate_key in candidate_keys:
+ model_info = litellm.model_cost.get(candidate_key)
+ if model_info is not None and model_info.get(capability) is not None:
+ return cast(Optional[bool], model_info.get(capability))
- # Check if model supports reasoning
- supports_reasoning_value = any(
- reasoning_model in normalized_model
- for reasoning_model in reasoning_supported_models
+ # Fallback: preserve historical substring matching for model name
+ # variants (e.g. fine-tuned or regionally-suffixed versions of a
+ # known model). Pick the *longest* matching entry so a more specific
+ # known model (e.g. "qwen3-8b-instruct") wins over a less specific
+ # one (e.g. "qwen3-8b") when the query model is more specific still.
+ # Use hyphen-aligned matching to avoid false positives where a short
+ # known model name is an unrelated substring of a longer one.
+ best_match_short: Optional[str] = None
+ best_match_value: Optional[bool] = None
+ for key_short, model_info in self._get_fireworks_index():
+ if model_info.get(capability) is None:
+ continue
+ if not self._matches_on_hyphen_boundary(short_name, key_short):
+ continue
+ if best_match_short is None or len(key_short) > len(best_match_short):
+ best_match_short = key_short
+ best_match_value = cast(Optional[bool], model_info.get(capability))
+
+ return best_match_value
+
+ def get_provider_info(self, model: str) -> ProviderSpecificModelInfo:
+ supports_function_calling_value = self._get_model_cost_capability(
+ model=model, capability="supports_function_calling"
+ )
+ supports_reasoning_value = self._get_model_cost_capability(
+ model=model, capability="supports_reasoning"
)
provider_specific_model_info: ProviderSpecificModelInfo = {
@@ -288,9 +369,16 @@ class FireworksAIConfig(OpenAIGPTConfig):
"supports_vision": True, # via document inlining
}
+ if supports_function_calling_value is not None:
+ provider_specific_model_info["supports_function_calling"] = (
+ supports_function_calling_value
+ )
+
# Only include supports_reasoning if True
if supports_reasoning_value:
- provider_specific_model_info["supports_reasoning"] = True
+ provider_specific_model_info["supports_reasoning"] = (
+ supports_reasoning_value
+ )
return provider_specific_model_info
diff --git a/litellm/llms/gemini/agents/__init__.py b/litellm/llms/gemini/agents/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/litellm/llms/gemini/agents/transformation.py b/litellm/llms/gemini/agents/transformation.py
new file mode 100644
index 00000000000..f6e0b95cf28
--- /dev/null
+++ b/litellm/llms/gemini/agents/transformation.py
@@ -0,0 +1,298 @@
+"""
+Google AI Studio Agents API configuration.
+
+Proxies the Gemini v1beta Agents API:
+ POST /v1beta/agents create
+ GET /v1beta/agents list
+ GET /v1beta/agents/{name} get
+ DELETE /v1beta/agents/{name} delete
+ GET /v1beta/agents/{name}/versions list versions
+"""
+
+from typing import Any, Dict, Optional, Tuple, Union
+
+import httpx
+
+from litellm._logging import verbose_logger
+from litellm.llms.base_llm.agents.transformation import BaseAgentsAPIConfig
+from litellm.llms.gemini.common_utils import GeminiError, GeminiModelInfo
+from litellm.types.agents import (
+ AgentCreateResponse,
+ AgentDeleteResult,
+ AgentListResponse,
+ AgentVersionsResponse,
+)
+
+# Keys inside litellm_params that should be forwarded to the Gemini
+# create-agent body verbatim.
+_GEMINI_AGENT_BODY_KEYS = ("base_agent", "instructions", "base_environment")
+
+# LiteLLM-internal keys that must never be forwarded to Gemini.
+_LITELLM_INTERNAL_KEYS = frozenset(
+ {
+ "custom_llm_provider",
+ "api_key",
+ "api_base",
+ "make_public",
+ "cost_per_query",
+ "input_cost_per_token",
+ "output_cost_per_token",
+ "require_trace_id_on_calls_to_agent",
+ "require_trace_id_on_calls_by_agent",
+ "max_iterations",
+ "max_budget_per_session",
+ "guardrails",
+ "is_public",
+ "agent_name",
+ "agent_id",
+ "agent_card_params",
+ "provider_agent_response",
+ }
+)
+
+
+class GeminiAgentsConfig(BaseAgentsAPIConfig):
+ """
+ Configuration for the Google AI Studio (Gemini) native Agents API.
+
+ Authentication uses x-goog-api-key, resolved from (in order):
+ 1. litellm_params["api_key"]
+ 2. GOOGLE_API_KEY env var
+ 3. GEMINI_API_KEY env var
+ """
+
+ @property
+ def api_version(self) -> str:
+ return "v1beta"
+
+ def _base_url(self, api_base: Optional[str]) -> str:
+ return f"{GeminiModelInfo.get_api_base(api_base)}/{self.api_version}"
+
+ # ------------------------------------------------------------------ #
+ # Shared helpers #
+ # ------------------------------------------------------------------ #
+
+ def get_error_class(
+ self,
+ error_message: str,
+ status_code: int,
+ headers: Union[dict, httpx.Headers],
+ ) -> Exception:
+ return GeminiError(
+ message=error_message,
+ status_code=status_code,
+ headers=dict(headers),
+ )
+
+ def get_complete_url(
+ self,
+ api_base: Optional[str],
+ litellm_params: Dict[str, Any],
+ ) -> str:
+ return f"{self._base_url(api_base)}/agents"
+
+ def validate_environment(
+ self,
+ headers: Dict[str, str],
+ litellm_params: Dict[str, Any],
+ ) -> Dict[str, str]:
+ headers = dict(headers)
+ headers["Content-Type"] = "application/json"
+ explicit_api_key = litellm_params.get("api_key")
+ # SECURITY: when the caller overrides ``api_base``, refuse to fall back
+ # to the process-wide GOOGLE_API_KEY / GEMINI_API_KEY env vars. Otherwise
+ # an authenticated proxy user could set ``api_base`` to an attacker-
+ # controlled host and have the proxy ship its shared Gemini key in the
+ # ``x-goog-api-key`` header.
+ if litellm_params.get("api_base") and not explicit_api_key:
+ raise ValueError(
+ "When overriding api_base for Gemini agents, you must also "
+ "supply an explicit api_key. Falling back to GOOGLE_API_KEY / "
+ "GEMINI_API_KEY env vars with a custom api_base is refused "
+ "to prevent leaking the shared provider key to arbitrary hosts."
+ )
+ api_key = GeminiModelInfo.get_api_key(explicit_api_key)
+ if not api_key:
+ raise ValueError(
+ "Google API key is required. "
+ "Set GOOGLE_API_KEY or GEMINI_API_KEY, or pass api_key."
+ )
+ headers["x-goog-api-key"] = api_key
+ return headers
+
+ def _raise_for_status(self, raw_response: httpx.Response) -> None:
+ if not (200 <= raw_response.status_code < 300):
+ raise GeminiError(
+ message=raw_response.text,
+ status_code=raw_response.status_code,
+ headers=dict(raw_response.headers),
+ )
+
+ # ------------------------------------------------------------------ #
+ # CREATE #
+ # ------------------------------------------------------------------ #
+
+ def transform_create_request(
+ self,
+ name: str,
+ litellm_params: Dict[str, Any],
+ ) -> Dict[str, Any]:
+ body: Dict[str, Any] = {"name": name}
+ for key in _GEMINI_AGENT_BODY_KEYS:
+ value = litellm_params.get(key)
+ if value is not None:
+ body[key] = value
+ verbose_logger.debug("GeminiAgentsConfig create body: %s", body)
+ return body
+
+ def transform_create_response(
+ self,
+ raw_response: httpx.Response,
+ name: str,
+ ) -> AgentCreateResponse:
+ """
+ Gemini returns:
+ {"id": "my-agent", "base_agent": "waverunner",
+ "system_instruction": "...", "base_environment": {...}}
+ """
+ self._raise_for_status(raw_response)
+ try:
+ data: Dict[str, Any] = raw_response.json()
+ except Exception:
+ verbose_logger.warning(
+ "GeminiAgentsConfig: non-JSON create response (status=%d).",
+ raw_response.status_code,
+ )
+ data = {"id": name}
+ # Gemini uses "id" as the identifier; normalise to both fields.
+ data.setdefault("id", name)
+ data.setdefault("name", data["id"])
+ verbose_logger.debug("GeminiAgentsConfig create response: %s", data)
+ return AgentCreateResponse(**data)
+
+ # ------------------------------------------------------------------ #
+ # LIST #
+ # ------------------------------------------------------------------ #
+
+ def transform_list_request(
+ self,
+ api_base: Optional[str],
+ litellm_params: Dict[str, Any],
+ ) -> Tuple[str, Dict[str, Any]]:
+ url = f"{self._base_url(api_base)}/agents"
+ params: Dict[str, Any] = {}
+ if litellm_params.get("page_size"):
+ params["pageSize"] = litellm_params["page_size"]
+ if litellm_params.get("page_token"):
+ params["pageToken"] = litellm_params["page_token"]
+ return url, params
+
+ def transform_list_response(
+ self,
+ raw_response: httpx.Response,
+ ) -> AgentListResponse:
+ self._raise_for_status(raw_response)
+ try:
+ data = raw_response.json()
+ except Exception:
+ data = {}
+ verbose_logger.debug("GeminiAgentsConfig list response: %s", data)
+ return AgentListResponse(
+ agents=data.get("agents", []),
+ next_page_token=data.get("nextPageToken"),
+ )
+
+ # ------------------------------------------------------------------ #
+ # GET #
+ # ------------------------------------------------------------------ #
+
+ def transform_get_request(
+ self,
+ name: str,
+ api_base: Optional[str],
+ litellm_params: Dict[str, Any],
+ ) -> Tuple[str, Dict[str, Any]]:
+ url = f"{self._base_url(api_base)}/agents/{name}"
+ return url, {}
+
+ def transform_get_response(
+ self,
+ raw_response: httpx.Response,
+ name: str,
+ ) -> AgentCreateResponse:
+ """Same shape as create response — Gemini returns "id" as identifier."""
+ self._raise_for_status(raw_response)
+ try:
+ data = raw_response.json()
+ except Exception:
+ data = {"id": name}
+ data.setdefault("id", name)
+ data.setdefault("name", data["id"])
+ verbose_logger.debug("GeminiAgentsConfig get response: %s", data)
+ return AgentCreateResponse(**data)
+
+ # ------------------------------------------------------------------ #
+ # DELETE #
+ # ------------------------------------------------------------------ #
+
+ def transform_delete_request(
+ self,
+ name: str,
+ api_base: Optional[str],
+ litellm_params: Dict[str, Any],
+ ) -> str:
+ return f"{self._base_url(api_base)}/agents/{name}"
+
+ def transform_delete_response(
+ self,
+ raw_response: httpx.Response,
+ name: str,
+ ) -> AgentDeleteResult:
+ """Gemini returns an empty body ``{}`` with HTTP 200 on success."""
+ self._raise_for_status(raw_response)
+ verbose_logger.debug(
+ "GeminiAgentsConfig delete (status=%d) agent '%s'",
+ raw_response.status_code,
+ name,
+ )
+ return AgentDeleteResult(name=name, deleted=True)
+
+ # ------------------------------------------------------------------ #
+ # LIST VERSIONS #
+ # ------------------------------------------------------------------ #
+
+ def transform_list_versions_request(
+ self,
+ name: str,
+ api_base: Optional[str],
+ litellm_params: Dict[str, Any],
+ ) -> Tuple[str, Dict[str, Any]]:
+ url = f"{self._base_url(api_base)}/agents/{name}/versions"
+ params: Dict[str, Any] = {}
+ if litellm_params.get("page_size"):
+ params["pageSize"] = litellm_params["page_size"]
+ if litellm_params.get("page_token"):
+ params["pageToken"] = litellm_params["page_token"]
+ return url, params
+
+ def transform_list_versions_response(
+ self,
+ raw_response: httpx.Response,
+ name: str,
+ ) -> AgentVersionsResponse:
+ """
+ Gemini returns:
+ {"agentVersions": [{"agent": "waverunner", "name": "agents/.../versions/uuid", ...}]}
+ """
+ self._raise_for_status(raw_response)
+ try:
+ data = raw_response.json()
+ except Exception:
+ data = {}
+ verbose_logger.debug(
+ "GeminiAgentsConfig list_versions response for '%s': %s", name, data
+ )
+ return AgentVersionsResponse(
+ agent_versions=data.get("agentVersions", []),
+ next_page_token=data.get("nextPageToken"),
+ )
diff --git a/litellm/llms/gemini/chat/transformation.py b/litellm/llms/gemini/chat/transformation.py
index 72569e5c6cd..b69b7e1913e 100644
--- a/litellm/llms/gemini/chat/transformation.py
+++ b/litellm/llms/gemini/chat/transformation.py
@@ -1,5 +1,7 @@
from typing import List, Optional, cast
+import litellm
+
from litellm.litellm_core_utils.prompt_templates.factory import (
convert_generic_image_chunk_to_openai_image_obj,
convert_to_anthropic_image_obj,
@@ -101,7 +103,10 @@ class GoogleAIStudioGeminiConfig(VertexGeminiConfig):
return supported_params
def _transform_messages(
- self, messages: List[AllMessageValues], model: Optional[str] = None
+ self,
+ messages: List[AllMessageValues],
+ model: Optional[str] = None,
+ litellm_params: Optional[dict] = None,
) -> List[ContentType]:
"""
Google AI Studio Gemini does not support HTTP/HTTPS URLs for files.
@@ -141,14 +146,26 @@ class GoogleAIStudioGeminiConfig(VertexGeminiConfig):
img_element["image_url"] = converted_image_url # type: ignore
elif element.get("type") == "file":
file_element = cast(ChatCompletionFileObject, element)
- file_id = file_element["file"].get("file_id")
+ _file_field = file_element.get("file")
+ if _file_field is None:
+ raise litellm.BadRequestError(
+ message="Content block has type='file' but is missing the required 'file' field",
+ model=model,
+ llm_provider="gemini",
+ )
+ file_id = _file_field.get("file_id")
if file_id and ("http://" in file_id or "https://" in file_id):
# Convert HTTP/HTTPS file URL to base64 data
try:
base64_data = convert_url_to_base64(file_id)
- file_element["file"]["file_data"] = base64_data # type: ignore
- file_element["file"].pop("file_id", None) # type: ignore
+ _file_field["file_data"] = base64_data # type: ignore
+ _file_field.pop("file_id", None) # type: ignore
except Exception:
# If conversion fails, leave as is and let the API handle it
pass
- return _gemini_convert_messages_with_history(messages=messages, model=model)
+ return _gemini_convert_messages_with_history(
+ messages=messages,
+ model=model,
+ litellm_params=litellm_params,
+ custom_llm_provider="gemini",
+ )
diff --git a/litellm/llms/gemini/interactions/transformation.py b/litellm/llms/gemini/interactions/transformation.py
index 593cbf7c2cf..b18b6a28ce4 100644
--- a/litellm/llms/gemini/interactions/transformation.py
+++ b/litellm/llms/gemini/interactions/transformation.py
@@ -6,13 +6,18 @@ Per OpenAPI spec (https://ai.google.dev/static/api/interactions.openapi.json):
- Get: GET https://generativelanguage.googleapis.com/{api_version}/interactions/{interaction_id}
- Delete: DELETE https://generativelanguage.googleapis.com/{api_version}/interactions/{interaction_id}
-This is a thin wrapper - no transformation needed since we follow the spec directly.
+Schema versioning:
+- Default (Api-Revision: 2026-05-20): new `steps` schema.
+- Legacy (Api-Revision: 2026-05-07): old `outputs` schema, controlled via
+ litellm.use_legacy_interactions_schema = True. Remove flag after June 8, 2026.
"""
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
import httpx
+import litellm
+
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.core_helpers import process_response_headers
from litellm.litellm_core_utils.url_utils import encode_url_path_segment
@@ -64,6 +69,7 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig):
"stream",
"store",
"background",
+ "environment",
"response_modalities",
"response_format",
"response_mime_type",
@@ -83,6 +89,15 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig):
api_key = GeminiModelInfo.get_api_key(litellm_params.get("api_key"))
if api_key:
headers["x-goog-api-key"] = api_key
+
+ # Inject the Api-Revision header to select the response schema.
+ # Default to the new `steps` schema unless the operator has opted out.
+ # Remove this conditional after June 8, 2026 and always use 2026-05-20.
+ if litellm.use_legacy_interactions_schema:
+ headers["Api-Revision"] = "2026-05-07"
+ else:
+ headers["Api-Revision"] = "2026-05-20"
+
return headers
def get_complete_url(
@@ -118,8 +133,19 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig):
headers: dict,
) -> Dict:
"""
- Build request body per OpenAPI spec - minimal transformation.
+ Build request body per OpenAPI spec.
+
+ When on the new schema (use_legacy_interactions_schema=False, the default):
+ - ``response_mime_type`` is folded into ``response_format`` and stripped from
+ the body (the field was removed in Api-Revision 2026-05-20).
+ - ``generation_config.image_config`` is moved to a ``response_format`` entry
+ with ``"type": "image"`` (also removed from generation_config in 2026-05-20).
+
+ When on the legacy schema (use_legacy_interactions_schema=True):
+ - All fields are forwarded as-is.
"""
+ use_legacy: bool = litellm.use_legacy_interactions_schema
+
request_body: Dict[str, Any] = {}
# Model or Agent (one required)
@@ -134,23 +160,81 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig):
if input is not None:
request_body["input"] = input
- # Pass through optional params directly (they match the spec)
+ # Pass through optional params — legacy schema keeps all fields as-is.
optional_keys = [
"tools",
"system_instruction",
- "generation_config",
"stream",
"store",
"background",
+ "environment",
"response_modalities",
- "response_format",
- "response_mime_type",
"previous_interaction_id",
]
for key in optional_keys:
if optional_params.get(key) is not None:
request_body[key] = optional_params[key]
+ if use_legacy:
+ # Legacy schema: forward response_mime_type and response_format as-is.
+ for key in ("response_format", "response_mime_type", "generation_config"):
+ if optional_params.get(key) is not None:
+ request_body[key] = optional_params[key]
+ else:
+ # New schema (Api-Revision: 2026-05-20):
+ # response_mime_type is removed — fold it into response_format.
+ response_format = optional_params.get("response_format")
+ response_mime_type = optional_params.get("response_mime_type")
+
+ if (
+ response_mime_type
+ and not isinstance(response_format, list)
+ and (
+ not isinstance(response_format, dict)
+ or "mime_type" not in response_format
+ )
+ ):
+ # Wrap the legacy schema into the new polymorphic format.
+ new_rf: Dict[str, Any] = {
+ "type": "text",
+ "mime_type": response_mime_type,
+ }
+ if response_format is not None:
+ new_rf["schema"] = response_format
+ response_format = new_rf
+
+ if response_format is not None:
+ request_body["response_format"] = response_format
+
+ # image_config moves out of generation_config into response_format.
+ generation_config: Optional[Dict[str, Any]] = optional_params.get(
+ "generation_config"
+ )
+ if generation_config is not None:
+ image_config = None
+ if isinstance(generation_config, dict):
+ generation_config = dict(
+ generation_config
+ ) # avoid mutating the caller's dict
+ image_config = generation_config.pop("image_config", None)
+ if not generation_config:
+ generation_config = None
+
+ if generation_config is not None:
+ request_body["generation_config"] = generation_config
+
+ if image_config is not None:
+ # Move image_config to response_format with type=image.
+ image_rf: Dict[str, Any] = {"type": "image", **image_config}
+ existing_rf = request_body.get("response_format")
+ if existing_rf is None:
+ request_body["response_format"] = image_rf
+ elif isinstance(existing_rf, list):
+ request_body["response_format"] = [*existing_rf, image_rf]
+ else:
+ # Convert single entry to array for multimodal output.
+ request_body["response_format"] = [existing_rf, image_rf]
+
return request_body
def transform_response(
diff --git a/litellm/llms/gemini/videos/transformation.py b/litellm/llms/gemini/videos/transformation.py
index c7116940b22..9714c8a3923 100644
--- a/litellm/llms/gemini/videos/transformation.py
+++ b/litellm/llms/gemini/videos/transformation.py
@@ -55,7 +55,7 @@ def _convert_image_to_gemini_format(image_file) -> Dict[str, str]:
def _usage_video_resolution_from_parameters(
- parameters: Dict[str, Any]
+ parameters: Dict[str, Any],
) -> Optional[str]:
"""Normalize Veo ``parameters.resolution`` for usage and cost tracking."""
res = parameters.get("resolution")
diff --git a/litellm/llms/infinity/rerank/transformation.py b/litellm/llms/infinity/rerank/transformation.py
index 314bf2f8a36..b9804605454 100644
--- a/litellm/llms/infinity/rerank/transformation.py
+++ b/litellm/llms/infinity/rerank/transformation.py
@@ -1,5 +1,5 @@
"""
-Transformation logic from Cohere's /v1/rerank format to Infinity's `/v1/rerank` format.
+Transformation logic from Cohere's /v1/rerank format to Infinity's `/v1/rerank` format.
Why separate file? Make it easy to see how transformation works
"""
diff --git a/litellm/llms/jina_ai/rerank/transformation.py b/litellm/llms/jina_ai/rerank/transformation.py
index ad4416925a6..56be754fc34 100644
--- a/litellm/llms/jina_ai/rerank/transformation.py
+++ b/litellm/llms/jina_ai/rerank/transformation.py
@@ -1,5 +1,5 @@
"""
-Transformation logic from Cohere's /v1/rerank format to Jina AI's `/v1/rerank` format.
+Transformation logic from Cohere's /v1/rerank format to Jina AI's `/v1/rerank` format.
Why separate file? Make it easy to see how transformation works
diff --git a/litellm/llms/lm_studio/embed/transformation.py b/litellm/llms/lm_studio/embed/transformation.py
index 1285550c30f..87f4f6e73d5 100644
--- a/litellm/llms/lm_studio/embed/transformation.py
+++ b/litellm/llms/lm_studio/embed/transformation.py
@@ -1,5 +1,5 @@
"""
-Transformation logic from OpenAI /v1/embeddings format to LM Studio's `/v1/embeddings` format.
+Transformation logic from OpenAI /v1/embeddings format to LM Studio's `/v1/embeddings` format.
Why separate file? Make it easy to see how transformation works
diff --git a/litellm/llms/novita/chat/transformation.py b/litellm/llms/novita/chat/transformation.py
index c05d2d7b2c5..5a64a124ade 100644
--- a/litellm/llms/novita/chat/transformation.py
+++ b/litellm/llms/novita/chat/transformation.py
@@ -1,5 +1,5 @@
"""
-Support for OpenAI's `/v1/chat/completions` endpoint.
+Support for OpenAI's `/v1/chat/completions` endpoint.
Calls done in OpenAI/openai.py as Novita AI is openai-compatible.
diff --git a/litellm/llms/nvidia_nim/chat/transformation.py b/litellm/llms/nvidia_nim/chat/transformation.py
index b8f8b04eb53..2ef92a90626 100644
--- a/litellm/llms/nvidia_nim/chat/transformation.py
+++ b/litellm/llms/nvidia_nim/chat/transformation.py
@@ -1,7 +1,7 @@
"""
-Nvidia NIM endpoint: https://docs.api.nvidia.com/nim/reference/databricks-dbrx-instruct-infer
+Nvidia NIM endpoint: https://docs.api.nvidia.com/nim/reference/databricks-dbrx-instruct-infer
-This is OpenAI compatible
+This is OpenAI compatible
This file only contains param mapping logic
diff --git a/litellm/llms/nvidia_nim/embed.py b/litellm/llms/nvidia_nim/embed.py
index 24c6cc34e4d..61c8e8244e4 100644
--- a/litellm/llms/nvidia_nim/embed.py
+++ b/litellm/llms/nvidia_nim/embed.py
@@ -1,7 +1,7 @@
"""
Nvidia NIM embeddings endpoint: https://docs.api.nvidia.com/nim/reference/nvidia-nv-embedqa-e5-v5-infer
-This is OpenAI compatible
+This is OpenAI compatible
This file only contains param mapping logic
diff --git a/litellm/llms/oci/chat/cohere.py b/litellm/llms/oci/chat/cohere.py
new file mode 100644
index 00000000000..ac92fd22aa8
--- /dev/null
+++ b/litellm/llms/oci/chat/cohere.py
@@ -0,0 +1,386 @@
+"""
+OCI Generative AI — Cohere-specific chat transformation helpers.
+
+Handles message history building, tool definition adaptation, non-streaming
+response parsing, and streaming chunk parsing for models served with
+``apiFormat="COHERE"`` (e.g. ``cohere.command-*``).
+"""
+
+import datetime
+import json
+from typing import Any, Dict, List, Optional
+
+import httpx
+from pydantic import ValidationError
+
+from litellm.llms.oci.chat.generic import (
+ _normalize_oci_finish_reason,
+ _synthesize_oci_tool_call_id,
+)
+from litellm.llms.oci.common_utils import (
+ OCI_JSON_TO_PYTHON_TYPES,
+ OCIError,
+ enrich_cohere_param_description,
+ resolve_oci_schema_anyof,
+ resolve_oci_schema_refs,
+ sanitize_oci_schema,
+)
+from litellm.types.llms.oci import (
+ CohereChatResult,
+ CohereMessage,
+ CohereParameterDefinition,
+ CohereStreamChunk,
+ CohereTool,
+ CohereToolCall,
+ CohereToolMessage,
+ CohereToolResult,
+)
+from litellm.types.llms.openai import AllMessageValues
+from litellm.types.utils import (
+ Choices,
+ Delta,
+ ModelResponse,
+ ModelResponseStream,
+ StreamingChoices,
+)
+from litellm.types.utils import Usage
+
+
+def _extract_text_content(content: Any) -> str:
+ """Return the plain-text representation of a message content value."""
+ if content is None:
+ return ""
+ if isinstance(content, str):
+ return content
+ if isinstance(content, list):
+ return "".join(
+ item.get("text", "")
+ for item in content
+ if isinstance(item, dict) and item.get("type") == "text"
+ )
+ return str(content)
+
+
+def adapt_messages_to_cohere_standard(
+ messages: List[AllMessageValues],
+) -> List[CohereMessage]:
+ """Build a Cohere ``chatHistory`` list from an OpenAI-format message array.
+
+ - All messages except the *last user message* are included. The caller pulls
+ the last user message into the request's top-level ``message`` field, so
+ trailing tool results (the standard agentic continuation pattern) still
+ appear in ``chatHistory`` and reach the model.
+ - If no user message exists, every message is included (no slice).
+ - System messages must be filtered out by the caller (they are routed into
+ ``preambleOverride`` separately) — they are not represented in
+ ``chatHistory``.
+ - Tool results are expressed as OCI ``CohereToolMessage.toolResults`` entries,
+ with the originating call's name and parameters resolved from the preceding
+ assistant message via a ``tool_call_id`` lookup.
+ """
+ # First pass: build tool_call_id → CohereToolCall so tool-result messages can
+ # reference the originating call by name and parameters.
+ tool_call_lookup: Dict[str, CohereToolCall] = {}
+ for msg in messages:
+ if msg.get("role") == "assistant":
+ tool_calls_raw: Any = msg.get("tool_calls") or []
+ for tc in tool_calls_raw:
+ tc_id = tc.get("id", "")
+ raw_args: Any = tc.get("function", {}).get("arguments", "{}")
+ try:
+ params: Dict[str, Any] = (
+ json.loads(raw_args) if isinstance(raw_args, str) else raw_args
+ )
+ except json.JSONDecodeError:
+ params = {}
+ tool_call_lookup[tc_id] = CohereToolCall(
+ name=str(tc.get("function", {}).get("name", "")),
+ parameters=params,
+ )
+
+ last_user_index = next(
+ (
+ i
+ for i in range(len(messages) - 1, -1, -1)
+ if messages[i].get("role") == "user"
+ ),
+ None,
+ )
+ history_source = (
+ messages
+ if last_user_index is None
+ else [m for i, m in enumerate(messages) if i != last_user_index]
+ )
+
+ chat_history: List[CohereMessage] = []
+ for msg in history_source:
+ role = msg.get("role")
+ content = _extract_text_content(msg.get("content"))
+
+ tool_calls: Optional[List[CohereToolCall]] = None
+ if role == "assistant" and msg.get("tool_calls"): # type: ignore[union-attr,typeddict-item]
+ tool_calls = []
+ for tc in msg["tool_calls"]: # type: ignore[union-attr,typeddict-item]
+ raw_arguments: Any = tc.get("function", {}).get("arguments", {})
+ if isinstance(raw_arguments, str):
+ try:
+ arguments: Dict[str, Any] = json.loads(raw_arguments)
+ except json.JSONDecodeError:
+ arguments = {}
+ else:
+ arguments = raw_arguments
+ tool_calls.append(
+ CohereToolCall(
+ name=str(tc.get("function", {}).get("name", "")),
+ parameters=arguments,
+ )
+ )
+
+ if role == "user":
+ chat_history.append(CohereMessage(role="USER", message=content))
+ elif role == "assistant":
+ chat_history.append(
+ CohereMessage(role="CHATBOT", message=content, toolCalls=tool_calls)
+ )
+ elif role == "tool":
+ tool_call_id = str(msg.get("tool_call_id", "") or "")
+ cohere_call = tool_call_lookup.get(
+ tool_call_id, CohereToolCall(name="", parameters={})
+ )
+ tool_result = CohereToolResult(
+ call=cohere_call,
+ outputs=[{"output": content}],
+ )
+ # OpenAI emits one tool-role message per parallel tool call, but
+ # the OCI Cohere API expects all results from a single assistant
+ # turn to share one TOOL history entry with multiple toolResults.
+ # Merge consecutive tool messages so the model sees the parallel
+ # call/result pairing correctly during agentic loops.
+ if chat_history and isinstance(chat_history[-1], CohereToolMessage):
+ chat_history[-1].toolResults.append(tool_result)
+ else:
+ chat_history.append(CohereToolMessage(toolResults=[tool_result]))
+
+ return chat_history
+
+
+def adapt_tool_definitions_to_cohere_standard(
+ tools: List[Dict[str, Any]],
+) -> List[CohereTool]:
+ """Adapt OpenAI-format tool definitions to the OCI Cohere format.
+
+ - Resolves ``$ref``/``$defs`` and ``anyOf`` patterns that OCI rejects.
+ - Maps JSON Schema type names to Python type names (``"string"`` → ``"str"``).
+ - Embeds unsupported constraints (enum, format, range, pattern) into the
+ parameter description so the model can still see them.
+ """
+ cohere_tools = []
+ for tool in tools:
+ function_def = tool.get("function", {})
+ raw_params = function_def.get("parameters", {})
+
+ resolved = sanitize_oci_schema(
+ resolve_oci_schema_anyof(resolve_oci_schema_refs(raw_params))
+ )
+ properties = resolved.get("properties", {})
+ required = resolved.get("required", [])
+
+ parameter_definitions = {}
+ for param_name, param_schema in properties.items():
+ json_type = param_schema.get("type", "string")
+ python_type = OCI_JSON_TO_PYTHON_TYPES.get(json_type, json_type)
+ parameter_definitions[param_name] = CohereParameterDefinition(
+ description=enrich_cohere_param_description(
+ param_schema.get("description", ""), param_schema
+ ),
+ type=python_type,
+ isRequired=param_name in required,
+ )
+
+ cohere_tools.append(
+ CohereTool(
+ name=function_def.get("name", ""),
+ description=function_def.get("description", ""),
+ parameterDefinitions=parameter_definitions,
+ )
+ )
+
+ return cohere_tools
+
+
+def handle_cohere_response(
+ json_response: dict,
+ model: str,
+ model_response: ModelResponse,
+ raw_response: httpx.Response,
+) -> ModelResponse:
+ """Parse a non-streaming Cohere OCI response into a LiteLLM ModelResponse."""
+ try:
+ cohere_response = CohereChatResult(**json_response)
+ except (TypeError, ValidationError) as e:
+ raise OCIError(
+ message=f"Response cannot be casted to CohereChatResult: {str(e)}",
+ status_code=raw_response.status_code,
+ )
+
+ model_response.model = model
+ model_response.created = int(datetime.datetime.now().timestamp())
+
+ response_text = cohere_response.chatResponse.text
+ finish_reason = _normalize_oci_finish_reason(
+ cohere_response.chatResponse.finishReason
+ )
+
+ tool_calls: Optional[List[Dict[str, Any]]] = None
+ if cohere_response.chatResponse.toolCalls:
+ tool_calls = [
+ {
+ "id": _synthesize_oci_tool_call_id(
+ i, tc.name, json.dumps(tc.parameters, sort_keys=True)
+ ),
+ "type": "function",
+ "function": {
+ "name": tc.name,
+ "arguments": json.dumps(tc.parameters),
+ },
+ }
+ for i, tc in enumerate(cohere_response.chatResponse.toolCalls)
+ ]
+
+ content: Optional[str] = response_text if response_text else None
+
+ # Only include ``tool_calls`` in the message dict when actually present.
+ # Passing an explicit ``None`` would let downstream consumers that key off
+ # ``"tool_calls" in message`` (rather than truthiness) incorrectly conclude
+ # that tool calls were attempted. Matches the generic handler's behaviour,
+ # which only sets ``message.tool_calls`` when tool calls are present.
+ message: Dict[str, Any] = {"role": "assistant", "content": content}
+ if tool_calls is not None:
+ message["tool_calls"] = tool_calls
+
+ model_response.choices = [
+ Choices(
+ index=0,
+ message=message,
+ finish_reason=finish_reason,
+ )
+ ]
+
+ usage_info = cohere_response.chatResponse.usage
+ if usage_info is not None:
+ model_response.usage = Usage( # type: ignore[attr-defined]
+ prompt_tokens=usage_info.promptTokens,
+ completion_tokens=usage_info.completionTokens,
+ total_tokens=usage_info.totalTokens,
+ )
+ else:
+ model_response.usage = Usage(prompt_tokens=0, completion_tokens=0, total_tokens=0) # type: ignore[attr-defined]
+
+ return model_response
+
+
+def handle_cohere_stream_chunk(
+ dict_chunk: dict,
+ prior_tool_calls_emitted: bool = False,
+ prior_text_emitted: bool = False,
+) -> ModelResponseStream:
+ """Parse a single Cohere SSE chunk into a LiteLLM ModelResponseStream.
+
+ ``prior_tool_calls_emitted`` lets the caller signal whether tool calls
+ were already emitted in earlier chunks of the same stream. When set, the
+ terminal consolidation chunk's tool calls are suppressed (they would
+ duplicate prior deltas); otherwise they are passed through so a stream
+ that delivers tool calls only on the terminal chunk doesn't silently
+ drop them.
+
+ ``prior_text_emitted`` plays the analogous role for the ``text`` field:
+ when set, the terminal consolidation chunk's ``text`` is suppressed
+ (it would re-emit the full assembled response on top of prior deltas);
+ when unset (e.g. a degenerate stream that delivers the entire response
+ in a single SSE event carrying both ``chatHistory`` and ``finishReason``),
+ the text is passed through so the response content isn't silently lost.
+ """
+ try:
+ typed_chunk = CohereStreamChunk(**dict_chunk)
+ except (TypeError, ValidationError) as e:
+ raise OCIError(
+ status_code=500,
+ message=f"Chunk cannot be parsed as CohereStreamChunk: {str(e)}",
+ )
+
+ if typed_chunk.index is None:
+ typed_chunk.index = 0
+
+ # OCI Cohere's terminal SSE event re-sends the full assembled response in
+ # `text` alongside a populated `chatHistory` and a non-null `finishReason`.
+ # Emitting that text would concatenate the whole response onto the
+ # already-streamed deltas. We require both signals to be present so that a
+ # future API change which adds `chatHistory` to intermediate chunks (or a
+ # rare early-populated case) doesn't silently drop legitimate token deltas.
+ is_terminal_consolidation = (
+ typed_chunk.chatHistory is not None and typed_chunk.finishReason is not None
+ )
+ # On non-terminal text-free chunks (e.g. tool-call-only or keep-alive
+ # chunks) emit ``content=None`` rather than ``content=""`` so downstream
+ # stream-mergers that distinguish "no text in this delta" from "an
+ # explicitly empty text delta" behave correctly.
+ #
+ # We only suppress the terminal chunk's ``text`` when the caller has
+ # confirmed that text deltas were already emitted earlier — otherwise
+ # (e.g. a degenerate stream that delivers the whole response in a
+ # single SSE event), passing it through is the only chance to surface it.
+ text: Optional[str] = (
+ None if (is_terminal_consolidation and prior_text_emitted) else typed_chunk.text
+ )
+
+ # Tool calls on the terminal consolidation chunk (whether from
+ # `typed_chunk.toolCalls` or from `chatHistory`) typically restate what
+ # was already streamed in intermediate chunks. Re-emitting them would
+ # mint fresh `uuid4` IDs and cause downstream consumers to execute each
+ # tool call twice. We only suppress when the caller has confirmed that
+ # tool calls were already emitted earlier — otherwise (e.g. a short
+ # response that delivers tool calls exclusively on the terminal chunk),
+ # passing them through is the only chance to surface them.
+ cohere_tool_calls = (
+ None
+ if (is_terminal_consolidation and prior_tool_calls_emitted)
+ else typed_chunk.toolCalls
+ )
+
+ tool_calls: Optional[List[Dict[str, Any]]] = None
+ if cohere_tool_calls:
+ tool_calls = [
+ {
+ # Cohere protocol has no tool-call id, so we synthesize one
+ # deterministically from the call's content/position. A random
+ # uuid4 per chunk would cause downstream stream-mergers to
+ # treat each chunk as a distinct tool call.
+ "id": _synthesize_oci_tool_call_id(
+ i, tc.name, json.dumps(tc.parameters, sort_keys=True)
+ ),
+ "type": "function",
+ "function": {
+ "name": tc.name,
+ "arguments": json.dumps(tc.parameters),
+ },
+ }
+ for i, tc in enumerate(cohere_tool_calls)
+ ]
+
+ finish_reason = _normalize_oci_finish_reason(typed_chunk.finishReason)
+
+ return ModelResponseStream(
+ choices=[
+ StreamingChoices(
+ index=typed_chunk.index,
+ delta=Delta(
+ content=text,
+ tool_calls=tool_calls,
+ provider_specific_fields=None,
+ thinking_blocks=None,
+ reasoning_content=None,
+ ),
+ finish_reason=finish_reason,
+ )
+ ]
+ )
diff --git a/litellm/llms/oci/chat/generic.py b/litellm/llms/oci/chat/generic.py
new file mode 100644
index 00000000000..2cc1ac77a40
--- /dev/null
+++ b/litellm/llms/oci/chat/generic.py
@@ -0,0 +1,477 @@
+"""
+OCI Generative AI — Generic-format chat transformation helpers.
+
+Handles message building, tool definition adaptation, non-streaming response
+parsing, and streaming chunk parsing for models served with
+``apiFormat="GENERIC"`` (e.g. Meta Llama, xAI Grok, Google Gemini).
+"""
+
+import datetime
+import hashlib
+from typing import Any, Dict, List, Optional, Union
+
+import httpx
+from pydantic import ValidationError
+
+from litellm.llms.oci.common_utils import (
+ OCIError,
+ resolve_oci_schema_anyof,
+ resolve_oci_schema_refs,
+ sanitize_oci_schema,
+)
+from litellm.types.llms.oci import (
+ OCICompletionResponse,
+ OCIContentPartUnion,
+ OCIImageContentPart,
+ OCIImageUrl,
+ OCIMessage,
+ OCIRoles,
+ OCIStreamChunk,
+ OCITextContentPart,
+ OCIToolCall,
+ OCIToolDefinition,
+ OCIVendors,
+)
+from litellm.types.llms.openai import AllMessageValues
+from litellm.types.utils import (
+ Delta,
+ ModelResponse,
+ ModelResponseStream,
+ StreamingChoices,
+)
+from litellm.types.utils import ChatCompletionMessageToolCall, Usage
+
+# Maps OpenAI role names to OCI GENERIC role names.
+open_ai_to_generic_oci_role_map: Dict[str, OCIRoles] = {
+ "system": "SYSTEM",
+ "user": "USER",
+ "assistant": "ASSISTANT",
+ "tool": "TOOL",
+}
+
+
+# ---------------------------------------------------------------------------
+# Message building
+# ---------------------------------------------------------------------------
+
+
+def adapt_messages_to_generic_oci_standard_content_message(
+ role: str, content: Union[str, list]
+) -> OCIMessage:
+ """Convert a plain-text or multipart content message to OCI format."""
+ new_content: List[OCIContentPartUnion] = []
+ if isinstance(content, str):
+ return OCIMessage(
+ role=open_ai_to_generic_oci_role_map[role],
+ content=[OCITextContentPart(text=content)],
+ toolCalls=None,
+ toolCallId=None,
+ )
+
+ for content_item in content:
+ if not isinstance(content_item, dict):
+ raise OCIError(
+ status_code=400, message="Each content item must be a dictionary"
+ )
+
+ item_type = content_item.get("type")
+ if not isinstance(item_type, str):
+ raise OCIError(
+ status_code=400,
+ message="Each content item must have a string `type` field",
+ )
+ if item_type not in ["text", "image_url"]:
+ raise OCIError(
+ status_code=400,
+ message=f"Content type `{item_type}` is not supported by OCI",
+ )
+
+ if item_type == "text":
+ text = content_item.get("text")
+ if not isinstance(text, str):
+ raise OCIError(
+ status_code=400,
+ message="Content item of type `text` must have a string `text` field",
+ )
+ new_content.append(OCITextContentPart(text=text))
+
+ elif item_type == "image_url":
+ image_url = content_item.get("image_url")
+ if isinstance(image_url, dict):
+ image_url = image_url.get("url")
+ if not isinstance(image_url, str):
+ raise OCIError(
+ status_code=400,
+ message="Prop `image_url` must be a string or an object with a `url` property",
+ )
+ new_content.append(OCIImageContentPart(imageUrl=OCIImageUrl(url=image_url)))
+
+ return OCIMessage(
+ role=open_ai_to_generic_oci_role_map[role],
+ content=new_content,
+ toolCalls=None,
+ toolCallId=None,
+ )
+
+
+def adapt_messages_to_generic_oci_standard_tool_call(
+ role: str, tool_calls: list
+) -> OCIMessage:
+ """Convert an assistant tool-call message to OCI format."""
+ tool_calls_formatted = []
+ for tool_call in tool_calls:
+ if not isinstance(tool_call, dict):
+ raise OCIError(
+ status_code=400, message="Each tool call must be a dictionary"
+ )
+ if tool_call.get("type") != "function":
+ raise OCIError(
+ status_code=400, message="OCI only supports function tool calls"
+ )
+
+ tool_call_id = tool_call.get("id")
+ if not isinstance(tool_call_id, str):
+ raise OCIError(status_code=400, message="Tool call `id` must be a string")
+
+ tool_function = tool_call.get("function")
+ if not isinstance(tool_function, dict):
+ raise OCIError(
+ status_code=400, message="Tool call `function` must be a dictionary"
+ )
+
+ function_name = tool_function.get("name")
+ if not isinstance(function_name, str):
+ raise OCIError(
+ status_code=400, message="Tool call `function.name` must be a string"
+ )
+
+ arguments = tool_call["function"].get("arguments", "{}")
+ if not isinstance(arguments, str):
+ raise OCIError(
+ status_code=400,
+ message="Tool call `function.arguments` must be a JSON string",
+ )
+
+ tool_calls_formatted.append(
+ OCIToolCall(
+ id=tool_call_id,
+ type="FUNCTION",
+ name=function_name,
+ arguments=arguments,
+ )
+ )
+
+ return OCIMessage(
+ role=open_ai_to_generic_oci_role_map[role],
+ content=None,
+ toolCalls=tool_calls_formatted,
+ toolCallId=None,
+ )
+
+
+def adapt_messages_to_generic_oci_standard_tool_response(
+ role: str, tool_call_id: str, content: str
+) -> OCIMessage:
+ """Convert a tool-result message to OCI format."""
+ return OCIMessage(
+ role=open_ai_to_generic_oci_role_map[role],
+ content=[OCITextContentPart(text=content)],
+ toolCalls=None,
+ toolCallId=tool_call_id,
+ )
+
+
+def adapt_messages_to_generic_oci_standard(
+ messages: List[AllMessageValues],
+) -> List[OCIMessage]:
+ """Convert an OpenAI-format message array to OCI GENERIC format."""
+ new_messages = []
+ for message in messages:
+ role = message["role"]
+ content = message.get("content")
+ tool_calls = message.get("tool_calls")
+ tool_call_id = message.get("tool_call_id")
+
+ if role == "assistant" and tool_calls is not None:
+ if not isinstance(tool_calls, list):
+ raise OCIError(
+ status_code=400, message="Message `tool_calls` must be a list"
+ )
+ new_messages.append(
+ adapt_messages_to_generic_oci_standard_tool_call(role, tool_calls)
+ )
+
+ elif role in ["system", "user", "assistant"] and content is not None:
+ if not isinstance(content, (str, list)):
+ raise OCIError(
+ status_code=400,
+ message="Message `content` must be a string or list of content parts",
+ )
+ new_messages.append(
+ adapt_messages_to_generic_oci_standard_content_message(role, content)
+ )
+
+ elif role == "tool":
+ if not isinstance(tool_call_id, str):
+ raise OCIError(
+ status_code=400,
+ message="Tool result message must have a string `tool_call_id`",
+ )
+ if not isinstance(content, str):
+ raise OCIError(
+ status_code=400,
+ message="Tool result message `content` must be a string",
+ )
+ new_messages.append(
+ adapt_messages_to_generic_oci_standard_tool_response(
+ role, tool_call_id, content
+ )
+ )
+
+ return new_messages
+
+
+# ---------------------------------------------------------------------------
+# Tool definition adaptation
+# ---------------------------------------------------------------------------
+
+
+def adapt_tool_definition_to_oci_standard(
+ tools: List[Dict], vendor: OCIVendors
+) -> List[OCIToolDefinition]:
+ """Convert OpenAI-format tool definitions to OCI GENERIC format.
+
+ Resolves ``$ref``/``$defs`` and ``anyOf`` that the OCI endpoint rejects.
+ """
+ new_tools = []
+ for tool in tools:
+ if tool["type"] != "function":
+ raise OCIError(status_code=400, message="OCI only supports function tools")
+
+ tool_function = tool.get("function")
+ if not isinstance(tool_function, dict):
+ raise OCIError(
+ status_code=400, message="Tool `function` must be a dictionary"
+ )
+
+ raw_params = tool_function.get("parameters", {})
+ resolved_params = sanitize_oci_schema(
+ resolve_oci_schema_anyof(resolve_oci_schema_refs(raw_params))
+ )
+
+ new_tools.append(
+ OCIToolDefinition(
+ type="FUNCTION",
+ name=tool_function.get("name"),
+ description=tool_function.get("description", ""),
+ parameters=resolved_params,
+ )
+ )
+
+ return new_tools
+
+
+def _normalize_oci_finish_reason(raw: Optional[str]) -> Optional[str]:
+ """Map an OCI-specific finish reason to its OpenAI-standard equivalent.
+
+ OCI emits ``COMPLETE`` / ``MAX_TOKENS`` / ``TOOL_CALL(S)`` plus a long tail
+ of error/cancel reasons (``ERROR``, ``ERROR_TOXIC``, ``ERROR_LIMIT``,
+ ``USER_CANCEL``, ``CONTENT_FILTERED``, ``CANCELLED``, ...). The OpenAI
+ spec only defines ``stop`` / ``length`` / ``tool_calls`` / ... — anything
+ else is collapsed to ``"stop"`` so downstream consumers switching on
+ ``finish_reason`` keep working. A ``None`` input passes through unchanged.
+ """
+ if raw is None:
+ return None
+ if raw == "COMPLETE":
+ return "stop"
+ if raw == "MAX_TOKENS":
+ return "length"
+ if raw in ("TOOL_CALL", "TOOL_CALLS"):
+ return "tool_calls"
+ return "stop"
+
+
+def _synthesize_oci_tool_call_id(position: int, name: str, arguments: str) -> str:
+ """Deterministic synthetic tool-call id derived from chunk content.
+
+ Used as a fallback when OCI omits ``id`` (always the case for the OCI
+ Cohere protocol, occasionally the case for OCI GENERIC streaming chunks).
+ A random ``uuid4`` per chunk would cause downstream stream-merging
+ consumers — which key off the tool-call ``id`` — to treat re-emissions of
+ the same logical call (e.g. terminal consolidation chunks, retries) as
+ distinct calls. A content-derived digest stays stable across identical
+ re-emissions while differing across truly distinct calls.
+ """
+ digest = hashlib.sha256(
+ f"{position}|{name}|{arguments}".encode("utf-8"),
+ usedforsecurity=False,
+ ).hexdigest()[:24]
+ return f"call_{digest}"
+
+
+def adapt_tools_to_openai_standard(
+ tools: List[OCIToolCall],
+) -> List[ChatCompletionMessageToolCall]:
+ """Convert OCI tool-call objects in a response to the OpenAI format."""
+ return [
+ ChatCompletionMessageToolCall(
+ id=tool.id or _synthesize_oci_tool_call_id(i, tool.name, tool.arguments),
+ type="function",
+ function={"name": tool.name, "arguments": tool.arguments},
+ )
+ for i, tool in enumerate(tools)
+ ]
+
+
+# ---------------------------------------------------------------------------
+# Response parsing
+# ---------------------------------------------------------------------------
+
+
+def handle_generic_response(
+ json_data: dict,
+ model: str,
+ model_response: ModelResponse,
+ raw_response: httpx.Response,
+) -> ModelResponse:
+ """Parse a non-streaming GENERIC OCI response into a LiteLLM ModelResponse."""
+ try:
+ completion_response = OCICompletionResponse(**json_data)
+ except (TypeError, ValidationError) as e:
+ raise OCIError(
+ message=f"Response cannot be casted to OCICompletionResponse: {str(e)}",
+ status_code=raw_response.status_code,
+ )
+
+ iso_str = completion_response.chatResponse.timeCreated
+ dt = datetime.datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
+ model_response.created = int(dt.timestamp())
+ model_response.model = completion_response.modelId
+
+ if not completion_response.chatResponse.choices:
+ raise OCIError(
+ message="OCI response contained no choices",
+ status_code=raw_response.status_code,
+ )
+
+ response_choice = completion_response.chatResponse.choices[0]
+ message = model_response.choices[0].message # type: ignore
+ response_message = response_choice.message
+ if response_message is not None:
+ if response_message.content:
+ # Concatenate all text parts — matches the streaming handler, which
+ # iterates the full content array. Skips non-text parts (e.g. image
+ # parts) so a leading non-text part doesn't suppress trailing text.
+ text: Optional[str] = None
+ for item in response_message.content:
+ if isinstance(item, OCITextContentPart):
+ text = (text or "") + item.text
+ if text is not None:
+ message.content = text
+ if response_message.toolCalls:
+ message.tool_calls = adapt_tools_to_openai_standard(
+ response_message.toolCalls
+ )
+
+ model_response.choices[0].finish_reason = _normalize_oci_finish_reason( # type: ignore[union-attr,assignment]
+ response_choice.finishReason
+ )
+
+ oci_usage = completion_response.chatResponse.usage
+ reasoning_tokens: Optional[int] = None
+ if (
+ oci_usage.completionTokensDetails
+ and oci_usage.completionTokensDetails.reasoningTokens is not None
+ ):
+ reasoning_tokens = oci_usage.completionTokensDetails.reasoningTokens
+ model_response.usage = Usage( # type: ignore[attr-defined]
+ prompt_tokens=oci_usage.promptTokens,
+ completion_tokens=oci_usage.completionTokens or 0,
+ total_tokens=oci_usage.totalTokens,
+ reasoning_tokens=reasoning_tokens,
+ )
+
+ return model_response
+
+
+def handle_generic_stream_chunk(dict_chunk: dict) -> ModelResponseStream:
+ """Parse a single GENERIC SSE chunk into a LiteLLM ModelResponseStream."""
+ # OCI streams tool calls progressively — early chunks may omit required fields.
+ if dict_chunk.get("message") and dict_chunk["message"].get("toolCalls"):
+ for tool_call in dict_chunk["message"]["toolCalls"]:
+ tool_call.setdefault("arguments", "")
+ tool_call.setdefault("id", "")
+ tool_call.setdefault("name", "")
+
+ try:
+ typed_chunk = OCIStreamChunk(**dict_chunk)
+ except (TypeError, ValidationError) as e:
+ raise OCIError(
+ status_code=500,
+ message=f"Chunk cannot be parsed as OCIStreamChunk: {str(e)}",
+ )
+
+ if typed_chunk.index is None:
+ typed_chunk.index = 0
+
+ # Emit ``content=None`` rather than ``content=""`` on chunks with no text
+ # parts (e.g. tool-call-only or keep-alive chunks) so downstream
+ # stream-mergers that distinguish "no text in this delta" from "an
+ # explicitly empty text delta" behave correctly.
+ text: Optional[str] = None
+ if typed_chunk.message and typed_chunk.message.content:
+ for item in typed_chunk.message.content:
+ if isinstance(item, OCITextContentPart):
+ text = (text or "") + item.text
+ elif isinstance(item, OCIImageContentPart):
+ raise OCIError(
+ status_code=500,
+ message="OCI returned image content in a streaming response — not supported",
+ )
+ else:
+ raise OCIError(
+ status_code=500,
+ message=f"Unsupported content type in OCI streaming response: {item.type}",
+ )
+
+ # Build plain tool-call dicts inline (matching the shape produced by
+ # ``handle_cohere_stream_chunk``) rather than calling
+ # ``adapt_tools_to_openai_standard`` and ``model_dump``-ing the typed
+ # objects. Both code paths feed ``Delta.tool_calls``, so emitting the
+ # same minimal ``{"id", "type", "function": {"name", "arguments"}}``
+ # shape keeps downstream stream-mergers behaving identically across
+ # GENERIC and Cohere chunks.
+ tool_calls: Optional[List[Dict[str, Any]]] = None
+ if typed_chunk.message and typed_chunk.message.toolCalls:
+ tool_calls = [
+ {
+ "id": tc.id or _synthesize_oci_tool_call_id(i, tc.name, tc.arguments),
+ "type": "function",
+ "function": {
+ "name": tc.name,
+ "arguments": tc.arguments,
+ },
+ }
+ for i, tc in enumerate(typed_chunk.message.toolCalls)
+ ]
+
+ finish_reason: Optional[str] = _normalize_oci_finish_reason(
+ typed_chunk.finishReason
+ )
+
+ return ModelResponseStream(
+ choices=[
+ StreamingChoices(
+ index=typed_chunk.index,
+ delta=Delta(
+ content=text,
+ tool_calls=tool_calls,
+ provider_specific_fields=None,
+ thinking_blocks=None,
+ reasoning_content=None,
+ ),
+ finish_reason=finish_reason,
+ )
+ ]
+ )
diff --git a/litellm/llms/oci/chat/transformation.py b/litellm/llms/oci/chat/transformation.py
index 62104e921a4..f050f9eea36 100644
--- a/litellm/llms/oci/chat/transformation.py
+++ b/litellm/llms/oci/chat/transformation.py
@@ -1,20 +1,26 @@
-import base64
-import datetime
-import hashlib
+"""
+OCI Generative AI — chat transformation orchestrator.
+
+This module wires together the Cohere-specific and Generic-model helpers to
+implement the LiteLLM BaseConfig interface. Heavy-lifting lives in:
+
+ - :mod:`litellm.llms.oci.chat.cohere` — Cohere message/tool/response logic
+ - :mod:`litellm.llms.oci.chat.generic` — Generic message/tool/response logic
+ - :mod:`litellm.llms.oci.common_utils` — auth, signing, schema utilities
+"""
+
import json
-from dataclasses import dataclass
from typing import (
TYPE_CHECKING,
Any,
AsyncIterator,
Dict,
+ Iterator,
List,
Optional,
- Protocol,
Tuple,
Union,
)
-from urllib.parse import urlparse
import httpx
@@ -28,43 +34,43 @@ from litellm.llms.custom_httpx.http_handler import (
get_async_httpx_client,
version,
)
-from litellm.llms.oci.common_utils import OCIError
+from litellm.llms.oci.chat.cohere import (
+ _extract_text_content,
+ adapt_messages_to_cohere_standard,
+ adapt_tool_definitions_to_cohere_standard,
+ handle_cohere_response,
+ handle_cohere_stream_chunk,
+)
+from litellm.llms.oci.chat.generic import (
+ adapt_messages_to_generic_oci_standard,
+ adapt_tool_definition_to_oci_standard,
+ handle_generic_response,
+ handle_generic_stream_chunk,
+)
+from litellm.llms.oci.common_utils import (
+ OCI_API_VERSION,
+ OCIError,
+ OCIRequestWrapper, # re-exported for backwards compatibility
+ get_oci_base_url,
+ resolve_oci_credentials,
+ sign_oci_request,
+ validate_oci_environment,
+)
from litellm.types.llms.oci import (
CohereChatRequest,
- CohereMessage,
- CohereChatResult,
- CohereParameterDefinition,
- CohereStreamChunk,
- CohereTool,
- CohereToolCall,
OCIChatRequestPayload,
OCICompletionPayload,
- OCICompletionResponse,
- OCIContentPartUnion,
- OCIImageContentPart,
- OCIImageUrl,
- OCIMessage,
- OCIRoles,
OCIServingMode,
- OCIStreamChunk,
- OCITextContentPart,
- OCIToolCall,
- OCIToolDefinition,
OCIVendors,
)
from litellm.types.llms.openai import AllMessageValues
from litellm.types.utils import (
- Delta,
LlmProviders,
ModelResponse,
ModelResponseStream,
- StreamingChoices,
-)
-from litellm.utils import (
- ChatCompletionMessageToolCall,
- CustomStreamWrapper,
- Usage,
)
+from litellm.utils import supports_reasoning
+from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
@@ -74,142 +80,157 @@ else:
LiteLLMLoggingObj = Any
-class OCISignerProtocol(Protocol):
- """
- Protocol for OCI request signers (e.g., oci.signer.Signer).
-
- This protocol defines the interface expected for OCI SDK signer objects.
- Compatible with the OCI Python SDK's Signer class.
-
- See: https://docs.oracle.com/en-us/iaas/tools/python/latest/api/signing.html
- """
-
- def do_request_sign(
- self, request: Any, *, enforce_content_headers: bool = False
- ) -> None:
- """
- Sign an HTTP request by adding authentication headers.
-
- Args:
- request: Request object with method, url, headers, body, and path_url attributes
- enforce_content_headers: Whether to enforce content-type and content-length headers
- """
- ...
-
-
-@dataclass
-class OCIRequestWrapper:
- """
- Wrapper for HTTP requests compatible with OCI signer interface.
-
- This class wraps request data in a format compatible with OCI SDK signers,
- which expect objects with method, url, headers, body, and path_url attributes.
- """
-
- method: str
- url: str
- headers: dict
- body: bytes
-
- @property
- def path_url(self) -> str:
- """Returns the path + query string for OCI signing."""
- parsed_url = urlparse(self.url)
- return parsed_url.path + ("?" + parsed_url.query if parsed_url.query else "")
-
-
-def sha256_base64(data: bytes) -> str:
- digest = hashlib.sha256(data).digest()
- return base64.b64encode(digest).decode()
-
-
-def build_signature_string(method, path, headers, signed_headers):
- lines = []
- for header in signed_headers:
- if header == "(request-target)":
- value = f"{method.lower()} {path}"
- else:
- value = headers[header]
- lines.append(f"{header}: {value}")
- return "\n".join(lines)
-
-
-def load_private_key_from_str(key_str: str):
- try:
- from cryptography.hazmat.primitives import serialization
- from cryptography.hazmat.primitives.asymmetric import rsa
- except ImportError as e:
- raise ImportError(
- "cryptography package is required for OCI authentication. "
- "Please install it with: pip install cryptography"
- ) from e
-
- key = serialization.load_pem_private_key(
- key_str.encode("utf-8"),
- password=None,
- )
- if not isinstance(key, rsa.RSAPrivateKey):
- raise TypeError(
- "The provided private key is not an RSA key, which is required for OCI signing."
- )
- return key
-
-
-def load_private_key_from_file(file_path: str):
- """Loads a private key from a file path"""
- try:
- with open(file_path, "r", encoding="utf-8") as f:
- key_str = f.read().strip()
- except FileNotFoundError:
- raise FileNotFoundError(f"Private key file not found: {file_path}")
- except OSError as e:
- raise OSError(f"Failed to read private key file '{file_path}': {e}") from e
-
- if not key_str:
- raise ValueError(f"Private key file is empty: {file_path}")
-
- return load_private_key_from_str(key_str)
-
-
-def get_vendor_from_model(model: str) -> OCIVendors:
- """
- Extracts the vendor from the model name.
-
- OCI GenAI API uses two apiFormat values:
- - "COHERE" for Cohere models (command-r, command-a, etc.)
- - "GENERIC" for all other models (Meta Llama, xAI Grok, Google Gemini, etc.)
-
- Args:
- model (str): The model name (e.g., "cohere.command-a-03-2025", "meta.llama-3.3-70b-instruct").
- Returns:
- OCIVendors: The vendor enum value.
- """
- vendor = model.split(".")[0].lower()
- if vendor == "cohere":
- return OCIVendors.COHERE
- else:
- return OCIVendors.GENERIC
-
-
-# 5 minute timeout (models may need to load)
+# Streaming timeout — generous because OCI models may need to warm up on first request
STREAMING_TIMEOUT = 60 * 5
+def _model_uses_max_completion_tokens(model: str) -> bool:
+ """Return True for OCI-hosted models that require ``maxCompletionTokens``.
+
+ Reasoning models on OCI (e.g. the OpenAI GPT-5 family) reject ``maxTokens``
+ with HTTP 400 and require ``maxCompletionTokens`` per OpenAI's reasoning-API
+ convention. Driven by ``supports_reasoning`` in
+ ``model_prices_and_context_window.json`` so new model families are picked
+ up via a catalog update rather than a code change.
+ """
+ if not model:
+ return False
+ name = model[4:] if model.lower().startswith("oci/") else model
+ return supports_reasoning(model=name, custom_llm_provider="oci")
+
+
+def _iter_sse_events(stream: Iterator[str]) -> Iterator[str]:
+ """Yield one ``data:`` SSE line at a time from a sync text stream.
+
+ The OCI streaming endpoint does not align SSE event boundaries with HTTP
+ read boundaries. A single read may carry multiple events, a single event
+ may straddle two reads, and some events arrive separated by only ``\\n``
+ instead of ``\\n\\n``. This helper buffers across reads and yields each
+ complete ``data:`` line so JSON parsing downstream never sees a partial
+ payload.
+ """
+ buffer = ""
+ for item in stream:
+ buffer += item
+ while "\n" in buffer:
+ line, buffer = buffer.split("\n", 1)
+ stripped = line.strip()
+ if stripped.startswith("data:"):
+ yield stripped
+ stripped = buffer.strip()
+ if stripped.startswith("data:"):
+ yield stripped
+
+
+async def _aiter_sse_events(stream: AsyncIterator[str]) -> AsyncIterator[str]:
+ """Async twin of :func:`_iter_sse_events`."""
+ buffer = ""
+ async for item in stream:
+ buffer += item
+ while "\n" in buffer:
+ line, buffer = buffer.split("\n", 1)
+ stripped = line.strip()
+ if stripped.startswith("data:"):
+ yield stripped
+ stripped = buffer.strip()
+ if stripped.startswith("data:"):
+ yield stripped
+
+
+def _normalize_tool_choice(selected_params: Dict) -> None:
+ tc = selected_params.get("toolChoice")
+ if tc is None:
+ return
+ if isinstance(tc, str):
+ tc_map = {
+ "auto": {"type": "AUTO"},
+ "none": {"type": "NONE"},
+ "required": {"type": "REQUIRED"},
+ "any": {"type": "REQUIRED"},
+ }
+ selected_params["toolChoice"] = tc_map.get(
+ tc.lower(), {"type": "FUNCTION", "name": tc}
+ )
+ return
+ if isinstance(tc, dict):
+ raw_type = tc.get("type")
+ if not isinstance(raw_type, str):
+ raise OCIError(
+ status_code=400,
+ message=f"Invalid tool_choice for OCI: missing or non-string 'type' in {tc!r}",
+ )
+ upper = raw_type.upper()
+ if upper == "FUNCTION":
+ fn = tc.get("function")
+ name = fn.get("name") if isinstance(fn, dict) else tc.get("name")
+ if not (isinstance(name, str) and name):
+ raise OCIError(
+ status_code=400,
+ message="Invalid tool_choice for OCI: 'FUNCTION' type requires a non-empty function name",
+ )
+ selected_params["toolChoice"] = {"type": "FUNCTION", "name": name}
+ elif upper in {"AUTO", "NONE", "REQUIRED"}:
+ selected_params["toolChoice"] = {"type": upper}
+ else:
+ raise OCIError(
+ status_code=400,
+ message=(
+ f"Invalid tool_choice for OCI: unsupported type {raw_type!r}; "
+ "expected one of 'FUNCTION', 'AUTO', 'NONE', 'REQUIRED'"
+ ),
+ )
+ return
+ raise OCIError(
+ status_code=400,
+ message=(
+ f"Invalid tool_choice for OCI: expected str or dict, got "
+ f"{type(tc).__name__}"
+ ),
+ )
+
+
+def _normalize_response_format(selected_params: Dict, vendor: OCIVendors) -> None:
+ rf = selected_params.get("responseFormat")
+ if not isinstance(rf, dict) or "type" not in rf:
+ return
+ rf_payload = dict(rf)
+ selected_params["responseFormat"] = rf_payload
+ response_type = rf_payload["type"]
+ if "json_schema" in rf_payload:
+ raw_schema = rf_payload.pop("json_schema")
+ rf_payload["jsonSchema"] = (
+ dict(raw_schema) if isinstance(raw_schema, dict) else raw_schema
+ )
+ if vendor == OCIVendors.COHERE:
+ rf_payload["type"] = response_type
+ else:
+ fmt = response_type.upper()
+ rf_payload["type"] = "JSON_OBJECT" if fmt == "JSON" else fmt
+
+
+def get_vendor_from_model(model: str) -> OCIVendors:
+ """Return the OCI vendor enum for a model name.
+
+ OCI GenAI uses two ``apiFormat`` values:
+
+ - ``"COHERE"`` for Cohere models (``cohere.*``)
+ - ``"GENERIC"`` for all others (Meta Llama, xAI Grok, Google Gemini, …)
+ """
+ name = model[4:] if model.lower().startswith("oci/") else model
+ vendor = name.split(".")[0].lower()
+ if vendor == "cohere":
+ return OCIVendors.COHERE
+ return OCIVendors.GENERIC
+
+
class OCIChatConfig(BaseConfig):
- """
- Configuration class for OCI's API interface.
- """
+ """LiteLLM BaseConfig implementation for OCI Generative AI chat."""
- def __init__(
- self,
- ) -> None:
- locals_ = locals().copy()
- for key, value in locals_.items():
- if key != "self" and value is not None:
- setattr(self.__class__, key, value)
- # mark the class as using a custom stream wrapper because the default only iterates on lines
- setattr(self.__class__, "has_custom_stream_wrapper", True)
+ @property
+ def has_custom_stream_wrapper(self) -> bool:
+ return True
+ def __init__(self) -> None:
self.openai_to_oci_generic_param_map = {
"stream": "isStream",
"max_tokens": "maxTokens",
@@ -221,6 +242,7 @@ class OCIChatConfig(BaseConfig):
"logit_bias": "logitBias",
"n": "numGenerations",
"presence_penalty": "presencePenalty",
+ "reasoning_effort": "reasoningEffort",
"seed": "seed",
"stop": "stop",
"tool_choice": "toolChoice",
@@ -239,25 +261,43 @@ class OCIChatConfig(BaseConfig):
"response_format": "responseFormat",
}
- # Cohere and Gemini use the same parameter mapping as GENERIC
- self.openai_to_oci_cohere_param_map = (
- self.openai_to_oci_generic_param_map.copy()
- )
+ # Cohere param map differs from GENERIC in three ways:
+ # - tool_choice is unsupported
+ # - stop sequences key is "stopSequences" not "stop"
+ # - n (numGenerations) is GENERIC-only
+ # The unsupported keys are kept in the map with value ``False`` so
+ # ``map_openai_params`` either drops them (under drop_params) or raises
+ # a clear error, rather than silently passing them through.
+ self.openai_to_oci_cohere_param_map = {
+ k: ("stopSequences" if k == "stop" else v)
+ for k, v in self.openai_to_oci_generic_param_map.items()
+ }
+ self.openai_to_oci_cohere_param_map["tool_choice"] = False
+ self.openai_to_oci_cohere_param_map["n"] = False
+ # ``top_k`` is not a standard OpenAI param, but Cohere's chat request
+ # accepts ``topK`` and LiteLLM commonly forwards ``top_k`` as a
+ # passthrough param. Cohere-only — ``OCIChatRequestPayload`` (GENERIC)
+ # has no ``topK`` field.
+ self.openai_to_oci_cohere_param_map["top_k"] = "topK"
+ # OCI Cohere models are not reasoning models; mark reasoning_effort
+ # explicitly unsupported so callers either get a clear error or have
+ # the param dropped under drop_params, rather than silently passing
+ # through and tripping Pydantic validation on CohereChatRequest.
+ self.openai_to_oci_cohere_param_map["reasoning_effort"] = False
+ # CohereChatRequest has no logProbs/logitBias fields, so passing these
+ # through would be silently dropped by Pydantic. Mark them unsupported
+ # so get_supported_openai_params doesn't advertise them and callers
+ # get a clear error (or drop_params behaviour) instead.
+ self.openai_to_oci_cohere_param_map["logprobs"] = False
+ self.openai_to_oci_cohere_param_map["logit_bias"] = False
def get_supported_openai_params(self, model: str) -> List[str]:
- supported_params = []
- vendor = get_vendor_from_model(model)
- if vendor == OCIVendors.COHERE:
- open_ai_to_oci_param_map = self.openai_to_oci_cohere_param_map
- open_ai_to_oci_param_map.pop("tool_choice")
- open_ai_to_oci_param_map.pop("max_retries")
- else:
- open_ai_to_oci_param_map = self.openai_to_oci_generic_param_map
- for key, value in open_ai_to_oci_param_map.items():
- if value:
- supported_params.append(key)
-
- return supported_params
+ param_map = (
+ self.openai_to_oci_cohere_param_map
+ if get_vendor_from_model(model) == OCIVendors.COHERE
+ else self.openai_to_oci_generic_param_map
+ )
+ return [key for key, value in param_map.items() if value]
def map_openai_params(
self,
@@ -268,238 +308,34 @@ class OCIChatConfig(BaseConfig):
) -> dict:
adapted_params = {}
vendor = get_vendor_from_model(model)
- if vendor == OCIVendors.COHERE:
- open_ai_to_oci_param_map = self.openai_to_oci_cohere_param_map
- else:
- open_ai_to_oci_param_map = self.openai_to_oci_generic_param_map
-
- all_params = {**non_default_params, **optional_params}
-
- for key, value in all_params.items():
- alias = open_ai_to_oci_param_map.get(key)
+ param_map = (
+ self.openai_to_oci_cohere_param_map
+ if vendor == OCIVendors.COHERE
+ else self.openai_to_oci_generic_param_map
+ )
+ for key, value in {**non_default_params, **optional_params}.items():
+ alias = param_map.get(key)
if alias is False:
- # Workaround for mypy issue
if drop_params or litellm.drop_params:
continue
- raise Exception(f"param `{key}` is not supported on OCI")
-
+ raise OCIError(
+ status_code=400,
+ message=f"param `{key}` is not supported on OCI",
+ )
if alias is None:
adapted_params[key] = value
continue
-
adapted_params[alias] = value
-
+ # Preserve the original OpenAI ``response_format`` key alongside the
+ # OCI-mapped ``responseFormat`` so downstream litellm framework code
+ # (e.g. ``json_mode`` detection, logging) that inspects
+ # ``optional_params["response_format"]`` continues to work.
if alias == "responseFormat":
adapted_params["response_format"] = value
return adapted_params
- def _sign_with_oci_signer(
- self,
- headers: dict,
- optional_params: dict,
- request_data: dict,
- api_base: str,
- ) -> Tuple[dict, bytes]:
- """
- Sign request using OCI SDK Signer object.
-
- Args:
- headers: Request headers to be signed
- optional_params: Optional parameters including oci_signer
- request_data: The request body dict to be sent in HTTP request
- api_base: The complete URL for the HTTP request
-
- Returns:
- Tuple of (signed_headers, encoded_body)
-
- Raises:
- OCIError: If signing fails
- ValueError: If HTTP method is unsupported
- """
- oci_signer = optional_params.get("oci_signer")
- body = json.dumps(request_data).encode("utf-8")
- method = str(optional_params.get("method", "POST")).upper()
-
- if method not in ["POST", "GET", "PUT", "DELETE", "PATCH"]:
- raise ValueError(f"Unsupported HTTP method: {method}")
-
- prepared_headers = headers.copy()
- prepared_headers.setdefault("content-type", "application/json")
- prepared_headers.setdefault("content-length", str(len(body)))
-
- request_wrapper = OCIRequestWrapper(
- method=method, url=api_base, headers=prepared_headers, body=body
- )
-
- if oci_signer is None:
- raise ValueError(
- "oci_signer cannot be None when calling _sign_with_oci_signer"
- )
-
- try:
- oci_signer.do_request_sign(request_wrapper, enforce_content_headers=True)
- except Exception as e:
- raise OCIError(
- status_code=500,
- message=(
- f"Failed to sign request with provided oci_signer: {str(e)}. "
- "The signer must implement the OCI SDK Signer interface with a "
- "do_request_sign(request, enforce_content_headers=True) method. "
- "See: https://docs.oracle.com/en-us/iaas/tools/python/latest/api/signing.html"
- ),
- ) from e
-
- headers.update(request_wrapper.headers)
- return headers, body
-
- def _sign_with_manual_credentials(
- self,
- headers: dict,
- optional_params: dict,
- request_data: dict,
- api_base: str,
- ) -> Tuple[dict, None]:
- """
- Sign request using manual OCI credentials.
-
- Args:
- headers: Request headers to be signed
- optional_params: Optional parameters including OCI credentials
- request_data: The request body dict to be sent in HTTP request
- api_base: The complete URL for the HTTP request
-
- Returns:
- Tuple of (signed_headers, None)
-
- Raises:
- Exception: If required credentials are missing
- ImportError: If cryptography package is not installed
- """
- oci_region = optional_params.get("oci_region", "us-ashburn-1")
- api_base = (
- api_base
- or litellm.api_base
- or f"https://inference.generativeai.{oci_region}.oci.oraclecloud.com"
- )
- oci_user = optional_params.get("oci_user")
- oci_fingerprint = optional_params.get("oci_fingerprint")
- oci_tenancy = optional_params.get("oci_tenancy")
- oci_key = optional_params.get("oci_key")
- oci_key_file = optional_params.get("oci_key_file")
-
- if (
- not oci_user
- or not oci_fingerprint
- or not oci_tenancy
- or not (oci_key or oci_key_file)
- ):
- raise Exception(
- "Missing required parameters: oci_user, oci_fingerprint, oci_tenancy, "
- "and at least one of oci_key or oci_key_file."
- )
-
- method = str(optional_params.get("method", "POST")).upper()
- body = json.dumps(request_data).encode("utf-8")
- parsed = urlparse(api_base)
- path = parsed.path or "/"
- host = parsed.netloc
-
- date = datetime.datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S GMT")
- content_type = headers.get("content-type", "application/json")
- content_length = str(len(body))
- x_content_sha256 = sha256_base64(body)
-
- headers_to_sign = {
- "date": date,
- "host": host,
- "content-type": content_type,
- "content-length": content_length,
- "x-content-sha256": x_content_sha256,
- }
-
- signed_headers = [
- "date",
- "(request-target)",
- "host",
- "content-length",
- "content-type",
- "x-content-sha256",
- ]
- signing_string = build_signature_string(
- method, path, headers_to_sign, signed_headers
- )
-
- try:
- from cryptography.hazmat.primitives import hashes
- from cryptography.hazmat.primitives.asymmetric import padding
- except ImportError as e:
- raise ImportError(
- "cryptography package is required for OCI authentication. "
- "Please install it with: pip install cryptography"
- ) from e
-
- # Handle oci_key - it should be a string (PEM content)
- oci_key_content = None
- if oci_key:
- if isinstance(oci_key, str):
- oci_key_content = oci_key
- # Fix common issues with PEM content
- # Replace escaped newlines with actual newlines
- oci_key_content = oci_key_content.replace("\\n", "\n")
- # Ensure proper line endings
- if "\r\n" in oci_key_content:
- oci_key_content = oci_key_content.replace("\r\n", "\n")
- else:
- raise OCIError(
- status_code=400,
- message=f"oci_key must be a string containing the PEM private key content. "
- f"Got type: {type(oci_key).__name__}",
- )
-
- private_key = (
- load_private_key_from_str(oci_key_content)
- if oci_key_content
- else load_private_key_from_file(oci_key_file) if oci_key_file else None
- )
-
- if private_key is None:
- raise OCIError(
- status_code=400,
- message="Private key is required for OCI authentication. Please provide either oci_key or oci_key_file.",
- )
-
- signature = private_key.sign(
- signing_string.encode("utf-8"),
- padding.PKCS1v15(),
- hashes.SHA256(),
- )
- signature_b64 = base64.b64encode(signature).decode()
-
- key_id = f"{oci_tenancy}/{oci_user}/{oci_fingerprint}"
-
- authorization = (
- 'Signature version="1",'
- f'keyId="{key_id}",'
- 'algorithm="rsa-sha256",'
- f'headers="{" ".join(signed_headers)}",'
- f'signature="{signature_b64}"'
- )
-
- headers.update(
- {
- "authorization": authorization,
- "date": date,
- "host": host,
- "content-type": content_type,
- "content-length": content_length,
- "x-content-sha256": x_content_sha256,
- }
- )
-
- return headers, None
-
def sign_request(
self,
headers: dict,
@@ -510,61 +346,16 @@ class OCIChatConfig(BaseConfig):
model: Optional[str] = None,
stream: Optional[bool] = None,
fake_stream: Optional[bool] = None,
- ) -> Tuple[dict, Optional[bytes]]:
- """
- Sign the OCI request by adding authentication headers.
-
- Supports two signing modes:
- 1. OCI SDK Signer: Use an oci_signer object to sign the request
- 2. Manual Signing: Use OCI credentials to manually sign the request
-
- Args:
- headers: Request headers to be signed
- optional_params: Optional parameters including auth credentials or oci_signer
- request_data: The request body dict to be sent in HTTP request
- api_base: The complete URL for the HTTP request
- api_key: Optional API key (not used for OCI)
- model: Optional model name
- stream: Optional streaming flag
- fake_stream: Optional fake streaming flag
-
- Returns:
- Tuple of (signed_headers, encoded_body):
- - If oci_signer is provided: Returns (headers, body) where body is the encoded JSON
- - If manual credentials are provided: Returns (headers, None) as body is not returned
- for the manual signing path
-
- Raises:
- OCIError: If signing fails with oci_signer
- Exception: If required credentials are missing
- ImportError: If cryptography package is not installed (manual signing only)
-
- Example:
- >>> from oci.signer import Signer
- >>> signer = Signer(
- ... tenancy="ocid1.tenancy.oc1..",
- ... user="ocid1.user.oc1..",
- ... fingerprint="xx:xx:xx",
- ... private_key_file_location="~/.oci/key.pem"
- ... )
- >>> headers, body = config.sign_request(
- ... headers={},
- ... optional_params={"oci_signer": signer},
- ... request_data={"message": "Hello"},
- ... api_base="https://inference.generativeai.us-ashburn-1.oci.oraclecloud.com/..."
- ... )
- """
- oci_signer = optional_params.get("oci_signer")
-
- # If a signer is provided, use it for request signing
- if oci_signer is not None:
- return self._sign_with_oci_signer(
- headers, optional_params, request_data, api_base
- )
-
- # Standard manual credential signing
- return self._sign_with_manual_credentials(
- headers, optional_params, request_data, api_base
+ ) -> Tuple[dict, bytes]:
+ return sign_oci_request(
+ headers=headers,
+ optional_params=optional_params,
+ request_data=request_data,
+ api_base=api_base,
+ api_key=api_key,
+ model=model,
+ stream=stream,
+ fake_stream=fake_stream,
)
def validate_environment(
@@ -577,80 +368,35 @@ class OCIChatConfig(BaseConfig):
api_key: Optional[str] = None,
api_base: Optional[str] = None,
) -> dict:
- """
- Validate the OCI environment and credentials.
-
- Supports two authentication modes:
- 1. OCI SDK Signer: Pass an oci_signer object (e.g., oci.signer.Signer)
- 2. Manual Credentials: Pass oci_user, oci_fingerprint, oci_tenancy, and oci_key/oci_key_file
-
- Args:
- headers: Request headers to populate
- model: Model name
- messages: List of chat messages
- optional_params: Optional parameters including authentication credentials
- litellm_params: LiteLLM parameters
- api_key: Optional API key (not used for OCI)
- api_base: Optional API base URL
-
- Returns:
- Updated headers dict
-
- Raises:
- Exception: If required parameters are missing or invalid
- """
- oci_signer = optional_params.get("oci_signer")
- oci_region = optional_params.get("oci_region", "us-ashburn-1")
-
- # Determine api_base
- api_base = (
- api_base
- or litellm.api_base
- or f"https://inference.generativeai.{oci_region}.oci.oraclecloud.com"
- )
-
- if not api_base:
- raise Exception(
- "Either `api_base` must be provided or `litellm.api_base` must be set. "
- "Alternatively, you can set the `oci_region` optional parameter to use the default OCI region."
- )
-
- # Validate credentials only if signer is not provided
- if oci_signer is None:
- oci_user = optional_params.get("oci_user")
- oci_fingerprint = optional_params.get("oci_fingerprint")
- oci_tenancy = optional_params.get("oci_tenancy")
- oci_key = optional_params.get("oci_key")
- oci_key_file = optional_params.get("oci_key_file")
- oci_compartment_id = optional_params.get("oci_compartment_id")
-
- if (
- not oci_user
- or not oci_fingerprint
- or not oci_tenancy
- or not (oci_key or oci_key_file)
- or not oci_compartment_id
- ):
- raise Exception(
- "Missing required parameters: oci_user, oci_fingerprint, oci_tenancy, oci_compartment_id "
- "and at least one of oci_key or oci_key_file. "
- "Alternatively, provide an oci_signer object from the OCI SDK."
- )
-
- # Common header setup
- headers.update(
- {
- "content-type": "application/json",
- "user-agent": f"litellm/{version}",
- }
- )
-
if not messages:
- raise Exception(
- "kwarg `messages` must be an array of messages that follow the openai chat standard"
+ raise OCIError(
+ status_code=400,
+ message="kwarg `messages` must be an array of messages that follow the openai chat standard",
)
-
- return headers
+ if optional_params.get("oci_signer") is None:
+ creds = resolve_oci_credentials(optional_params)
+ missing = [
+ k
+ for k in (
+ "oci_user",
+ "oci_fingerprint",
+ "oci_tenancy",
+ "oci_compartment_id",
+ )
+ if not creds.get(k)
+ ]
+ if missing or not (creds.get("oci_key") or creds.get("oci_key_file")):
+ raise OCIError(
+ status_code=401,
+ message=(
+ "Missing required parameters: oci_user, oci_fingerprint, oci_tenancy, "
+ "oci_compartment_id and at least one of oci_key or oci_key_file. "
+ "These can be supplied via optional_params or via OCI_USER, OCI_FINGERPRINT, "
+ "OCI_TENANCY, OCI_COMPARTMENT_ID, OCI_KEY_FILE environment variables. "
+ "Alternatively, provide an oci_signer object from the OCI SDK."
+ ),
+ )
+ return validate_oci_environment(headers, optional_params, api_key)
def get_complete_url(
self,
@@ -661,43 +407,63 @@ class OCIChatConfig(BaseConfig):
litellm_params: dict,
stream: Optional[bool] = None,
) -> str:
- oci_region = optional_params.get("oci_region", "us-ashburn-1")
- return f"https://inference.generativeai.{oci_region}.oci.oraclecloud.com/20231130/actions/chat"
+ base = get_oci_base_url(optional_params, api_base or litellm.api_base)
+ return f"{base}/{OCI_API_VERSION}/actions/chat"
- def _get_optional_params(self, vendor: OCIVendors, optional_params: dict) -> Dict:
- selected_params = {}
- if vendor == OCIVendors.COHERE:
- open_ai_to_oci_param_map = self.openai_to_oci_cohere_param_map
- # remove tool_choice from the map
- open_ai_to_oci_param_map.pop("tool_choice")
- # Add default values for Cohere API
- selected_params = {
- "maxTokens": 600,
- "temperature": 1,
- "topK": 0,
- "topP": 0.75,
- "frequencyPenalty": 0,
- }
- else:
- open_ai_to_oci_param_map = self.openai_to_oci_generic_param_map
+ def _get_optional_params(
+ self, vendor: OCIVendors, optional_params: dict, model: str = ""
+ ) -> Dict:
+ param_map = (
+ self.openai_to_oci_cohere_param_map
+ if vendor == OCIVendors.COHERE
+ else self.openai_to_oci_generic_param_map
+ )
+ selected_params: Dict = {}
- # Map OpenAI params to OCI params
- for openai_key, oci_key in open_ai_to_oci_param_map.items():
- if oci_key and openai_key in optional_params:
- selected_params[oci_key] = optional_params[openai_key] # type: ignore[index]
+ # OpenAI reasoning models on OCI (e.g. GPT-5 family) reject "maxTokens"
+ # and require "maxCompletionTokens" per OCI's /20231130/Chat schema.
+ # Driven by the supports_reasoning flag in the model catalog. Cohere's
+ # endpoint uses "maxTokens" regardless, so the override is GENERIC-only.
+ max_tokens_key = (
+ "maxCompletionTokens"
+ if vendor != OCIVendors.COHERE
+ and model
+ and _model_uses_max_completion_tokens(model)
+ else "maxTokens"
+ )
- # Also check for already-mapped OCI params (for backward compatibility)
- for oci_value in open_ai_to_oci_param_map.values():
- if (
- oci_value
- and oci_value in optional_params
- and oci_value not in selected_params
- ):
- selected_params[oci_value] = optional_params[oci_value] # type: ignore[index]
+ # ``map_openai_params`` runs before ``transform_request`` (and thus
+ # before this helper), so by the time we see ``optional_params`` the
+ # OpenAI keys have already been translated to their OCI aliases.
+ # We still accept the original OpenAI key as a fallback for callers
+ # that build ``optional_params`` directly, with OpenAI keys winning
+ # over OCI aliases when both happen to be present. The first OpenAI
+ # key reaching a given OCI target wins, so ``max_tokens`` /
+ # ``max_completion_tokens`` (both → ``maxTokens``) don't double-write.
+ for openai_key, oci_alias in param_map.items():
+ if not oci_alias:
+ continue
+ target = max_tokens_key if oci_alias == "maxTokens" else oci_alias
+ if target in selected_params:
+ continue
+ if openai_key in optional_params:
+ selected_params[target] = optional_params[openai_key] # type: ignore[index]
+ elif oci_alias in optional_params:
+ selected_params[target] = optional_params[oci_alias] # type: ignore[index]
+
+ # OCI expects uppercase reasoning levels (LOW/MEDIUM/HIGH/NONE); OpenAI
+ # clients send lowercase. OpenAI's "disable" maps to OCI's "NONE".
+ if "reasoningEffort" in selected_params:
+ effort = selected_params["reasoningEffort"]
+ if isinstance(effort, str):
+ normalized = effort.upper()
+ if normalized == "DISABLE":
+ normalized = "NONE"
+ selected_params["reasoningEffort"] = normalized
if "tools" in selected_params:
if vendor == OCIVendors.COHERE:
- selected_params["tools"] = self.adapt_tool_definitions_to_cohere_standard( # type: ignore[assignment]
+ selected_params["tools"] = adapt_tool_definitions_to_cohere_standard( # type: ignore[assignment]
selected_params["tools"] # type: ignore[arg-type]
)
else:
@@ -705,146 +471,15 @@ class OCIChatConfig(BaseConfig):
selected_params["tools"], vendor # type: ignore[arg-type]
)
- # Transform response_format type to OCI uppercase format
- if "responseFormat" in selected_params:
- rf = selected_params["responseFormat"]
- if isinstance(rf, dict) and "type" in rf:
- rf_payload = dict(rf)
- selected_params["responseFormat"] = rf_payload
+ # Normalise tool_choice to OCI's flat uppercase dict form
+ # ({"type": "AUTO"|"NONE"|"REQUIRED"} or {"type": "FUNCTION", "name": ""}).
+ # OCI rejects both the OpenAI string and the nested OpenAI dict shape.
+ _normalize_tool_choice(selected_params)
- response_type = rf_payload["type"]
- schema_payload: Optional[Any] = None
-
- if "json_schema" in rf_payload:
- raw_schema_payload = rf_payload.pop("json_schema")
- if isinstance(raw_schema_payload, dict):
- schema_payload = dict(raw_schema_payload)
- else:
- schema_payload = raw_schema_payload
-
- if schema_payload is not None:
- rf_payload["jsonSchema"] = schema_payload
-
- if vendor == OCIVendors.COHERE:
- # Cohere expects lower-case type values
- rf_payload["type"] = response_type
- else:
- format_type = response_type.upper()
- if format_type == "JSON":
- format_type = "JSON_OBJECT"
- rf_payload["type"] = format_type
+ _normalize_response_format(selected_params, vendor)
return selected_params
- def adapt_messages_to_cohere_standard(
- self, messages: List[AllMessageValues]
- ) -> List[CohereMessage]:
- """Build chat history for Cohere models."""
- chat_history = []
- for msg in messages[:-1]: # All messages except the last one
- role = msg.get("role")
- content = msg.get("content")
-
- if isinstance(content, list):
- # Extract text from content array
- text_content = ""
- for content_item in content:
- if (
- isinstance(content_item, dict)
- and content_item.get("type") == "text"
- ):
- text_content += content_item.get("text", "")
- content = text_content
-
- # Ensure content is a string
- if not isinstance(content, str):
- content = str(content) if content is not None else ""
-
- # Handle tool calls
- tool_calls: Optional[List[CohereToolCall]] = None
- if role == "assistant" and "tool_calls" in msg and msg.get("tool_calls"): # type: ignore[union-attr,typeddict-item]
- tool_calls = []
- for tool_call in msg["tool_calls"]: # type: ignore[union-attr,typeddict-item]
- # Parse arguments if they're a JSON string
- raw_arguments: Any = tool_call.get("function", {}).get(
- "arguments", {}
- )
- if isinstance(raw_arguments, str):
- try:
- arguments: Dict[str, Any] = json.loads(raw_arguments)
- except json.JSONDecodeError:
- arguments = {}
- else:
- arguments = raw_arguments
-
- tool_calls.append(
- CohereToolCall(
- name=str(tool_call.get("function", {}).get("name", "")),
- parameters=arguments,
- )
- )
-
- if role == "user":
- chat_history.append(CohereMessage(role="USER", message=content))
- elif role == "assistant":
- chat_history.append(
- CohereMessage(role="CHATBOT", message=content, toolCalls=tool_calls)
- )
- elif role == "tool":
- # Tool messages need special handling
- chat_history.append(
- CohereMessage(
- role="TOOL",
- message=content,
- toolCalls=None, # Tool messages don't have tool calls
- )
- )
-
- return chat_history
-
- def adapt_tool_definitions_to_cohere_standard(
- self, tools: List[Dict[str, Any]]
- ) -> List[CohereTool]:
- """Adapt tool definitions to Cohere format."""
- cohere_tools = []
- for tool in tools:
- function_def = tool.get("function", {})
- parameters = function_def.get("parameters", {}).get("properties", {})
- required = function_def.get("parameters", {}).get("required", [])
-
- parameter_definitions = {}
- for param_name, param_schema in parameters.items():
- parameter_definitions[param_name] = CohereParameterDefinition(
- description=param_schema.get("description", ""),
- type=param_schema.get("type", "string"),
- isRequired=param_name in required,
- )
-
- cohere_tools.append(
- CohereTool(
- name=function_def.get("name", ""),
- description=function_def.get("description", ""),
- parameterDefinitions=parameter_definitions,
- )
- )
-
- return cohere_tools
-
- def _extract_text_content(self, content: Any) -> str:
- """Extract text content from message content."""
- if isinstance(content, str):
- return content
- elif isinstance(content, list):
- text_content = ""
- for content_item in content:
- if (
- isinstance(content_item, dict)
- and content_item.get("type") == "text"
- ):
- text_content += content_item.get("text", "")
- return text_content
- return str(content)
-
def transform_request(
self,
model: str,
@@ -853,186 +488,78 @@ class OCIChatConfig(BaseConfig):
litellm_params: dict,
headers: dict,
) -> dict:
- oci_compartment_id = optional_params.get("oci_compartment_id", None)
+ creds = resolve_oci_credentials(optional_params)
+ oci_compartment_id = creds["oci_compartment_id"]
if not oci_compartment_id:
- raise Exception("kwarg `oci_compartment_id` is required for OCI requests")
+ raise OCIError(
+ status_code=400,
+ message=(
+ "oci_compartment_id is required for OCI chat requests. "
+ "Pass it as optional_params or set the OCI_COMPARTMENT_ID env var."
+ ),
+ )
vendor = get_vendor_from_model(model)
oci_serving_mode = optional_params.get("oci_serving_mode", "ON_DEMAND")
if oci_serving_mode not in ["ON_DEMAND", "DEDICATED"]:
- raise Exception(
- "kwarg `oci_serving_mode` must be either 'ON_DEMAND' or 'DEDICATED'"
+ raise OCIError(
+ status_code=400,
+ message="kwarg `oci_serving_mode` must be either 'ON_DEMAND' or 'DEDICATED'",
)
if oci_serving_mode == "DEDICATED":
- oci_endpoint_id = optional_params.get("oci_endpoint_id", model)
- servingMode = OCIServingMode(
+ serving_mode = OCIServingMode(
servingType="DEDICATED",
- endpointId=oci_endpoint_id,
+ endpointId=optional_params.get("oci_endpoint_id", model),
)
else:
- servingMode = OCIServingMode(
- servingType="ON_DEMAND",
- modelId=model,
- )
+ serving_mode = OCIServingMode(servingType="ON_DEMAND", modelId=model)
- # Build request based on vendor type
if vendor == OCIVendors.COHERE:
- # For Cohere, we need to use the specific Cohere format
- # Extract the last user message as the main message
- user_messages = [msg for msg in messages if msg.get("role") == "user"]
+ user_messages = [m for m in messages if m.get("role") == "user"]
if not user_messages:
- raise Exception("No user message found for Cohere model")
+ raise OCIError(
+ status_code=400,
+ message="No user message found — Cohere models require at least one user message",
+ )
- # Extract system messages into preambleOverride
- system_messages = [msg for msg in messages if msg.get("role") == "system"]
+ system_messages = [m for m in messages if m.get("role") == "system"]
preamble_override = None
if system_messages:
preamble = "\n".join(
- self._extract_text_content(msg["content"])
- for msg in system_messages
+ _extract_text_content(m["content"]) for m in system_messages
)
if preamble:
preamble_override = preamble
- # Create Cohere-specific chat request
- optional_cohere_params = self._get_optional_params(
- OCIVendors.COHERE, optional_params
- )
chat_request = CohereChatRequest(
apiFormat="COHERE",
- message=self._extract_text_content(user_messages[-1]["content"]),
- chatHistory=self.adapt_messages_to_cohere_standard(messages),
+ message=_extract_text_content(user_messages[-1]["content"]),
+ chatHistory=adapt_messages_to_cohere_standard(
+ [m for m in messages if m.get("role") != "system"]
+ ),
preambleOverride=preamble_override,
- **optional_cohere_params,
+ **self._get_optional_params(OCIVendors.COHERE, optional_params, model),
)
-
data = OCICompletionPayload(
compartmentId=oci_compartment_id,
- servingMode=servingMode,
+ servingMode=serving_mode,
chatRequest=chat_request,
)
else:
- # Use generic format for other vendors
data = OCICompletionPayload(
compartmentId=oci_compartment_id,
- servingMode=servingMode,
+ servingMode=serving_mode,
chatRequest=OCIChatRequestPayload(
apiFormat=vendor.value,
messages=adapt_messages_to_generic_oci_standard(messages),
- **self._get_optional_params(vendor, optional_params),
+ **self._get_optional_params(vendor, optional_params, model),
),
)
return data.model_dump(exclude_none=True)
- def _handle_cohere_response(
- self, json_response: dict, model: str, model_response: ModelResponse
- ) -> ModelResponse:
- """Handle Cohere-specific response format."""
- cohere_response = CohereChatResult(**json_response)
- # Cohere response format (uses camelCase)
- model_id = model
-
- # Set basic response info
- model_response.model = model_id
- model_response.created = int(datetime.datetime.now().timestamp())
-
- # Extract the response text
- response_text = cohere_response.chatResponse.text
- oci_finish_reason = cohere_response.chatResponse.finishReason
-
- # Map finish reason
- if oci_finish_reason == "COMPLETE":
- finish_reason = "stop"
- elif oci_finish_reason == "MAX_TOKENS":
- finish_reason = "length"
- else:
- finish_reason = "stop"
-
- # Handle tool calls
- tool_calls: Optional[List[Dict[str, Any]]] = None
- if cohere_response.chatResponse.toolCalls:
- tool_calls = []
- for tool_call in cohere_response.chatResponse.toolCalls:
- tool_calls.append(
- {
- "id": f"call_{len(tool_calls)}", # Generate a simple ID
- "type": "function",
- "function": {
- "name": tool_call.name,
- "arguments": json.dumps(tool_call.parameters),
- },
- }
- )
-
- # Create choice
- from litellm.types.utils import Choices
-
- choice = Choices(
- index=0,
- message={
- "role": "assistant",
- "content": response_text,
- "tool_calls": tool_calls,
- },
- finish_reason=finish_reason,
- )
- model_response.choices = [choice]
-
- # Extract usage info
- usage_info = cohere_response.chatResponse.usage
- from litellm.types.utils import Usage
-
- model_response.usage = Usage( # type: ignore[attr-defined]
- prompt_tokens=usage_info.promptTokens, # type: ignore[union-attr]
- completion_tokens=usage_info.completionTokens, # type: ignore[union-attr]
- total_tokens=usage_info.totalTokens, # type: ignore[union-attr]
- )
-
- return model_response
-
- def _handle_generic_response(
- self,
- json: dict,
- model: str,
- model_response: ModelResponse,
- raw_response: httpx.Response,
- ) -> ModelResponse:
- """Handle generic OCI response format."""
- try:
- completion_response = OCICompletionResponse(**json)
- except TypeError as e:
- raise OCIError(
- message=f"Response cannot be casted to OCICompletionResponse: {str(e)}",
- status_code=raw_response.status_code,
- )
-
- iso_str = completion_response.chatResponse.timeCreated
- dt = datetime.datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
- model_response.created = int(dt.timestamp())
-
- model_response.model = completion_response.modelId
-
- message = model_response.choices[0].message # type: ignore
- response_message = completion_response.chatResponse.choices[0].message
- if response_message.content and response_message.content[0].type == "TEXT":
- message.content = response_message.content[0].text
- if response_message.toolCalls:
- message.tool_calls = adapt_tools_to_openai_standard(
- response_message.toolCalls
- )
-
- usage = Usage(
- prompt_tokens=completion_response.chatResponse.usage.promptTokens,
- completion_tokens=completion_response.chatResponse.usage.completionTokens,
- total_tokens=completion_response.chatResponse.usage.totalTokens,
- )
- model_response.usage = usage # type: ignore
-
- return model_response
-
def transform_response(
self,
model: str,
@@ -1047,34 +574,31 @@ class OCIChatConfig(BaseConfig):
api_key: Optional[str] = None,
json_mode: Optional[bool] = None,
) -> ModelResponse:
- json = raw_response.json() # noqa: F811
+ response_json = raw_response.json()
- error = json.get("error")
-
- if error is not None:
- raise OCIError(
- message=str(json["error"]),
- status_code=raw_response.status_code,
- )
-
- if not isinstance(json, dict):
+ if not isinstance(response_json, dict):
raise OCIError(
message="Invalid response format from OCI",
status_code=raw_response.status_code,
)
- vendor = get_vendor_from_model(model)
+ if response_json.get("error") is not None:
+ raise OCIError(
+ message=str(response_json["error"]),
+ status_code=raw_response.status_code,
+ )
- # Handle response based on vendor type
+ vendor = get_vendor_from_model(model)
if vendor == OCIVendors.COHERE:
- model_response = self._handle_cohere_response(json, model, model_response)
+ model_response = handle_cohere_response(
+ response_json, model, model_response, raw_response
+ )
else:
- model_response = self._handle_generic_response(
- json, model, model_response, raw_response
+ model_response = handle_generic_response(
+ response_json, model, model_response, raw_response
)
model_response._hidden_params["additional_headers"] = raw_response.headers
-
return model_response
@track_llm_api_timing()
@@ -1091,8 +615,6 @@ class OCIChatConfig(BaseConfig):
json_mode: Optional[bool] = None,
signed_json_body: Optional[bytes] = None,
) -> "OCIStreamWrapper":
- if "stream" in data:
- del data["stream"]
if client is None or isinstance(client, AsyncHTTPHandler):
client = _get_httpx_client(params={})
@@ -1100,7 +622,11 @@ class OCIChatConfig(BaseConfig):
response = client.post(
api_base,
headers=headers,
- data=json.dumps(data),
+ data=(
+ signed_json_body
+ if signed_json_body is not None
+ else json.dumps(data)
+ ),
stream=True,
logging_obj=logging_obj,
timeout=STREAMING_TIMEOUT,
@@ -1111,15 +637,12 @@ class OCIChatConfig(BaseConfig):
if response.status_code != 200:
raise OCIError(status_code=response.status_code, message=response.text)
- completion_stream = response.iter_text()
-
- streaming_response = OCIStreamWrapper(
- completion_stream=completion_stream,
+ return OCIStreamWrapper(
+ completion_stream=_iter_sse_events(response.iter_text()),
model=model,
custom_llm_provider=custom_llm_provider,
logging_obj=logging_obj,
)
- return streaming_response
@track_llm_api_timing()
async def get_async_custom_stream_wrapper(
@@ -1135,17 +658,18 @@ class OCIChatConfig(BaseConfig):
json_mode: Optional[bool] = None,
signed_json_body: Optional[bytes] = None,
) -> "OCIStreamWrapper":
- if "stream" in data:
- del data["stream"]
-
if client is None or isinstance(client, HTTPHandler):
- client = get_async_httpx_client(llm_provider=LlmProviders.BYTEZ, params={})
+ client = get_async_httpx_client(llm_provider=LlmProviders.OCI, params={})
try:
response = await client.post(
api_base,
headers=headers,
- data=json.dumps(data),
+ data=(
+ signed_json_body
+ if signed_json_body is not None
+ else json.dumps(data)
+ ),
stream=True,
logging_obj=logging_obj,
timeout=STREAMING_TIMEOUT,
@@ -1156,22 +680,12 @@ class OCIChatConfig(BaseConfig):
if response.status_code != 200:
raise OCIError(status_code=response.status_code, message=response.text)
- completion_stream = response.aiter_text()
-
- async def split_chunks(completion_stream: AsyncIterator[str]):
- async for item in completion_stream:
- for chunk in item.split("\n\n"):
- if not chunk:
- continue
- yield chunk.strip()
-
- streaming_response = OCIStreamWrapper(
- completion_stream=split_chunks(completion_stream),
+ return OCIStreamWrapper(
+ completion_stream=_aiter_sse_events(response.aiter_text()),
model=model,
custom_llm_provider=custom_llm_provider,
logging_obj=logging_obj,
)
- return streaming_response
def get_error_class(
self, error_message: str, status_code: int, headers: Union[dict, httpx.Headers]
@@ -1179,332 +693,61 @@ class OCIChatConfig(BaseConfig):
return OCIError(status_code=status_code, message=error_message)
-open_ai_to_generic_oci_role_map: Dict[str, OCIRoles] = {
- "system": "SYSTEM",
- "user": "USER",
- "assistant": "ASSISTANT",
- "tool": "TOOL",
-}
-
-
-def adapt_messages_to_generic_oci_standard_content_message(
- role: str, content: Union[str, list]
-) -> OCIMessage:
- new_content: List[OCIContentPartUnion] = []
- if isinstance(content, str):
- return OCIMessage(
- role=open_ai_to_generic_oci_role_map[role],
- content=[OCITextContentPart(text=content)],
- toolCalls=None,
- toolCallId=None,
- )
-
- # content is a list of content items:
- # [
- # {"type": "text", "text": "Hello"},
- # {"type": "image_url", "image_url": "https://example.com/image.png"}
- # ]
- for content_item in content:
- if not isinstance(content_item, dict):
- raise Exception("Each content item must be a dictionary")
-
- type = content_item.get("type")
- if not isinstance(type, str):
- raise Exception("Prop `type` is not a string")
-
- if type not in ["text", "image_url"]:
- raise Exception(f"Prop `{type}` is not supported")
-
- if type == "text":
- text = content_item.get("text")
- if not isinstance(text, str):
- raise Exception("Prop `text` is not a string")
- new_content.append(OCITextContentPart(text=text))
-
- elif type == "image_url":
- image_url = content_item.get("image_url")
- # Handle both OpenAI format (object with url) and string format
- if isinstance(image_url, dict):
- image_url = image_url.get("url")
- if not isinstance(image_url, str):
- raise Exception(
- "Prop `image_url` must be a string or an object with a `url` property"
- )
- new_content.append(OCIImageContentPart(imageUrl=OCIImageUrl(url=image_url)))
-
- return OCIMessage(
- role=open_ai_to_generic_oci_role_map[role],
- content=new_content,
- toolCalls=None,
- toolCallId=None,
- )
-
-
-def adapt_messages_to_generic_oci_standard_tool_call(
- role: str, tool_calls: list
-) -> OCIMessage:
- tool_calls_formated = []
- for tool_call in tool_calls:
- if not isinstance(tool_call, dict):
- raise Exception("Each tool call must be a dictionary")
-
- if tool_call.get("type") != "function":
- raise Exception("OCI only supports function tools")
-
- tool_call_id = tool_call.get("id")
- if not isinstance(tool_call_id, str):
- raise Exception("Prop `id` is not a string")
-
- tool_function = tool_call.get("function")
- if not isinstance(tool_function, dict):
- raise Exception("Prop `function` is not a dictionary")
-
- function_name = tool_function.get("name")
- if not isinstance(function_name, str):
- raise Exception("Prop `name` is not a string")
-
- arguments = tool_call["function"].get("arguments", "{}")
- if not isinstance(arguments, str):
- raise Exception("Prop `arguments` is not a string")
-
- # tool_calls_formated.append(OCIToolCall(
- # id=tool_call_id,
- # type="FUNCTION",
- # function=OCIFunction(
- # name=function_name,
- # arguments=arguments
- # )
- # ))
-
- tool_calls_formated.append(
- OCIToolCall(
- id=tool_call_id,
- type="FUNCTION",
- name=function_name,
- arguments=arguments,
- )
- )
-
- return OCIMessage(
- role=open_ai_to_generic_oci_role_map[role],
- content=None,
- toolCalls=tool_calls_formated,
- toolCallId=None,
- )
-
-
-def adapt_messages_to_generic_oci_standard_tool_response(
- role: str, tool_call_id: str, content: str
-) -> OCIMessage:
- return OCIMessage(
- role=open_ai_to_generic_oci_role_map[role],
- content=[OCITextContentPart(text=content)],
- toolCalls=None,
- toolCallId=tool_call_id,
- )
-
-
-def adapt_messages_to_generic_oci_standard(
- messages: List[AllMessageValues],
-) -> List[OCIMessage]:
- new_messages = []
- for message in messages:
- role = message["role"]
- content = message.get("content")
- tool_calls = message.get("tool_calls")
- tool_call_id = message.get("tool_call_id")
-
- if role == "assistant" and tool_calls is not None:
- if not isinstance(tool_calls, list):
- raise Exception("Prop `tool_calls` must be a list of tool calls")
- new_messages.append(
- adapt_messages_to_generic_oci_standard_tool_call(role, tool_calls)
- )
-
- elif role in ["system", "user", "assistant"] and content is not None:
- if not isinstance(content, (str, list)):
- raise Exception(
- "Prop `content` must be a string or a list of content items"
- )
- new_messages.append(
- adapt_messages_to_generic_oci_standard_content_message(role, content)
- )
-
- elif role == "tool":
- if not isinstance(tool_call_id, str):
- raise Exception("Prop `tool_call_id` is required and must be a string")
- if not isinstance(content, str):
- raise Exception("Prop `content` is not a string")
- new_messages.append(
- adapt_messages_to_generic_oci_standard_tool_response(
- role, tool_call_id, content
- )
- )
-
- return new_messages
-
-
-def adapt_tool_definition_to_oci_standard(tools: List[Dict], vendor: OCIVendors):
- new_tools = []
- for tool in tools:
- if tool["type"] != "function":
- raise Exception("OCI only supports function tools")
-
- tool_function = tool.get("function")
- if not isinstance(tool_function, dict):
- raise Exception("Prop `function` is not a dictionary")
-
- new_tool = OCIToolDefinition(
- type="FUNCTION",
- name=tool_function.get("name"),
- description=tool_function.get("description", ""),
- parameters=tool_function.get("parameters", {}),
- )
- new_tools.append(new_tool)
-
- return new_tools
-
-
-def adapt_tools_to_openai_standard(
- tools: List[OCIToolCall],
-) -> List[ChatCompletionMessageToolCall]:
- new_tools = []
- for tool in tools:
- new_tool = ChatCompletionMessageToolCall(
- id=tool.id,
- type="function",
- function={
- "name": tool.name,
- "arguments": tool.arguments,
- },
- )
- new_tools.append(new_tool)
- return new_tools
-
-
class OCIStreamWrapper(CustomStreamWrapper):
- """
- Custom stream wrapper for OCI responses.
- This class is used to handle streaming responses from OCI's API.
- """
+ """Custom stream wrapper that dispatches OCI SSE chunks to the correct handler."""
- def __init__(
- self,
- **kwargs: Any,
- ):
+ def __init__(self, **kwargs: Any):
super().__init__(**kwargs)
+ # Tracks whether any prior Cohere chunk in this stream has emitted
+ # tool calls. The Cohere handler uses this to decide whether the
+ # terminal consolidation chunk's tool calls are duplicates (suppress)
+ # or the only copy of the tool calls (pass through).
+ self._cohere_tool_calls_emitted = False
+ # Analogous flag for text content. Lets the Cohere handler distinguish
+ # the common case (prior deltas already streamed the text, so the
+ # terminal chunk's text is a duplicate to suppress) from the degenerate
+ # single-event case (terminal chunk carries the only copy of the text).
+ self._cohere_text_emitted = False
- def chunk_creator(self, chunk: Any):
+ def chunk_creator(self, chunk: Any) -> ModelResponseStream:
if not isinstance(chunk, str):
raise ValueError(f"Chunk is not a string: {chunk}")
if not chunk.startswith("data:"):
raise ValueError(f"Chunk does not start with 'data:': {chunk}")
- dict_chunk = json.loads(chunk[5:]) # Remove 'data: ' prefix and parse JSON
-
- # Check if this is a Cohere stream chunk
- if "apiFormat" in dict_chunk and dict_chunk.get("apiFormat") == "COHERE":
- return self._handle_cohere_stream_chunk(dict_chunk)
- else:
- return self._handle_generic_stream_chunk(dict_chunk)
-
- def _handle_cohere_stream_chunk(self, dict_chunk: dict):
- """Handle Cohere-specific streaming chunks."""
try:
- typed_chunk = CohereStreamChunk(**dict_chunk)
- except TypeError as e:
- raise ValueError(f"Chunk cannot be casted to CohereStreamChunk: {str(e)}")
+ dict_chunk = json.loads(chunk[5:])
+ except json.JSONDecodeError as e:
+ raise OCIError(
+ status_code=500,
+ message=f"Chunk cannot be parsed as JSON: {str(e)}",
+ )
- if typed_chunk.index is None:
- typed_chunk.index = 0
+ if dict_chunk.get("apiFormat") == "COHERE":
+ result = handle_cohere_stream_chunk(
+ dict_chunk,
+ prior_tool_calls_emitted=self._cohere_tool_calls_emitted,
+ prior_text_emitted=self._cohere_text_emitted,
+ )
+ if not self._cohere_tool_calls_emitted:
+ for choice in result.choices:
+ if getattr(choice.delta, "tool_calls", None) is not None:
+ self._cohere_tool_calls_emitted = True
+ break
+ if not self._cohere_text_emitted:
+ for choice in result.choices:
+ if getattr(choice.delta, "content", None):
+ self._cohere_text_emitted = True
+ break
+ return result
+ return handle_generic_stream_chunk(dict_chunk)
- # Extract text content
- text = typed_chunk.text or ""
- # Map finish reason to standard format
- finish_reason = typed_chunk.finishReason
- if finish_reason == "COMPLETE":
- finish_reason = "stop"
- elif finish_reason == "MAX_TOKENS":
- finish_reason = "length"
- elif finish_reason is None:
- finish_reason = None
- else:
- finish_reason = "stop"
-
- # For Cohere, we don't have tool calls in the streaming format
- tool_calls = None
-
- return ModelResponseStream(
- choices=[
- StreamingChoices(
- index=typed_chunk.index if typed_chunk.index else 0,
- delta=Delta(
- content=text,
- tool_calls=tool_calls,
- provider_specific_fields=None,
- thinking_blocks=None,
- reasoning_content=None,
- ),
- finish_reason=finish_reason,
- )
- ]
- )
-
- def _handle_generic_stream_chunk(self, dict_chunk: dict):
- """Handle generic OCI streaming chunks."""
- # Fix missing required fields in tool calls before Pydantic validation
- # OCI streams tool calls progressively, so early chunks may be missing required fields
- if dict_chunk.get("message") and dict_chunk["message"].get("toolCalls"):
- for tool_call in dict_chunk["message"]["toolCalls"]:
- if "arguments" not in tool_call:
- tool_call["arguments"] = ""
- if "id" not in tool_call:
- tool_call["id"] = ""
- if "name" not in tool_call:
- tool_call["name"] = ""
-
- try:
- typed_chunk = OCIStreamChunk(**dict_chunk)
- except TypeError as e:
- raise ValueError(f"Chunk cannot be casted to OCIStreamChunk: {str(e)}")
-
- if typed_chunk.index is None:
- typed_chunk.index = 0
-
- text = ""
- if typed_chunk.message and typed_chunk.message.content:
- for item in typed_chunk.message.content:
- if isinstance(item, OCITextContentPart):
- text += item.text
- elif isinstance(item, OCIImageContentPart):
- raise ValueError(
- "OCI does not support image content in streaming responses"
- )
- else:
- raise ValueError(
- f"Unsupported content type in OCI response: {item.type}"
- )
-
- tool_calls = None
- if typed_chunk.message and typed_chunk.message.toolCalls:
- tool_calls = adapt_tools_to_openai_standard(typed_chunk.message.toolCalls)
-
- return ModelResponseStream(
- choices=[
- StreamingChoices(
- index=typed_chunk.index if typed_chunk.index else 0,
- delta=Delta(
- content=text,
- tool_calls=(
- [tool.model_dump() for tool in tool_calls]
- if tool_calls
- else None
- ),
- provider_specific_fields=None, # OCI does not have provider specific fields in the response
- thinking_blocks=None, # OCI does not have thinking blocks in the response
- reasoning_content=None, # OCI does not have reasoning content in the response
- ),
- finish_reason=typed_chunk.finishReason,
- )
- ]
- )
+__all__ = [
+ "OCIChatConfig",
+ "OCIStreamWrapper",
+ "OCIRequestWrapper",
+ "OCI_API_VERSION",
+ "STREAMING_TIMEOUT",
+ "get_vendor_from_model",
+ "version",
+]
diff --git a/litellm/llms/oci/common_utils.py b/litellm/llms/oci/common_utils.py
index 661a6c89e4b..8785b1548a5 100644
--- a/litellm/llms/oci/common_utils.py
+++ b/litellm/llms/oci/common_utils.py
@@ -1,9 +1,42 @@
-from typing import Optional
+import base64
+import hashlib
+import json
+import os
+import re
+from dataclasses import dataclass
+from email.utils import formatdate
+from typing import Any, Dict, Optional, Protocol, Tuple
+from urllib.parse import urlparse
import httpx
from litellm.llms.base_llm.chat.transformation import BaseLLMException
+try:
+ from cryptography.hazmat.primitives import hashes, serialization
+ from cryptography.hazmat.primitives.asymmetric import padding, rsa
+
+ _CRYPTOGRAPHY_AVAILABLE = True
+except ImportError:
+ _CRYPTOGRAPHY_AVAILABLE = False
+
+try:
+ from litellm._version import version as _litellm_version
+except ImportError:
+ _litellm_version = "0.0.0"
+
+
+# OCI GenAI REST API version — stable since service launch, unlikely to change
+OCI_API_VERSION = "20231130"
+
+
+def _require_cryptography() -> None:
+ if not _CRYPTOGRAPHY_AVAILABLE:
+ raise ImportError(
+ "cryptography package is required for OCI authentication. "
+ "Please install it with: pip install cryptography"
+ )
+
class OCIError(BaseLLMException):
def __init__(
@@ -17,3 +50,520 @@ class OCIError(BaseLLMException):
message=message,
headers=headers,
)
+
+
+# ---------------------------------------------------------------------------
+# OCI signing protocol and helpers
+# ---------------------------------------------------------------------------
+
+
+class OCISignerProtocol(Protocol):
+ """
+ Protocol for OCI request signers (e.g., oci.signer.Signer).
+
+ Compatible with the OCI Python SDK's Signer class.
+ See: https://docs.oracle.com/en-us/iaas/tools/python/latest/api/signing.html
+ """
+
+ def do_request_sign(
+ self, request: Any, *, enforce_content_headers: bool = False
+ ) -> None:
+ pass
+
+
+@dataclass
+class OCIRequestWrapper:
+ """
+ Wrapper for HTTP requests compatible with OCI signer interface.
+
+ Wraps request data in the format expected by OCI SDK signers, which require
+ objects with method, url, headers, body, and path_url attributes.
+ """
+
+ method: str
+ url: str
+ headers: dict
+ body: bytes
+
+ @property
+ def path_url(self) -> str:
+ """Returns the path + query string for OCI signing."""
+ parsed = urlparse(self.url)
+ return parsed.path + ("?" + parsed.query if parsed.query else "")
+
+
+def sha256_base64(data: bytes) -> str:
+ # SHA-256 is used here to compute the x-content-sha256 header required by the
+ # OCI HTTP signing specification (RSA-SHA256 request signing), not for password
+ # or secret hashing. This is the correct and mandated algorithm for this purpose.
+ # See: https://docs.oracle.com/en-us/iaas/Content/API/Concepts/signingrequests.htm
+ #
+ # ``usedforsecurity=False`` declares non-security intent to static analyzers
+ # (CodeQL ``py/weak-sensitive-data-hashing``) — without it the request body
+ # gets flagged as "password-like data" via taint tracking.
+ digest = hashlib.sha256(data, usedforsecurity=False).digest() # noqa: S324
+ return base64.b64encode(digest).decode()
+
+
+def build_signature_string(
+ method: str, path: str, headers: dict, signed_headers: list
+) -> str:
+ lines = []
+ for header in signed_headers:
+ if header == "(request-target)":
+ value = f"{method.lower()} {path}"
+ else:
+ value = headers[header]
+ lines.append(f"{header}: {value}")
+ return "\n".join(lines)
+
+
+def load_private_key_from_str(key_str: str) -> Any:
+ _require_cryptography()
+ key = serialization.load_pem_private_key( # type: ignore[union-attr]
+ key_str.encode("utf-8"),
+ password=None,
+ )
+ if not isinstance(key, rsa.RSAPrivateKey): # type: ignore[union-attr]
+ raise TypeError(
+ "The provided private key is not an RSA key, which is required for OCI signing."
+ )
+ return key
+
+
+def load_private_key_from_file(file_path: str) -> Any:
+ """Loads a private key from a file path."""
+ try:
+ with open(file_path, "r", encoding="utf-8") as f:
+ key_str = f.read().strip()
+ except FileNotFoundError:
+ raise FileNotFoundError(f"Private key file not found: {file_path}")
+ except OSError as e:
+ raise OSError(f"Failed to read private key file '{file_path}': {e}") from e
+
+ if not key_str:
+ raise ValueError(f"Private key file is empty: {file_path}")
+
+ return load_private_key_from_str(key_str)
+
+
+# ---------------------------------------------------------------------------
+# Env-var credential resolution
+# ---------------------------------------------------------------------------
+
+_OCI_REGION_ENV = "OCI_REGION"
+_OCI_USER_ENV = "OCI_USER"
+_OCI_FINGERPRINT_ENV = "OCI_FINGERPRINT"
+_OCI_TENANCY_ENV = "OCI_TENANCY"
+_OCI_KEY_FILE_ENV = "OCI_KEY_FILE"
+_OCI_KEY_ENV = "OCI_KEY"
+_OCI_COMPARTMENT_ID_ENV = "OCI_COMPARTMENT_ID"
+
+
+def resolve_oci_credentials(optional_params: dict) -> dict:
+ """
+ Merge OCI credentials from optional_params (explicit, always wins) and
+ environment variables (fallback).
+
+ Returns a dict with resolved values for:
+ oci_region, oci_user, oci_fingerprint, oci_tenancy,
+ oci_key, oci_key_file, oci_compartment_id
+ """
+ return {
+ "oci_region": optional_params.get("oci_region")
+ or os.environ.get(_OCI_REGION_ENV)
+ or "us-ashburn-1",
+ "oci_user": optional_params.get("oci_user") or os.environ.get(_OCI_USER_ENV),
+ "oci_fingerprint": optional_params.get("oci_fingerprint")
+ or os.environ.get(_OCI_FINGERPRINT_ENV),
+ "oci_tenancy": optional_params.get("oci_tenancy")
+ or os.environ.get(_OCI_TENANCY_ENV),
+ "oci_key": optional_params.get("oci_key") or os.environ.get(_OCI_KEY_ENV),
+ "oci_key_file": optional_params.get("oci_key_file")
+ or os.environ.get(_OCI_KEY_FILE_ENV),
+ "oci_compartment_id": optional_params.get("oci_compartment_id")
+ or os.environ.get(_OCI_COMPARTMENT_ID_ENV),
+ }
+
+
+_OCI_REGION_RE = re.compile(r"^[a-z][a-z0-9-]{0,30}[a-z0-9]$")
+_OCI_ACTION_PATH_RE = re.compile(rf"/{OCI_API_VERSION}/actions/[^/?#]+/?$")
+
+
+def get_oci_base_url(optional_params: dict, api_base: Optional[str] = None) -> str:
+ """Return the OCI inference base URL, respecting any explicit api_base override.
+
+ If ``api_base`` already ends with a fully-formed OCI action path
+ (``/{OCI_API_VERSION}/actions/``), that suffix is stripped so callers
+ can append their own action path without producing a doubled URL.
+ """
+ if api_base:
+ return _OCI_ACTION_PATH_RE.sub("", api_base).rstrip("/")
+ creds = resolve_oci_credentials(optional_params)
+ region = creds["oci_region"]
+ if not isinstance(region, str) or not _OCI_REGION_RE.match(region):
+ raise OCIError(
+ status_code=400,
+ message=(
+ f"Invalid OCI region {region!r}: must match "
+ "^[a-z][a-z0-9-]{0,30}[a-z0-9]$ (e.g. 'us-ashburn-1')."
+ ),
+ )
+ return f"https://inference.generativeai.{region}.oci.oraclecloud.com"
+
+
+# ---------------------------------------------------------------------------
+# Signing implementations (shared by chat, embed, and rerank configs)
+# ---------------------------------------------------------------------------
+
+
+def sign_with_oci_signer(
+ headers: dict,
+ optional_params: dict,
+ request_data: dict,
+ api_base: str,
+) -> Tuple[dict, bytes]:
+ """Sign a request using an OCI SDK Signer object passed in optional_params."""
+ oci_signer = optional_params.get("oci_signer")
+ body = json.dumps(request_data).encode("utf-8")
+ method = str(optional_params.get("method", "POST")).upper()
+
+ if method not in {"POST", "GET", "PUT", "DELETE", "PATCH"}:
+ raise ValueError(f"Unsupported HTTP method: {method}")
+
+ prepared_headers = {**headers}
+ prepared_headers.setdefault("content-type", "application/json")
+ prepared_headers.setdefault("content-length", str(len(body)))
+
+ request_wrapper = OCIRequestWrapper(
+ method=method, url=api_base, headers=prepared_headers, body=body
+ )
+
+ if oci_signer is None:
+ raise ValueError("oci_signer cannot be None when calling sign_with_oci_signer")
+
+ try:
+ oci_signer.do_request_sign(request_wrapper, enforce_content_headers=True)
+ except Exception as e:
+ raise OCIError(
+ status_code=500,
+ message=(
+ f"Failed to sign request with provided oci_signer: {str(e)}. "
+ "The signer must implement the OCI SDK Signer interface with a "
+ "do_request_sign(request, enforce_content_headers=True) method. "
+ "See: https://docs.oracle.com/en-us/iaas/tools/python/latest/api/signing.html"
+ ),
+ ) from e
+
+ headers.update(request_wrapper.headers)
+ return headers, body
+
+
+def sign_with_manual_credentials(
+ headers: dict,
+ optional_params: dict,
+ request_data: dict,
+ api_base: str,
+) -> Tuple[dict, bytes]:
+ """Sign a request using manually provided OCI credentials (user/fingerprint/tenancy/key)."""
+ creds = resolve_oci_credentials(optional_params)
+ oci_user = creds["oci_user"]
+ oci_fingerprint = creds["oci_fingerprint"]
+ oci_tenancy = creds["oci_tenancy"]
+ oci_key = creds["oci_key"]
+ oci_key_file = creds["oci_key_file"]
+
+ if (
+ not oci_user
+ or not oci_fingerprint
+ or not oci_tenancy
+ or not (oci_key or oci_key_file)
+ ):
+ raise OCIError(
+ status_code=401,
+ message=(
+ "Missing required OCI credentials: oci_user, oci_fingerprint, oci_tenancy, "
+ "and at least one of oci_key or oci_key_file. "
+ "These can also be supplied via environment variables: "
+ f"{_OCI_USER_ENV}, {_OCI_FINGERPRINT_ENV}, {_OCI_TENANCY_ENV}, {_OCI_KEY_ENV} (or {_OCI_KEY_FILE_ENV}). "
+ "Alternatively, provide an oci_signer object from the OCI SDK."
+ ),
+ )
+
+ method = str(optional_params.get("method", "POST")).upper()
+ body = json.dumps(request_data).encode("utf-8")
+ parsed = urlparse(api_base)
+ path = parsed.path or "/"
+ host = parsed.netloc
+
+ date = formatdate(usegmt=True)
+ content_type = headers.get("content-type", "application/json")
+ content_length = str(len(body))
+ x_content_sha256 = sha256_base64(body)
+
+ headers_to_sign: Dict[str, str] = {
+ "date": date,
+ "host": host,
+ "content-type": content_type,
+ "content-length": content_length,
+ "x-content-sha256": x_content_sha256,
+ }
+
+ signed_header_names = [
+ "date",
+ "(request-target)",
+ "host",
+ "content-length",
+ "content-type",
+ "x-content-sha256",
+ ]
+ signing_string = build_signature_string(
+ method, path, headers_to_sign, signed_header_names
+ )
+
+ _require_cryptography()
+
+ # Resolve the private key — prefer inline PEM content over file path
+ oci_key_content: Optional[str] = None
+ if oci_key:
+ if not isinstance(oci_key, str):
+ raise OCIError(
+ status_code=400,
+ message=(
+ f"oci_key must be a string containing the PEM private key content. "
+ f"Got type: {type(oci_key).__name__}"
+ ),
+ )
+ oci_key_content = oci_key.replace("\\n", "\n").replace("\r\n", "\n")
+
+ private_key = (
+ load_private_key_from_str(oci_key_content)
+ if oci_key_content
+ else load_private_key_from_file(oci_key_file) if oci_key_file else None
+ )
+
+ if private_key is None:
+ raise OCIError(
+ status_code=400,
+ message="Private key is required for OCI authentication. Provide either oci_key or oci_key_file.",
+ )
+
+ signature = private_key.sign(
+ signing_string.encode("utf-8"),
+ padding.PKCS1v15(), # type: ignore[union-attr]
+ hashes.SHA256(), # type: ignore[union-attr]
+ )
+ signature_b64 = base64.b64encode(signature).decode()
+
+ key_id = f"{oci_tenancy}/{oci_user}/{oci_fingerprint}"
+ authorization = (
+ 'Signature version="1",'
+ f'keyId="{key_id}",'
+ 'algorithm="rsa-sha256",'
+ f'headers="{" ".join(signed_header_names)}",'
+ f'signature="{signature_b64}"'
+ )
+
+ headers.update(
+ {
+ "authorization": authorization,
+ "date": date,
+ "host": host,
+ "content-type": content_type,
+ "content-length": content_length,
+ "x-content-sha256": x_content_sha256,
+ }
+ )
+ return headers, body
+
+
+def sign_oci_request(
+ headers: dict,
+ optional_params: dict,
+ request_data: dict,
+ api_base: str,
+ api_key: Optional[str] = None,
+ model: Optional[str] = None,
+ stream: Optional[bool] = None,
+ fake_stream: Optional[bool] = None,
+) -> Tuple[dict, bytes]:
+ """
+ Route to the appropriate OCI signing method based on what credentials are present.
+
+ If ``oci_signer`` is in optional_params, use the OCI SDK signer object.
+ Otherwise use manual RSA-SHA256 signing with explicit credentials (which can
+ also be supplied via OCI_* environment variables).
+
+ Returns:
+ Tuple of (signed_headers, signed_body_bytes)
+ """
+ if optional_params.get("oci_signer") is not None:
+ return sign_with_oci_signer(headers, optional_params, request_data, api_base)
+ return sign_with_manual_credentials(
+ headers, optional_params, request_data, api_base
+ )
+
+
+def validate_oci_environment(
+ headers: dict,
+ optional_params: dict,
+ api_key: Optional[str] = None,
+) -> dict:
+ """
+ Populate common OCI request headers (content-type, user-agent).
+
+ Full credential validation is deferred to signing time so that credentials
+ supplied via environment variables are resolved at call time rather than
+ at construction time.
+ """
+ headers.setdefault("content-type", "application/json")
+ headers.setdefault("user-agent", f"litellm/{_litellm_version}")
+ return headers
+
+
+# ---------------------------------------------------------------------------
+# JSON schema utilities for OCI tool definitions
+#
+# OCI Generative AI does not support JSON Schema extensions ($ref, $defs,
+# anyOf). Pydantic v2 emits all three for models with Optional fields or
+# nested schemas. The helpers below are ported from the official
+# langchain-oracle reference implementation so that tool schemas are always
+# valid before they reach the OCI endpoint.
+# ---------------------------------------------------------------------------
+
+# Mapping from JSON Schema type names to Python type names, as expected by
+# the OCI Cohere API's CohereParameterDefinition.type field.
+OCI_JSON_TO_PYTHON_TYPES: Dict[str, str] = {
+ "string": "str",
+ "number": "float",
+ "boolean": "bool",
+ "integer": "int",
+ "array": "List",
+ "object": "Dict",
+ "any": "any",
+}
+
+
+def resolve_oci_schema_refs(schema: Dict[str, Any]) -> Dict[str, Any]:
+ """Inline all ``$ref``/``$defs`` references — OCI does not support JSON Schema ``$ref``."""
+ defs = schema.get("$defs", {})
+ resolving_stack: set = set()
+
+ def _resolve(obj: Any) -> Any:
+ if isinstance(obj, dict):
+ if "$ref" in obj:
+ ref = obj["$ref"]
+ if ref.startswith("#/$defs/"):
+ key = ref.split("/")[-1]
+ if key in resolving_stack:
+ return {"type": "object"} # break cycles
+ resolving_stack.add(key)
+ try:
+ return _resolve(defs.get(key, obj))
+ finally:
+ resolving_stack.discard(key)
+ return obj # external $ref — leave unchanged
+ return {k: _resolve(v) for k, v in obj.items()}
+ if isinstance(obj, list):
+ return [_resolve(item) for item in obj]
+ return obj
+
+ resolved = _resolve(schema)
+ if isinstance(resolved, dict):
+ resolved.pop("$defs", None)
+ return resolved
+
+
+def resolve_oci_schema_anyof(obj: Any) -> Any:
+ """Resolve Pydantic v2 ``Optional[T]`` → ``anyOf`` patterns.
+
+ Pydantic v2 emits ``{"anyOf": [{"type": "T"}, {"type": "null"}]}`` for
+ ``Optional[T]``. OCI models don't understand ``anyOf``, so we pick the
+ first non-null branch and merge top-level metadata into it.
+ """
+ if isinstance(obj, dict):
+ if "anyOf" in obj and "type" not in obj:
+ non_null = [
+ t
+ for t in obj["anyOf"]
+ if not (isinstance(t, dict) and t.get("type") == "null")
+ ]
+ if non_null:
+ resolved = {**obj, **non_null[0]}
+ resolved.pop("anyOf", None)
+ return resolve_oci_schema_anyof(resolved)
+ return {k: resolve_oci_schema_anyof(v) for k, v in obj.items()}
+ if isinstance(obj, list):
+ return [resolve_oci_schema_anyof(item) for item in obj]
+ return obj
+
+
+def sanitize_oci_schema(schema: Any) -> Any:
+ """Recursively remove OCI-incompatible fields from a JSON schema.
+
+ Strips ``title`` keys, removes ``None``-valued ``default`` entries,
+ normalises ``type: [T, "null"]`` list types, and ensures arrays carry an
+ ``items`` definition.
+ """
+ if isinstance(schema, list):
+ return [sanitize_oci_schema(item) for item in schema]
+ if not isinstance(schema, dict):
+ return schema
+
+ sanitized: Dict[str, Any] = {}
+ for key, value in schema.items():
+ if key == "title":
+ continue
+ if key == "default" and value is None:
+ continue
+ if key == "type":
+ if value == "any":
+ sanitized[key] = "object"
+ continue
+ if isinstance(value, list):
+ non_null = [t for t in value if t != "null"]
+ sanitized[key] = non_null[0] if non_null else "string"
+ continue
+ sanitized[key] = sanitize_oci_schema(value)
+
+ if sanitized.get("type") == "array" and "items" not in sanitized:
+ sanitized["items"] = {"type": "object"}
+
+ required = sanitized.get("required")
+ properties = sanitized.get("properties")
+ if "required" in sanitized:
+ if isinstance(required, list) and isinstance(properties, dict):
+ sanitized["required"] = [
+ f for f in required if isinstance(f, str) and f in properties
+ ]
+ elif not isinstance(required, list):
+ sanitized["required"] = []
+
+ return sanitized
+
+
+def enrich_cohere_param_description(
+ description: str, param_schema: Dict[str, Any]
+) -> str:
+ """Embed schema constraints into a Cohere parameter description.
+
+ ``CohereParameterDefinition`` only has ``type``, ``description``, and
+ ``isRequired``. Rich constraints (``enum``, ``format``, ``minimum``,
+ ``maximum``, ``pattern``) are appended to the description string so the
+ model can still see and respect them.
+ """
+ parts = [description] if description else []
+ if "enum" in param_schema:
+ parts.append(f"Allowed values: {param_schema['enum']}")
+ if "format" in param_schema:
+ parts.append(f"Format: {param_schema['format']}")
+ if "minimum" in param_schema or "maximum" in param_schema:
+ range_parts = []
+ if "minimum" in param_schema:
+ range_parts.append(f"min={param_schema['minimum']}")
+ if "maximum" in param_schema:
+ range_parts.append(f"max={param_schema['maximum']}")
+ parts.append(f"Range: {', '.join(range_parts)}")
+ if "pattern" in param_schema:
+ parts.append(f"Pattern: {param_schema['pattern']}")
+ return ". ".join(parts) if parts else ""
diff --git a/litellm/llms/oci/embed/transformation.py b/litellm/llms/oci/embed/transformation.py
index 1dcd8c5213c..6cfa85b4bc4 100644
--- a/litellm/llms/oci/embed/transformation.py
+++ b/litellm/llms/oci/embed/transformation.py
@@ -1,8 +1,14 @@
"""
-OCI Generative AI Embedding Configuration
+OCI Generative AI — Embedding transformation.
-Supports embedding models available on Oracle Cloud Infrastructure Generative AI service.
-Uses the same authentication mechanisms as OCI chat (manual signing or OCI SDK Signer).
+Endpoint: POST /20231130/actions/embedText
+Supported models: cohere.embed-english-v3.0, cohere.embed-multilingual-v3.0,
+cohere.embed-v4.0, and all other Cohere embed variants available on OCI
+(including dedicated endpoints).
+
+Authentication follows the same RSA-SHA256 / OCI SDK signer pattern as chat.
+The base handler (base_llm_http_handler.embedding) calls sign_request after
+building the body, so signing happens automatically.
Supported models:
- cohere.embed-english-v3.0
@@ -10,25 +16,45 @@ Supported models:
- cohere.embed-multilingual-v3.0
- cohere.embed-multilingual-light-v3.0
- cohere.embed-english-image-v3.0
-- cohere.embed-english-light-image-v3.0
-- cohere.embed-multilingual-light-image-v3.0
+- cohere.embed-multilingual-image-v3.0
- cohere.embed-v4.0
Reference: https://docs.oracle.com/en-us/iaas/api/#/en/generative-ai-inference/latest/EmbedTextResult/EmbedText
"""
-from typing import Any, Dict, List, Optional, Union
+from typing import TYPE_CHECKING, Any, List, Optional, Tuple, Union
import httpx
-from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
+import litellm
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.base_llm.embedding.transformation import BaseEmbeddingConfig
-from litellm.llms.oci.chat.transformation import OCIChatConfig
-from litellm.llms.oci.common_utils import OCIError
+from litellm.llms.oci.common_utils import (
+ OCI_API_VERSION,
+ OCIError,
+ get_oci_base_url,
+ resolve_oci_credentials,
+ sign_oci_request,
+ validate_oci_environment,
+)
+from litellm.types.llms.oci import (
+ OCIEmbedRequest,
+ OCIEmbedResponse,
+ OCIServingMode,
+)
from litellm.types.llms.openai import AllEmbeddingInputValues, AllMessageValues
from litellm.types.utils import EmbeddingResponse, Usage
+if TYPE_CHECKING:
+ from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
+
+ LiteLLMLoggingObj = _LiteLLMLoggingObj
+else:
+ LiteLLMLoggingObj = Any
+
+# OCI sends up to 96 texts per embedText request (Cohere limit).
+OCI_EMBED_BATCH_LIMIT = 96
+
# Input type mapping from OpenAI conventions to OCI/Cohere conventions
_INPUT_TYPE_MAP = {
"search_document": "SEARCH_DOCUMENT",
@@ -38,65 +64,43 @@ _INPUT_TYPE_MAP = {
}
-class OCIEmbeddingConfig(BaseEmbeddingConfig):
+class OCIEmbedConfig(BaseEmbeddingConfig):
"""
- Configuration for OCI Generative AI Embedding API.
+ Transformation config for OCI Generative AI embeddings.
- The OCI embedding endpoint uses the Cohere embed models hosted on OCI.
- Authentication is handled via OCI request signing (manual credentials or OCI SDK Signer).
+ Supports both text and (on cohere.embed-v4.0) multimodal inputs.
- Usage:
- ```python
- import litellm
+ Authentication — same two modes as chat:
+ - **OCI SDK signer**: pass ``oci_signer`` in optional_params.
+ - **Manual RSA-SHA256**: pass ``oci_user``, ``oci_fingerprint``, ``oci_tenancy``,
+ and ``oci_key`` or ``oci_key_file``, or set the corresponding ``OCI_*`` env vars.
- response = litellm.embedding(
- model="oci/cohere.embed-english-v3.0",
- input=["Hello world", "Goodbye world"],
- oci_compartment_id="ocid1.compartment.oc1..xxx",
- oci_region="us-ashburn-1",
- oci_user="ocid1.user.oc1..xxx",
- oci_fingerprint="xx:xx:xx:xx",
- oci_tenancy="ocid1.tenancy.oc1..xxx",
- oci_key_file="~/.oci/key.pem",
- )
- ```
+ Required call-time params (via optional_params or env vars):
+ - ``oci_compartment_id`` / ``OCI_COMPARTMENT_ID``
+ - ``oci_region`` / ``OCI_REGION`` (default: ``us-ashburn-1``)
+
+ Optional call-time params:
+ - ``oci_serving_mode``: ``"ON_DEMAND"`` (default) or ``"DEDICATED"``
+ - ``oci_endpoint_id``: endpoint OCID for dedicated serving mode
+ - ``input_type``: ``SEARCH_DOCUMENT``, ``SEARCH_QUERY``, ``CLASSIFICATION``, ``CLUSTERING``
+ - ``truncate``: ``NONE``, ``START``, or ``END`` (default ``END``)
+ - ``dimensions``: output embedding dimensions (cohere.embed-v4.0+)
"""
- def __init__(self) -> None:
- # We reuse OCIChatConfig for signing logic
- self._chat_config = OCIChatConfig()
-
- def get_complete_url(
- self,
- api_base: Optional[str],
- api_key: Optional[str],
- model: str,
- optional_params: dict,
- litellm_params: dict,
- stream: Optional[bool] = None,
- ) -> str:
- if api_base:
- return api_base
-
- oci_region = optional_params.get("oci_region", "us-ashburn-1")
- return f"https://inference.generativeai.{oci_region}.oci.oraclecloud.com/20231130/actions/embedText"
-
- def get_supported_openai_params(self, model: str) -> list:
- return [
- "dimensions",
- ]
+ def get_supported_openai_params(self, model: str) -> List[str]:
+ return ["dimensions"]
def map_openai_params(
self,
non_default_params: dict,
optional_params: dict,
model: str,
- drop_params: bool,
+ drop_params: bool = False,
) -> dict:
- # Note: OCI Cohere embed does not support custom dimensions natively,
- # but we pass it through in case future models support it
- if "dimensions" in non_default_params:
- optional_params["dimensions"] = non_default_params["dimensions"]
+ for key, value in non_default_params.items():
+ if key == "dimensions":
+ # OCI API uses outputDimensions (cohere.embed-v4.0+)
+ optional_params["outputDimensions"] = value
return optional_params
def validate_environment(
@@ -109,49 +113,42 @@ class OCIEmbeddingConfig(BaseEmbeddingConfig):
api_key: Optional[str] = None,
api_base: Optional[str] = None,
) -> dict:
- """
- Validate OCI credentials for embedding requests.
- Supports both OCI SDK Signer and manual credential signing.
- """
- oci_signer = optional_params.get("oci_signer")
- oci_region = optional_params.get("oci_region", "us-ashburn-1")
-
- api_base = (
- api_base
- or f"https://inference.generativeai.{oci_region}.oci.oraclecloud.com"
- )
-
- if oci_signer is None:
- oci_user = optional_params.get("oci_user")
- oci_fingerprint = optional_params.get("oci_fingerprint")
- oci_tenancy = optional_params.get("oci_tenancy")
- oci_key = optional_params.get("oci_key")
- oci_key_file = optional_params.get("oci_key_file")
- oci_compartment_id = optional_params.get("oci_compartment_id")
-
- if (
- not oci_user
- or not oci_fingerprint
- or not oci_tenancy
- or not (oci_key or oci_key_file)
- or not oci_compartment_id
- ):
- raise Exception(
- "Missing required parameters: oci_user, oci_fingerprint, oci_tenancy, oci_compartment_id "
- "and at least one of oci_key or oci_key_file. "
- "Alternatively, provide an oci_signer object from the OCI SDK."
+ if optional_params.get("oci_signer") is None:
+ creds = resolve_oci_credentials(optional_params)
+ missing = [
+ k
+ for k in (
+ "oci_user",
+ "oci_fingerprint",
+ "oci_tenancy",
+ "oci_compartment_id",
)
+ if not creds.get(k)
+ ]
+ if missing or not (creds.get("oci_key") or creds.get("oci_key_file")):
+ raise OCIError(
+ status_code=401,
+ message=(
+ "Missing required parameters: oci_user, oci_fingerprint, oci_tenancy, "
+ "oci_compartment_id and at least one of oci_key or oci_key_file. "
+ "These can be supplied via optional_params or via OCI_USER, OCI_FINGERPRINT, "
+ "OCI_TENANCY, OCI_COMPARTMENT_ID, OCI_KEY_FILE environment variables. "
+ "Alternatively, provide an oci_signer object from the OCI SDK."
+ ),
+ )
+ return validate_oci_environment(headers, optional_params, api_key)
- from litellm.llms.custom_httpx.http_handler import version
-
- headers.update(
- {
- "content-type": "application/json",
- "user-agent": f"litellm/{version}",
- }
- )
-
- return headers
+ def get_complete_url(
+ self,
+ api_base: Optional[str],
+ api_key: Optional[str],
+ model: str,
+ optional_params: dict,
+ litellm_params: dict,
+ stream: Optional[bool] = None,
+ ) -> str:
+ base = get_oci_base_url(optional_params, api_base or litellm.api_base)
+ return f"{base}/{OCI_API_VERSION}/actions/embedText"
def sign_request(
self,
@@ -163,9 +160,8 @@ class OCIEmbeddingConfig(BaseEmbeddingConfig):
model: Optional[str] = None,
stream: Optional[bool] = None,
fake_stream: Optional[bool] = None,
- ):
- """Delegate to OCIChatConfig's signing logic."""
- return self._chat_config.sign_request(
+ ) -> Tuple[dict, bytes]:
+ return sign_oci_request(
headers=headers,
optional_params=optional_params,
request_data=request_data,
@@ -182,91 +178,74 @@ class OCIEmbeddingConfig(BaseEmbeddingConfig):
input: AllEmbeddingInputValues,
optional_params: dict,
headers: dict,
- api_base: Optional[str] = None,
) -> dict:
- """
- Transform the embedding request to OCI format.
-
- OCI embedText API expects:
- {
- "compartmentId": "...",
- "servingMode": {"servingType": "ON_DEMAND", "modelId": "..."},
- "inputs": ["text1", "text2"],
- "truncate": "END",
- "inputType": "SEARCH_DOCUMENT"
- }
- """
- oci_compartment_id = optional_params.get("oci_compartment_id")
- if not oci_compartment_id:
- raise Exception(
- "kwarg `oci_compartment_id` is required for OCI embedding requests"
+ creds = resolve_oci_credentials(optional_params)
+ compartment_id = creds["oci_compartment_id"]
+ if not compartment_id:
+ raise OCIError(
+ status_code=400,
+ message=(
+ "oci_compartment_id is required for OCI embedding requests. "
+ "Pass it as optional_params or set the OCI_COMPARTMENT_ID env var."
+ ),
)
- # Build serving mode
- oci_serving_mode = optional_params.get("oci_serving_mode", "ON_DEMAND")
- if oci_serving_mode == "DEDICATED":
- oci_endpoint_id = optional_params.get("oci_endpoint_id", model)
- serving_mode = {
- "servingType": "DEDICATED",
- "endpointId": oci_endpoint_id,
- }
- else:
- serving_mode = {
- "servingType": "ON_DEMAND",
- "modelId": model,
- }
-
- # Normalize input to list of strings
+ # Normalise input to a flat list of strings
if isinstance(input, str):
- inputs = [input]
+ texts = [input]
elif isinstance(input, list):
- inputs = []
+ texts = []
for item in input:
- if isinstance(item, str):
- inputs.append(item)
- elif isinstance(item, list):
- raise ValueError(
- "OCI embedding does not support token-array inputs. "
- "Please convert token lists to strings before calling embedding()."
+ if isinstance(item, list):
+ raise OCIError(
+ status_code=400,
+ message=(
+ "OCI embedText does not support token-array inputs. "
+ "Convert token lists to strings before calling embedding()."
+ ),
)
- else:
- inputs.append(str(item))
+ texts.append(item if isinstance(item, str) else str(item))
else:
- inputs = [str(input)]
+ texts = [str(input)]
- # Build request data — OCI embedText API expects inputs, truncate,
- # and inputType at the top level alongside compartmentId and servingMode
- request_data: Dict[str, Any] = {
- "compartmentId": oci_compartment_id,
- "servingMode": serving_mode,
- "inputs": inputs,
- "truncate": optional_params.get("truncate", "END"),
- }
+ if len(texts) > OCI_EMBED_BATCH_LIMIT:
+ raise OCIError(
+ status_code=400,
+ message=(
+ f"OCI embedText accepts at most {OCI_EMBED_BATCH_LIMIT} inputs per request "
+ f"(got {len(texts)}). Batch your requests."
+ ),
+ )
- # Map input_type if provided
+ serving_mode_type = optional_params.get("oci_serving_mode", "ON_DEMAND").upper()
+ if serving_mode_type not in {"ON_DEMAND", "DEDICATED"}:
+ raise OCIError(
+ status_code=400,
+ message="oci_serving_mode must be 'ON_DEMAND' or 'DEDICATED'.",
+ )
+
+ if serving_mode_type == "DEDICATED":
+ endpoint_id = optional_params.get("oci_endpoint_id", model)
+ serving_mode = OCIServingMode(
+ servingType="DEDICATED", endpointId=endpoint_id
+ )
+ else:
+ serving_mode = OCIServingMode(servingType="ON_DEMAND", modelId=model)
+
+ # Map input_type from OpenAI convention to OCI/Cohere convention
input_type = optional_params.get("input_type")
if input_type:
- mapped_type = _INPUT_TYPE_MAP.get(input_type.lower(), input_type.upper())
- request_data["inputType"] = mapped_type
+ input_type = _INPUT_TYPE_MAP.get(input_type.lower(), input_type.upper())
- # Sign the request using the same URL the HTTP handler will POST to
- signing_url = self.get_complete_url(
- api_base=api_base,
- api_key=None,
- model=model,
- optional_params=optional_params,
- litellm_params={},
+ request = OCIEmbedRequest(
+ compartmentId=compartment_id,
+ servingMode=serving_mode,
+ inputs=texts,
+ inputType=input_type,
+ truncate=optional_params.get("truncate", "END"),
+ outputDimensions=optional_params.get("outputDimensions"),
)
-
- signed_headers, body = self.sign_request(
- headers=headers,
- optional_params=optional_params,
- request_data=request_data,
- api_base=signing_url,
- )
- headers.update(signed_headers)
-
- return request_data
+ return request.model_dump(exclude_none=True)
def transform_embedding_response(
self,
@@ -274,63 +253,57 @@ class OCIEmbeddingConfig(BaseEmbeddingConfig):
raw_response: httpx.Response,
model_response: EmbeddingResponse,
logging_obj: LiteLLMLoggingObj,
- api_key: Optional[str] = None,
- request_data: dict = {},
- optional_params: dict = {},
- litellm_params: dict = {},
+ api_key: Optional[str],
+ request_data: dict,
+ optional_params: dict,
+ litellm_params: dict,
) -> EmbeddingResponse:
- """
- Transform OCI embedding response to standard EmbeddingResponse format.
-
- OCI response format:
- {
- "embeddings": [[0.1, 0.2, ...], [0.3, 0.4, ...]],
- "modelId": "cohere.embed-english-v3.0",
- "modelVersion": "3.0",
- "inputTextTokenCounts": [5, 4]
- }
- """
if raw_response.status_code != 200:
raise OCIError(
- message=raw_response.text,
status_code=raw_response.status_code,
+ message=raw_response.text,
)
try:
- raw_response_json = raw_response.json()
- except Exception:
+ json_response = raw_response.json()
+ except Exception as e:
raise OCIError(
- message=raw_response.text,
status_code=raw_response.status_code,
+ message=f"Failed to parse OCI embed response as JSON: {e}",
)
- embeddings = raw_response_json.get("embeddings", [])
- model_id = raw_response_json.get("modelId", model)
-
- # Build response data in OpenAI format
- embedding_data = []
- for idx, embedding in enumerate(embeddings):
- embedding_data.append(
- {
- "object": "embedding",
- "index": idx,
- "embedding": embedding,
- }
+ try:
+ parsed = OCIEmbedResponse(**json_response)
+ except Exception as e:
+ raise OCIError(
+ status_code=500,
+ message=f"OCI embed response does not match expected schema: {e}",
)
- model_response.model = model_id
- model_response.data = embedding_data
- model_response.object = "list"
+ model_response.model = parsed.modelId
+ model_response.data = [
+ {
+ "object": "embedding",
+ "index": i,
+ "embedding": embedding,
+ }
+ for i, embedding in enumerate(parsed.embeddings)
+ ]
- # Calculate token usage
- input_token_counts = raw_response_json.get("inputTextTokenCounts", [])
- total_tokens = sum(input_token_counts) if input_token_counts else 0
-
- usage = Usage(
- prompt_tokens=total_tokens,
- total_tokens=total_tokens,
- )
- model_response.usage = usage
+ if parsed.inputTextTokenCounts is not None:
+ # Actual OCI API returns per-input token counts — sum for total usage
+ total = sum(parsed.inputTextTokenCounts)
+ model_response.usage = Usage(prompt_tokens=total, total_tokens=total)
+ elif parsed.usage is not None:
+ # Some deployments may return a usage object directly
+ model_response.usage = Usage(
+ prompt_tokens=parsed.usage.promptTokens,
+ total_tokens=parsed.usage.totalTokens,
+ )
+ else:
+ # Neither field returned — default to zero so downstream consumers
+ # can always rely on usage being populated.
+ model_response.usage = Usage(prompt_tokens=0, total_tokens=0)
return model_response
@@ -340,8 +313,8 @@ class OCIEmbeddingConfig(BaseEmbeddingConfig):
status_code: int,
headers: Union[dict, httpx.Headers],
) -> BaseLLMException:
- return OCIError(
- message=error_message,
- status_code=status_code,
- headers=headers if isinstance(headers, httpx.Headers) else None,
- )
+ return OCIError(status_code=status_code, message=error_message)
+
+
+# Alias for backwards compatibility with any code that imports OCIEmbeddingConfig
+OCIEmbeddingConfig = OCIEmbedConfig
diff --git a/litellm/llms/openai/chat/gpt_transformation.py b/litellm/llms/openai/chat/gpt_transformation.py
index 6b7ec4dfb1c..5464b5bb7ee 100644
--- a/litellm/llms/openai/chat/gpt_transformation.py
+++ b/litellm/llms/openai/chat/gpt_transformation.py
@@ -287,7 +287,13 @@ class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig):
content_item["image_url"] = new_image_url_obj
elif content_item.get("type") == "file":
content_item = cast(ChatCompletionFileObject, content_item)
- file_obj = content_item["file"]
+ file_obj = content_item.get("file")
+ if file_obj is None:
+ raise litellm.BadRequestError(
+ message="Content block has type='file' but is missing the required 'file' field",
+ model=None,
+ llm_provider="openai",
+ )
new_file_obj = ChatCompletionFileObjectFile(
**{ # type: ignore
k: v
diff --git a/litellm/llms/openai/chat/o_series_transformation.py b/litellm/llms/openai/chat/o_series_transformation.py
index 02ae2cc9750..8db7ecf7b3a 100644
--- a/litellm/llms/openai/chat/o_series_transformation.py
+++ b/litellm/llms/openai/chat/o_series_transformation.py
@@ -1,14 +1,14 @@
"""
-Support for o1/o3 model family
+Support for o1/o3 model family
https://platform.openai.com/docs/guides/reasoning
Translations handled by LiteLLM:
-- modalities: image => drop param (if user opts in to dropping param)
-- role: system ==> translate to role 'user'
-- streaming => faked by LiteLLM
-- Tools, response_format => drop param (if user opts in to dropping param)
-- Logprobs => drop param (if user opts in to dropping param)
+- modalities: image => drop param (if user opts in to dropping param)
+- role: system ==> translate to role 'user'
+- streaming => faked by LiteLLM
+- Tools, response_format => drop param (if user opts in to dropping param)
+- Logprobs => drop param (if user opts in to dropping param)
"""
from typing import Any, Coroutine, List, Literal, Optional, Union, cast, overload
diff --git a/litellm/llms/openai/common_utils.py b/litellm/llms/openai/common_utils.py
index c13a976c1b9..381f215a13f 100644
--- a/litellm/llms/openai/common_utils.py
+++ b/litellm/llms/openai/common_utils.py
@@ -201,7 +201,7 @@ class BaseOpenAILLM:
@staticmethod
def get_openai_client_initialization_param_fields(
- client_type: Literal["openai", "azure"]
+ client_type: Literal["openai", "azure"],
) -> Tuple[str, ...]:
"""Returns a tuple of fields that are used to initialize the OpenAI client"""
if client_type == "openai":
diff --git a/litellm/llms/openai/responses/transformation.py b/litellm/llms/openai/responses/transformation.py
index b7d5340d8d4..5043d25ee37 100644
--- a/litellm/llms/openai/responses/transformation.py
+++ b/litellm/llms/openai/responses/transformation.py
@@ -126,9 +126,21 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
litellm_params: GenericLiteLLMParams,
headers: dict,
) -> Dict:
- """No transform applied since inputs are in OpenAI spec already"""
+ """Strip Anthropic-only `cache_control` markers before sending to OpenAI.
+
+ OpenAI's Responses API rejects unknown fields on input content blocks
+ with HTTP 400 ("Unknown parameter: 'input[0].content[0].cache_control'").
+ Chat Completions strips these in
+ `remove_cache_control_flag_from_messages_and_tools`; mirror that here.
+ """
input = self._validate_input_param(input)
+ tools = response_api_optional_request_params.get("tools")
+ input, tools = self.remove_cache_control_flag_from_input_and_tools(
+ model=model, input=input, tools=tools
+ )
+ if tools is not None:
+ response_api_optional_request_params["tools"] = tools
final_request_params = dict(
ResponsesAPIRequestParams(
model=model, input=input, **response_api_optional_request_params
@@ -137,6 +149,38 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
return final_request_params
+ def remove_cache_control_flag_from_input_and_tools(
+ self,
+ model: str, # allows overrides to selectively run this
+ input: Union[str, ResponseInputParam],
+ tools: Optional[List[ALL_RESPONSES_API_TOOL_PARAMS]] = None,
+ ) -> Tuple[
+ Union[str, ResponseInputParam],
+ Optional[List[ALL_RESPONSES_API_TOOL_PARAMS]],
+ ]:
+ """Sibling of `remove_cache_control_flag_from_messages_and_tools` on
+ the chat path. Strips Anthropic-only `cache_control` markers from
+ Responses API input content blocks and tools.
+
+ `filter_value_from_dict` mutates each dict in place, so the same
+ objects are returned.
+ """
+ from litellm.litellm_core_utils.prompt_templates.common_utils import (
+ filter_value_from_dict,
+ )
+
+ if isinstance(input, list):
+ for item in input:
+ if isinstance(item, dict):
+ filter_value_from_dict(cast(dict, item), "cache_control")
+
+ if tools is not None:
+ for tool in tools:
+ if isinstance(tool, dict):
+ filter_value_from_dict(cast(dict, tool), "cache_control")
+
+ return input, tools
+
def _validate_input_param(
self, input: Union[str, ResponseInputParam]
) -> Union[str, ResponseInputParam]:
@@ -604,6 +648,12 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
url = str(parsed_url.copy_with(path=compact_path))
input = self._validate_input_param(input)
+ tools = response_api_optional_request_params.get("tools")
+ input, tools = self.remove_cache_control_flag_from_input_and_tools(
+ model=model, input=input, tools=tools
+ )
+ if tools is not None:
+ response_api_optional_request_params["tools"] = tools
data = dict(
ResponsesAPIRequestParams(
model=model, input=input, **response_api_optional_request_params
diff --git a/litellm/llms/openrouter/image_generation/transformation.py b/litellm/llms/openrouter/image_generation/transformation.py
index a55716a5e50..9c2293eb3f1 100644
--- a/litellm/llms/openrouter/image_generation/transformation.py
+++ b/litellm/llms/openrouter/image_generation/transformation.py
@@ -49,7 +49,6 @@ from litellm.types.utils import (
)
from litellm.llms.openrouter.common_utils import OpenRouterException
-
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
else:
diff --git a/litellm/llms/reducto/__init__.py b/litellm/llms/reducto/__init__.py
new file mode 100644
index 00000000000..8b137891791
--- /dev/null
+++ b/litellm/llms/reducto/__init__.py
@@ -0,0 +1 @@
+
diff --git a/litellm/llms/reducto/common.py b/litellm/llms/reducto/common.py
new file mode 100644
index 00000000000..4e7d96dbe87
--- /dev/null
+++ b/litellm/llms/reducto/common.py
@@ -0,0 +1,159 @@
+import base64
+import binascii
+from collections import defaultdict
+from typing import TYPE_CHECKING, Any, Dict, List, NoReturn, Optional, Tuple
+
+from litellm.constants import request_timeout
+
+REDUCTO_API_BASE = "https://platform.reducto.ai"
+REDUCTO_ID_PREFIX = "reducto://"
+
+if TYPE_CHECKING:
+ from litellm.llms.base_llm.ocr.transformation import OCRPage
+
+
+def _normalize_api_base(api_base: Optional[str]) -> str:
+ return (api_base or REDUCTO_API_BASE).rstrip("/")
+
+
+def _raise_bad_request(message: str, model: str) -> NoReturn:
+ import litellm
+
+ raise litellm.BadRequestError(
+ message=message,
+ model=model,
+ llm_provider="reducto",
+ )
+
+
+def extract_file_id_or_bytes(
+ source_url: str,
+ model: str,
+) -> Tuple[Optional[str], Optional[bytes], Optional[str]]:
+ if source_url.startswith(REDUCTO_ID_PREFIX):
+ return source_url, None, None
+
+ if source_url.startswith("http://") or source_url.startswith("https://"):
+ _raise_bad_request(
+ "Reducto requires type='file' (auto-uploaded) or a reducto:// id. Plain http(s) URLs are not supported; upload the file first.",
+ model=model,
+ )
+
+ if not source_url.startswith("data:"):
+ _raise_bad_request(
+ "Reducto requires a reducto:// id or a base64 data URI after OCR preprocessing.",
+ model=model,
+ )
+
+ try:
+ header, encoded = source_url.split(",", 1)
+ except ValueError:
+ _raise_bad_request("Invalid Reducto data URI provided.", model=model)
+
+ if ";base64" not in header:
+ _raise_bad_request(
+ "Reducto only supports base64-encoded data URIs.", model=model
+ )
+
+ mime = header.removeprefix("data:").split(";")[0] or "application/octet-stream"
+ try:
+ raw_bytes = base64.b64decode(encoded, validate=True)
+ except (binascii.Error, ValueError):
+ _raise_bad_request("Invalid Reducto base64 payload provided.", model=model)
+
+ return None, raw_bytes, mime
+
+
+def _extract_file_id_from_upload_response(response: Any) -> str:
+ try:
+ payload = response.json()
+ except ValueError as exc:
+ raise ValueError(
+ "Reducto /upload returned a non-JSON 200 response: {}".format(response.text)
+ ) from exc
+ file_id = (payload or {}).get("file_id") if isinstance(payload, dict) else None
+ if not isinstance(file_id, str) or not file_id:
+ raise ValueError(
+ "Reducto /upload returned 200 without a file_id; got payload={}".format(
+ payload
+ )
+ )
+ return file_id
+
+
+def upload_bytes_sync(
+ raw_bytes: bytes,
+ mime: Optional[str],
+ api_key: str,
+ api_base: Optional[str],
+) -> str:
+ import litellm
+
+ response = litellm.module_level_client.post(
+ url="{}{}".format(_normalize_api_base(api_base), "/upload"),
+ headers={"Authorization": f"Bearer {api_key}"},
+ files={"file": ("document", raw_bytes, mime or "application/octet-stream")},
+ timeout=request_timeout,
+ )
+ response.raise_for_status()
+ return _extract_file_id_from_upload_response(response)
+
+
+async def upload_bytes_async(
+ raw_bytes: bytes,
+ mime: Optional[str],
+ api_key: str,
+ api_base: Optional[str],
+) -> str:
+ import litellm
+
+ response = await litellm.module_level_aclient.post(
+ url="{}{}".format(_normalize_api_base(api_base), "/upload"),
+ headers={"Authorization": f"Bearer {api_key}"},
+ files={"file": ("document", raw_bytes, mime or "application/octet-stream")},
+ timeout=request_timeout,
+ )
+ response.raise_for_status()
+ return _extract_file_id_from_upload_response(response)
+
+
+def build_pages_from_reducto(result: Dict[str, Any]) -> List["OCRPage"]:
+ from litellm.llms.base_llm.ocr.transformation import OCRPage
+
+ chunks = result.get("chunks", []) or []
+ blocks_by_page: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
+
+ for chunk in chunks:
+ for block in chunk.get("blocks", []) or []:
+ page_no = (block.get("bbox") or {}).get("page")
+ if page_no is None:
+ continue
+ try:
+ normalized_page = int(page_no)
+ except (TypeError, ValueError):
+ continue
+ blocks_by_page[normalized_page].append(block)
+
+ if not blocks_by_page:
+ fallback_markdown = "\n\n".join(
+ chunk.get("content", "") for chunk in chunks if chunk.get("content")
+ )
+ if fallback_markdown == "":
+ return []
+ return [OCRPage(index=0, markdown=fallback_markdown)]
+
+ pages: List["OCRPage"] = []
+ for page_no, blocks in sorted(blocks_by_page.items()):
+ markdown = "\n\n".join(
+ block.get("content", "") for block in blocks if block.get("content")
+ )
+ page_index = max(page_no - 1, 0)
+ page = OCRPage(
+ index=page_index,
+ markdown=markdown,
+ )
+ # OCRPage accepts extra keys at runtime; assign blocks after construction
+ # so static typing does not reject provider-specific metadata.
+ setattr(page, "blocks", blocks)
+ pages.append(page)
+ return pages
diff --git a/litellm/llms/reducto/ocr/__init__.py b/litellm/llms/reducto/ocr/__init__.py
new file mode 100644
index 00000000000..8b137891791
--- /dev/null
+++ b/litellm/llms/reducto/ocr/__init__.py
@@ -0,0 +1 @@
+
diff --git a/litellm/llms/reducto/ocr/transformation.py b/litellm/llms/reducto/ocr/transformation.py
new file mode 100644
index 00000000000..cc338ecc484
--- /dev/null
+++ b/litellm/llms/reducto/ocr/transformation.py
@@ -0,0 +1,241 @@
+from typing import Any, Dict, Optional, Tuple
+
+import httpx
+
+from litellm.llms.base_llm.ocr.transformation import (
+ BaseOCRConfig,
+ DocumentType,
+ OCRRequestData,
+ OCRResponse,
+ OCRUsageInfo,
+)
+from litellm.llms.reducto.common import (
+ REDUCTO_API_BASE,
+ build_pages_from_reducto,
+ extract_file_id_or_bytes,
+ upload_bytes_async,
+ upload_bytes_sync,
+)
+
+
+class _BaseReductoOCRConfig(BaseOCRConfig):
+ def map_ocr_params(
+ self,
+ non_default_params: dict,
+ optional_params: dict,
+ model: str,
+ ) -> dict:
+ mapped_params = dict(optional_params)
+ supported_params = self.get_supported_ocr_params(model=model)
+ for param, value in non_default_params.items():
+ if param in supported_params:
+ mapped_params[param] = value
+ return mapped_params
+
+ def validate_environment(
+ self,
+ headers: Dict,
+ model: str,
+ api_key: Optional[str] = None,
+ api_base: Optional[str] = None,
+ litellm_params: Optional[dict] = None,
+ **kwargs,
+ ) -> Dict:
+ from litellm.secret_managers.main import get_secret_str
+
+ resolved_key = api_key or get_secret_str("REDUCTO_API_KEY")
+ if resolved_key is None:
+ raise ValueError(
+ "Missing REDUCTO_API_KEY - set it in the environment or pass api_key to litellm.ocr()/litellm.aocr()"
+ )
+
+ return {
+ "Authorization": f"Bearer {resolved_key}",
+ "Content-Type": "application/json",
+ **headers,
+ }
+
+ def get_complete_url(
+ self,
+ api_base: Optional[str],
+ model: str,
+ optional_params: dict,
+ litellm_params: Optional[dict] = None,
+ **kwargs,
+ ) -> str:
+ return "{}/parse".format((api_base or REDUCTO_API_BASE).rstrip("/"))
+
+ def _get_source_url(self, document: DocumentType, model: str) -> str:
+ source_url = document.get("document_url") or document.get("image_url")
+ if source_url is None:
+ raise ValueError(
+ "Reducto expected OCR preprocessing to produce document_url or image_url for model={}".format(
+ model
+ )
+ )
+ return source_url
+
+ @staticmethod
+ def _resolve_credentials(
+ api_key: Optional[str], api_base: Optional[str]
+ ) -> Tuple[str, str]:
+ from litellm.secret_managers.main import get_secret_str
+
+ resolved_key = api_key or get_secret_str("REDUCTO_API_KEY")
+ if resolved_key is None:
+ raise ValueError(
+ "Missing REDUCTO_API_KEY - set it in the environment or pass api_key to litellm.ocr()/litellm.aocr()"
+ )
+ resolved_base = (api_base or REDUCTO_API_BASE).rstrip("/")
+ return resolved_key, resolved_base
+
+ def _ensure_file_id_sync(
+ self,
+ model: str,
+ document: DocumentType,
+ api_key: Optional[str],
+ api_base: Optional[str],
+ ) -> str:
+ source_url = self._get_source_url(document=document, model=model)
+ file_id, raw_bytes, mime = extract_file_id_or_bytes(source_url, model=model)
+ if file_id is not None:
+ return file_id
+ resolved_key, resolved_base = self._resolve_credentials(api_key, api_base)
+ return upload_bytes_sync(
+ raw_bytes=raw_bytes or b"",
+ mime=mime,
+ api_key=resolved_key,
+ api_base=resolved_base,
+ )
+
+ async def _ensure_file_id_async(
+ self,
+ model: str,
+ document: DocumentType,
+ api_key: Optional[str],
+ api_base: Optional[str],
+ ) -> str:
+ source_url = self._get_source_url(document=document, model=model)
+ file_id, raw_bytes, mime = extract_file_id_or_bytes(source_url, model=model)
+ if file_id is not None:
+ return file_id
+ resolved_key, resolved_base = self._resolve_credentials(api_key, api_base)
+ return await upload_bytes_async(
+ raw_bytes=raw_bytes or b"",
+ mime=mime,
+ api_key=resolved_key,
+ api_base=resolved_base,
+ )
+
+ def transform_ocr_response(
+ self,
+ model: str,
+ raw_response: httpx.Response,
+ logging_obj: Any,
+ **kwargs,
+ ) -> OCRResponse:
+ response_json = raw_response.json()
+ result = response_json.get("result", response_json) or {}
+ usage = response_json.get("usage", {}) or {}
+ response = OCRResponse(
+ pages=build_pages_from_reducto(result),
+ model=model,
+ usage_info=OCRUsageInfo(
+ pages_processed=usage.get("num_pages"),
+ credits=usage.get("credits"),
+ ),
+ object="ocr",
+ )
+ response._hidden_params["reducto_raw"] = response_json
+ return response
+
+
+class ReductoParseV3Config(_BaseReductoOCRConfig):
+ def get_supported_ocr_params(self, model: str) -> list:
+ return ["formatting", "retrieval", "settings"]
+
+ def transform_ocr_request(
+ self,
+ model: str,
+ document: DocumentType,
+ optional_params: dict,
+ headers: dict,
+ **kwargs,
+ ) -> OCRRequestData:
+ file_id = self._ensure_file_id_sync(
+ model=model,
+ document=document,
+ api_key=kwargs.get("api_key"),
+ api_base=kwargs.get("api_base"),
+ )
+ return OCRRequestData(data={"input": file_id, **optional_params}, files=None)
+
+ async def async_transform_ocr_request(
+ self,
+ model: str,
+ document: DocumentType,
+ optional_params: dict,
+ headers: dict,
+ **kwargs,
+ ) -> OCRRequestData:
+ file_id = await self._ensure_file_id_async(
+ model=model,
+ document=document,
+ api_key=kwargs.get("api_key"),
+ api_base=kwargs.get("api_base"),
+ )
+ return OCRRequestData(data={"input": file_id, **optional_params}, files=None)
+
+
+class ReductoParseLegacyConfig(_BaseReductoOCRConfig):
+ def get_supported_ocr_params(self, model: str) -> list:
+ return ["enhance"]
+
+ def _build_legacy_body(self, file_id: str, optional_params: dict) -> Dict[str, Any]:
+ body: Dict[str, Any] = {"document_url": file_id}
+ enhance = optional_params.get("enhance")
+ if enhance is not None:
+ body["options"] = {"enhance": enhance}
+ return body
+
+ def transform_ocr_request(
+ self,
+ model: str,
+ document: DocumentType,
+ optional_params: dict,
+ headers: dict,
+ **kwargs,
+ ) -> OCRRequestData:
+ file_id = self._ensure_file_id_sync(
+ model=model,
+ document=document,
+ api_key=kwargs.get("api_key"),
+ api_base=kwargs.get("api_base"),
+ )
+ return OCRRequestData(
+ data=self._build_legacy_body(
+ file_id=file_id, optional_params=optional_params
+ ),
+ files=None,
+ )
+
+ async def async_transform_ocr_request(
+ self,
+ model: str,
+ document: DocumentType,
+ optional_params: dict,
+ headers: dict,
+ **kwargs,
+ ) -> OCRRequestData:
+ file_id = await self._ensure_file_id_async(
+ model=model,
+ document=document,
+ api_key=kwargs.get("api_key"),
+ api_base=kwargs.get("api_base"),
+ )
+ return OCRRequestData(
+ data=self._build_legacy_body(
+ file_id=file_id, optional_params=optional_params
+ ),
+ files=None,
+ )
diff --git a/litellm/llms/sagemaker/common_utils.py b/litellm/llms/sagemaker/common_utils.py
index 50c8ee4220e..6c15d642f8c 100644
--- a/litellm/llms/sagemaker/common_utils.py
+++ b/litellm/llms/sagemaker/common_utils.py
@@ -1,3 +1,4 @@
+import functools
import json
from typing import AsyncIterator, Iterator, List, Optional, Union
@@ -22,14 +23,22 @@ def _load_sagemaker_response_stream_shape():
)
except Exception as e:
verbose_logger.warning(
- "litellm: could not pre-load sagemaker-runtime response stream shape "
+ "litellm: could not load sagemaker-runtime response stream shape "
"— SageMaker event-stream decoding will be unavailable. Error: %s",
e,
)
return None
-SAGEMAKER_RESPONSE_STREAM_SHAPE = _load_sagemaker_response_stream_shape()
+@functools.lru_cache(maxsize=1)
+def get_sagemaker_response_stream_shape():
+ """
+ Lazily load and cache the sagemaker-runtime stream shape for the process.
+
+ Avoids importing botocore (and logging warnings) unless SageMaker event-stream
+ decoding is actually needed.
+ """
+ return _load_sagemaker_response_stream_shape()
class SagemakerError(BaseLLMException):
@@ -207,7 +216,8 @@ class AWSEventStreamDecoder:
verbose_logger.error(f"Final error parsing accumulated JSON: {e}")
def _parse_message_from_event(self, event) -> Optional[str]:
- if SAGEMAKER_RESPONSE_STREAM_SHAPE is None:
+ response_stream_shape = get_sagemaker_response_stream_shape()
+ if response_stream_shape is None:
raise SagemakerError(
status_code=500,
message=(
@@ -216,9 +226,7 @@ class AWSEventStreamDecoder:
),
)
response_dict = event.to_response_dict()
- parsed_response = self.parser.parse(
- response_dict, SAGEMAKER_RESPONSE_STREAM_SHAPE
- )
+ parsed_response = self.parser.parse(response_dict, response_stream_shape)
if response_dict["status_code"] != 200:
raise ValueError(f"Bad response code, expected 200: {response_dict}")
diff --git a/litellm/llms/sagemaker/completion/handler.py b/litellm/llms/sagemaker/completion/handler.py
index efbb218f575..de7be18e8ba 100644
--- a/litellm/llms/sagemaker/completion/handler.py
+++ b/litellm/llms/sagemaker/completion/handler.py
@@ -578,7 +578,7 @@ class SagemakerLLM(BaseAWSLLM):
logger_fn=None,
):
"""
- Supports both Huggingface Jumpstart embeddings and Voyage models
+ Supports Hugging Face (TGI), Voyage, and Cohere embedding endpoints
"""
### BOTO3 INIT
import boto3
diff --git a/litellm/llms/sagemaker/completion/transformation.py b/litellm/llms/sagemaker/completion/transformation.py
index 3e4e2460cdb..8fd32bc4460 100644
--- a/litellm/llms/sagemaker/completion/transformation.py
+++ b/litellm/llms/sagemaker/completion/transformation.py
@@ -1,7 +1,7 @@
"""
Translate from OpenAI's `/v1/chat/completions` to Sagemaker's `/invoke`
-In the Huggingface TGI format.
+In the Huggingface TGI format.
"""
import json
diff --git a/litellm/llms/sagemaker/embedding/cohere_transformation.py b/litellm/llms/sagemaker/embedding/cohere_transformation.py
new file mode 100644
index 00000000000..fdb67202ebb
--- /dev/null
+++ b/litellm/llms/sagemaker/embedding/cohere_transformation.py
@@ -0,0 +1,141 @@
+"""
+Translate from OpenAI's `/v1/embeddings` to Sagemaker's `/invoke`
+
+In the native Cohere embed format for self-hosted Cohere endpoints
+(AWS Marketplace / JumpStart). Cohere containers expect
+`{"texts": [...], "input_type": "..."}` and reject the HuggingFace TGI shape
+`{"inputs": [...]}` with `422 EmbedReqV2.inputs is of type string but should
+be of type Object`.
+
+Reference: https://docs.cohere.com/v2/reference/embed
+"""
+
+from typing import TYPE_CHECKING, Any, List, Optional, Union, cast
+
+if TYPE_CHECKING:
+ from litellm.types.llms.openai import AllEmbeddingInputValues
+
+from httpx._models import Headers, Response
+
+import litellm
+from litellm.llms.base_llm.chat.transformation import BaseLLMException
+from litellm.llms.base_llm.embedding.transformation import BaseEmbeddingConfig
+from litellm.llms.bedrock.embed.cohere_transformation import (
+ BedrockCohereEmbeddingConfig,
+)
+from litellm.llms.cohere.embed.v1_transformation import CohereEmbeddingConfig
+from litellm.types.utils import EmbeddingResponse
+
+from ..common_utils import SagemakerError
+
+
+class SagemakerCohereEmbeddingConfig(BaseEmbeddingConfig):
+ """
+ SageMaker invoke payload for self-hosted Cohere embed models.
+ """
+
+ def __init__(self) -> None:
+ pass
+
+ def get_supported_openai_params(self, model: str) -> List[str]:
+ return ["encoding_format", "dimensions", "input_type"]
+
+ def map_openai_params(
+ self,
+ non_default_params: dict,
+ optional_params: dict,
+ model: str,
+ drop_params: bool,
+ ) -> dict:
+ optional_params = BedrockCohereEmbeddingConfig().map_openai_params(
+ non_default_params=non_default_params,
+ optional_params=optional_params,
+ )
+ if "input_type" in non_default_params:
+ optional_params["input_type"] = non_default_params["input_type"]
+ return optional_params
+
+ def get_error_class(
+ self, error_message: str, status_code: int, headers: Union[dict, Headers]
+ ) -> BaseLLMException:
+ return SagemakerError(
+ message=error_message, status_code=status_code, headers=headers
+ )
+
+ def transform_embedding_request(
+ self,
+ model: str,
+ input: "AllEmbeddingInputValues",
+ optional_params: dict,
+ headers: dict,
+ ) -> dict:
+ """
+ Transform embedding request for Cohere models on SageMaker
+ """
+ if isinstance(input, str):
+ input_list: List[str] = [input]
+ elif isinstance(input, list):
+ if input and (isinstance(input[0], list) or isinstance(input[0], int)):
+ raise ValueError("Input must be a list of strings")
+ input_list = cast(List[str], input)
+ else:
+ input_list = [str(input)]
+
+ return dict(
+ BedrockCohereEmbeddingConfig()._transform_request(
+ model=model,
+ input=input_list,
+ inference_params=optional_params,
+ )
+ )
+
+ def transform_embedding_response(
+ self,
+ model: str,
+ raw_response: Response,
+ model_response: "EmbeddingResponse",
+ logging_obj: Any,
+ api_key: Optional[str] = None,
+ request_data: dict = {},
+ optional_params: dict = {},
+ litellm_params: dict = {},
+ ) -> "EmbeddingResponse":
+ """
+ Transform embedding response for Cohere models on SageMaker.
+
+ Uses `CohereEmbeddingConfig._populate_embedding_response` (not
+ `_transform_response`) so we do not log `post_call` a second time
+ — the SageMaker embedding handler already logs `post_call` before
+ invoking this transform.
+ """
+ input_value = (
+ logging_obj.model_call_details.get("input")
+ or request_data.get("texts")
+ or request_data.get("images")
+ or []
+ )
+ if isinstance(input_value, str):
+ input_value = [input_value]
+
+ return CohereEmbeddingConfig()._populate_embedding_response(
+ response_json=raw_response.json(),
+ model_response=model_response,
+ model=model,
+ encoding=litellm.encoding,
+ input=input_value,
+ )
+
+ def validate_environment(
+ self,
+ headers: dict,
+ model: str,
+ messages: List[Any],
+ optional_params: dict,
+ litellm_params: dict,
+ api_key: Optional[str] = None,
+ api_base: Optional[str] = None,
+ ) -> dict:
+ """
+ Validate environment for SageMaker Cohere embeddings
+ """
+ return {"Content-Type": "application/json"}
diff --git a/litellm/llms/sagemaker/embedding/transformation.py b/litellm/llms/sagemaker/embedding/transformation.py
index 04430171187..5e2aa99534f 100644
--- a/litellm/llms/sagemaker/embedding/transformation.py
+++ b/litellm/llms/sagemaker/embedding/transformation.py
@@ -1,7 +1,7 @@
"""
Translate from OpenAI's `/v1/embeddings` to Sagemaker's `/invoke`
-In the Huggingface TGI format.
+In the Huggingface TGI format.
"""
from typing import TYPE_CHECKING, Any, List, Optional, Union
@@ -11,12 +11,13 @@ if TYPE_CHECKING:
from httpx._models import Headers, Response
-from litellm.llms.base_llm.embedding.transformation import BaseEmbeddingConfig
from litellm.llms.base_llm.chat.transformation import BaseLLMException
-from litellm.types.utils import Usage, EmbeddingResponse
+from litellm.llms.base_llm.embedding.transformation import BaseEmbeddingConfig
from litellm.llms.voyage.embedding.transformation import VoyageEmbeddingConfig
+from litellm.types.utils import EmbeddingResponse, Usage
from ..common_utils import SagemakerError
+from .cohere_transformation import SagemakerCohereEmbeddingConfig
class SagemakerEmbeddingConfig(BaseEmbeddingConfig):
@@ -38,17 +39,20 @@ class SagemakerEmbeddingConfig(BaseEmbeddingConfig):
Returns:
Appropriate embedding config instance
"""
- if "voyage" in model.lower():
+ model_lower = model.lower()
+ if "voyage" in model_lower:
return VoyageEmbeddingConfig()
- else:
- return cls()
+ if "cohere" in model_lower:
+ return SagemakerCohereEmbeddingConfig()
+ return cls()
def get_supported_openai_params(self, model: str) -> List[str]:
- # Check if this is an embedding model
- if "voyage" in model.lower():
+ model_lower = model.lower()
+ if "voyage" in model_lower:
return VoyageEmbeddingConfig().get_supported_openai_params(model)
- else:
- return []
+ if "cohere" in model_lower:
+ return SagemakerCohereEmbeddingConfig().get_supported_openai_params(model)
+ return []
def map_openai_params(
self,
diff --git a/litellm/llms/sap/credentials.py b/litellm/llms/sap/credentials.py
index 0ae351783e8..dd307ddf496 100644
--- a/litellm/llms/sap/credentials.py
+++ b/litellm/llms/sap/credentials.py
@@ -207,7 +207,7 @@ def resolve_resource_group(sources: List[Source]) -> Optional[str]:
def _parse_service_key_once(
- service_key: Optional[Union[str, dict]]
+ service_key: Optional[Union[str, dict]],
) -> Optional[Dict[str, Any]]:
"""
Pre-parse service_key if it's a string to avoid repeated JSON parsing.
diff --git a/litellm/llms/snowflake/chat/transformation.py b/litellm/llms/snowflake/chat/transformation.py
index 3e590680a75..23bb6f44757 100644
--- a/litellm/llms/snowflake/chat/transformation.py
+++ b/litellm/llms/snowflake/chat/transformation.py
@@ -14,7 +14,6 @@ from ...openai_like.chat.transformation import OpenAIGPTConfig
from ..utils import SnowflakeBaseConfig
-
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
diff --git a/litellm/llms/together_ai/chat.py b/litellm/llms/together_ai/chat.py
index 7efb12fc1b2..238849cc1ec 100644
--- a/litellm/llms/together_ai/chat.py
+++ b/litellm/llms/together_ai/chat.py
@@ -1,5 +1,5 @@
"""
-Support for OpenAI's `/v1/chat/completions` endpoint.
+Support for OpenAI's `/v1/chat/completions` endpoint.
Calls done in OpenAI/openai.py as TogetherAI is openai-compatible.
diff --git a/litellm/llms/together_ai/embed.py b/litellm/llms/together_ai/embed.py
index 577df0256cc..6a39b94acfc 100644
--- a/litellm/llms/together_ai/embed.py
+++ b/litellm/llms/together_ai/embed.py
@@ -1,5 +1,5 @@
"""
-Support for OpenAI's `/v1/embeddings` endpoint.
+Support for OpenAI's `/v1/embeddings` endpoint.
Calls done in OpenAI/openai.py as TogetherAI is openai-compatible.
diff --git a/litellm/llms/together_ai/rerank/transformation.py b/litellm/llms/together_ai/rerank/transformation.py
index 63b593dfe42..f4d642bd25a 100644
--- a/litellm/llms/together_ai/rerank/transformation.py
+++ b/litellm/llms/together_ai/rerank/transformation.py
@@ -1,5 +1,5 @@
"""
-Transformation logic from Cohere's /v1/rerank format to Together AI's `/v1/rerank` format.
+Transformation logic from Cohere's /v1/rerank format to Together AI's `/v1/rerank` format.
Why separate file? Make it easy to see how transformation works
"""
diff --git a/litellm/llms/vertex_ai/context_caching/transformation.py b/litellm/llms/vertex_ai/context_caching/transformation.py
index 950edbeb478..f73eb220cc6 100644
--- a/litellm/llms/vertex_ai/context_caching/transformation.py
+++ b/litellm/llms/vertex_ai/context_caching/transformation.py
@@ -1,5 +1,5 @@
"""
-Transformation logic for context caching.
+Transformation logic for context caching.
Why separate file? Make it easy to see how transformation works
"""
@@ -19,7 +19,7 @@ from ..gemini.transformation import (
def get_first_continuous_block_idx(
- filtered_messages: List[Tuple[int, AllMessageValues]] # (idx, message)
+ filtered_messages: List[Tuple[int, AllMessageValues]], # (idx, message)
) -> int:
"""
Find the array index that ends the first continuous sequence of message blocks.
@@ -174,7 +174,9 @@ def transform_openai_messages_to_gemini_context_caching(
)
transformed_messages = _gemini_convert_messages_with_history(
- messages=new_messages, model=model
+ messages=new_messages,
+ model=model,
+ custom_llm_provider=custom_llm_provider,
)
model_name = "models/{}".format(model)
diff --git a/litellm/llms/vertex_ai/context_caching/vertex_ai_context_caching.py b/litellm/llms/vertex_ai/context_caching/vertex_ai_context_caching.py
index ac0f07b8e0b..3f945adca0d 100644
--- a/litellm/llms/vertex_ai/context_caching/vertex_ai_context_caching.py
+++ b/litellm/llms/vertex_ai/context_caching/vertex_ai_context_caching.py
@@ -41,7 +41,7 @@ class ContextCachingEndpoints(VertexBase):
"""
def __init__(self) -> None:
- pass
+ super().__init__()
def _get_token_and_url_context_caching(
self,
diff --git a/litellm/llms/vertex_ai/gemini/transformation.py b/litellm/llms/vertex_ai/gemini/transformation.py
index 9afa5dec465..4f5846cc5b6 100644
--- a/litellm/llms/vertex_ai/gemini/transformation.py
+++ b/litellm/llms/vertex_ai/gemini/transformation.py
@@ -6,13 +6,16 @@ Why separate file? Make it easy to see how transformation works
import json
import os
-from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple, Union, cast
+import re
+from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union, cast
+from urllib.parse import quote
import httpx
from pydantic import BaseModel
import litellm
from litellm._logging import verbose_logger
+from litellm.litellm_core_utils.asyncify import asyncify
from litellm.litellm_core_utils.prompt_templates.common_utils import (
_get_image_mime_type_from_url,
)
@@ -57,6 +60,45 @@ from ..common_utils import (
get_supports_system_message,
)
+# Typed as Any to avoid introducing a module-load-time cyclic import to
+# vertex_llm_base. The instance is lazily constructed by _get_vertex_base()
+# the first time GCS metadata needs to be fetched.
+_GCS_METADATA_VERTEX_BASE: Optional[Any] = None
+# Shared sync client for GCS JSON API metadata reads so proxy/SSL settings
+# from litellm's HTTP stack apply (see Greptile review on PR #27278).
+_GCS_METADATA_HTTP_HANDLER: Optional[HTTPHandler] = None
+_GEMINI_MIME_TYPE_ALIASES: Dict[str, str] = {
+ "image/jpg": "image/jpeg",
+}
+
+
+def _apply_gemini_mime_type_aliases(mime_type: str) -> str:
+ """Normalize known MIME aliases only; does not consult the file-type registry.
+
+ Also strips MIME parameters (e.g. ``; charset=utf-8``) so that values
+ sourced from GCS object metadata (``contentType``) validate correctly.
+ """
+ normalized = mime_type.split(";", 1)[0].strip().lower()
+ return _GEMINI_MIME_TYPE_ALIASES.get(normalized, normalized)
+
+
+def _get_vertex_base() -> Any:
+ """Lazily return the shared VertexBase instance to avoid a module-load-time cyclic import."""
+ global _GCS_METADATA_VERTEX_BASE
+ if _GCS_METADATA_VERTEX_BASE is None:
+ from ..vertex_llm_base import VertexBase
+
+ _GCS_METADATA_VERTEX_BASE = VertexBase()
+ return _GCS_METADATA_VERTEX_BASE
+
+
+def _get_gcs_metadata_http_handler() -> HTTPHandler:
+ global _GCS_METADATA_HTTP_HANDLER
+ if _GCS_METADATA_HTTP_HANDLER is None:
+ _GCS_METADATA_HTTP_HANDLER = HTTPHandler(timeout=5.0)
+ return _GCS_METADATA_HTTP_HANDLER
+
+
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
@@ -171,12 +213,299 @@ def _apply_gemini_metadata(
return cast(PartType, part_dict)
+def _parse_gs_uri(gs_uri: str) -> Tuple[str, str]:
+ if not gs_uri.startswith("gs://"):
+ raise ValueError(f"Invalid gs URI: {gs_uri}")
+ uri_without_scheme = gs_uri[5:] # drop gs://
+ uri_parts = uri_without_scheme.split("/", 1)
+ if len(uri_parts) != 2 or not uri_parts[0] or not uri_parts[1]:
+ raise ValueError(f"Invalid gs URI: {gs_uri}")
+ return uri_parts[0], uri_parts[1]
+
+
+def _is_valid_gcs_bucket_name(bucket: str) -> bool:
+ """
+ Validate bucket name against core GCS naming constraints.
+ """
+ bucket_length = len(bucket)
+ max_bucket_length = 222 if "." in bucket else 63
+ if bucket_length < 3 or bucket_length > max_bucket_length:
+ return False
+ if "." in bucket and any(
+ len(label) == 0 or len(label) > 63 for label in bucket.split(".")
+ ):
+ return False
+ if not re.fullmatch(r"[a-z0-9][a-z0-9._-]*[a-z0-9]", bucket):
+ return False
+ if ".." in bucket:
+ return False
+ if re.fullmatch(r"\d+\.\d+\.\d+\.\d+", bucket):
+ return False
+ return True
+
+
+def _gs_uri_requires_content_type_metadata(url: str) -> bool:
+ """
+ True when _process_gemini_media would call _get_gcs_object_content_type
+ (extension-less gs:// and no explicit format passed into that helper).
+ """
+ if "gs://" not in url:
+ return False
+ extension_with_dot = os.path.splitext(url)[-1]
+ extension = extension_with_dot[1:] if extension_with_dot else ""
+ return len(extension) == 0
+
+
+def _image_url_payload_may_need_sync_gcs_metadata_fetch(
+ raw_image_url: Any,
+) -> bool:
+ """
+ True when this image_url value (content-part image_url or assistant ``images[]``
+ entry) can trigger a blocking GCS metadata read for MIME resolution.
+ """
+ fmt: Optional[str] = None
+ url: Optional[str] = None
+ if isinstance(raw_image_url, dict):
+ url = raw_image_url.get("url") # type: ignore[assignment]
+ if not isinstance(url, str):
+ return False
+ fmt = (
+ raw_image_url.get("format")
+ or raw_image_url.get("mime_type")
+ or raw_image_url.get("content_type")
+ )
+ elif isinstance(raw_image_url, str):
+ url = raw_image_url
+ else:
+ return False
+ if "gs://" not in url or fmt:
+ return False
+ return _gs_uri_requires_content_type_metadata(url)
+
+
+def _openai_messages_may_need_sync_gcs_metadata_fetch(
+ messages: List[AllMessageValues],
+) -> bool:
+ """
+ Heuristic: True if any message part can trigger a blocking GCS JSON
+ metadata read inside _transform_request_body (extension-less gs:// without
+ explicit MIME hints). Covers user/system ``content`` parts and assistant
+ ``images`` (same paths as ``_gemini_convert_messages_with_history``). Used
+ to decide whether ``async_transform_request_body`` should offload the sync
+ transform via ``asyncify``.
+ """
+ for raw in messages:
+ msg: Any = raw
+ if not isinstance(msg, dict) and hasattr(msg, "model_dump"):
+ msg = msg.model_dump(exclude_none=False)
+ if not isinstance(msg, dict):
+ continue
+ images_field = msg.get("images")
+ if isinstance(images_field, list):
+ for image_item in images_field:
+ if not isinstance(image_item, dict):
+ continue
+ if _image_url_payload_may_need_sync_gcs_metadata_fetch(
+ image_item.get("image_url")
+ ):
+ return True
+
+ content = msg.get("content")
+ if not isinstance(content, list):
+ continue
+ for item in content:
+ if not isinstance(item, dict):
+ continue
+ itype = item.get("type")
+ if itype == "image_url":
+ if _image_url_payload_may_need_sync_gcs_metadata_fetch(
+ item.get("image_url")
+ ):
+ return True
+ elif itype == "file":
+ file_obj = item.get("file")
+ if not isinstance(file_obj, dict):
+ continue
+ fmt = (
+ file_obj.get("format")
+ or file_obj.get("mime_type")
+ or file_obj.get("content_type")
+ )
+ passed = file_obj.get("file_id") or file_obj.get("file_data")
+ if (
+ isinstance(passed, str)
+ and "gs://" in passed
+ and not fmt
+ and _gs_uri_requires_content_type_metadata(passed)
+ ):
+ return True
+ return False
+
+
+def _get_gcs_object_content_type(
+ image_url: str,
+ vertex_project: Optional[str] = None,
+ vertex_credentials: Optional[Any] = None,
+) -> Optional[str]:
+ """
+ Resolve content type from GCS object metadata.
+
+ Only attaches a Bearer token when the caller explicitly supplies Vertex
+ credentials, to avoid using the server's default Google credentials on
+ the Gemini API-key (Google AI Studio) path and being used as an oracle
+ for private GCS object metadata. Without explicit credentials we only
+ issue an anonymous request, which only succeeds for publicly-readable
+ objects.
+ """
+ try:
+ bucket, object_name = _parse_gs_uri(image_url)
+ except ValueError:
+ return None
+ if not _is_valid_gcs_bucket_name(bucket):
+ return None
+
+ headers: Dict[str, str] = {}
+ explicit_vertex_auth_provided = (
+ vertex_project is not None or vertex_credentials is not None
+ )
+ if explicit_vertex_auth_provided:
+ try:
+ access_token, _ = _get_vertex_base().get_access_token(
+ credentials=vertex_credentials,
+ project_id=vertex_project,
+ )
+ headers["Authorization"] = f"Bearer {access_token}"
+ except Exception as e:
+ raise litellm.BadRequestError(
+ message=(
+ "Unable to fetch GCS metadata with provided Vertex credentials/project. "
+ f"Original error: {str(e)}"
+ ),
+ model=None,
+ llm_provider="vertex_ai",
+ )
+
+ # Build the URL via httpx.URL with a fixed scheme/host and URL-encode both
+ # bucket and object so CodeQL does not flag the interpolation as a
+ # potential SSRF that could resolve to an arbitrary host.
+ encoded_bucket = quote(bucket, safe="")
+ encoded_object = quote(object_name, safe="")
+ metadata_url = httpx.URL(
+ scheme="https",
+ host="storage.googleapis.com",
+ path=f"/storage/v1/b/{encoded_bucket}/o/{encoded_object}",
+ params={"fields": "contentType"},
+ )
+ try:
+ response = _get_gcs_metadata_http_handler().get(
+ url=str(metadata_url),
+ headers=headers or None,
+ )
+ except httpx.RequestError as e:
+ if explicit_vertex_auth_provided:
+ raise litellm.BadRequestError(
+ message=(
+ "Unable to reach GCS JSON API for object metadata with provided "
+ f"Vertex credentials. {type(e).__name__}: {e}"
+ ),
+ model=None,
+ llm_provider="vertex_ai",
+ ) from e
+ return None
+
+ if response.is_error:
+ if explicit_vertex_auth_provided:
+ preview = (response.text or "")[:1024]
+ raise litellm.BadRequestError(
+ message=(
+ "Unable to read GCS object metadata with provided Vertex credentials. "
+ f"HTTP {response.status_code}. Response body (truncated): {preview!r}"
+ ),
+ model=None,
+ llm_provider="vertex_ai",
+ )
+ return None
+
+ try:
+ payload = response.json()
+ except ValueError as e:
+ if explicit_vertex_auth_provided:
+ raise litellm.BadRequestError(
+ message=(
+ "GCS metadata response was not valid JSON when using provided "
+ f"Vertex credentials (HTTP {response.status_code}). Error: {e}"
+ ),
+ model=None,
+ llm_provider="vertex_ai",
+ ) from e
+ return None
+
+ if not isinstance(payload, dict):
+ if explicit_vertex_auth_provided:
+ raise litellm.BadRequestError(
+ message=(
+ "GCS metadata response was not a JSON object when using provided "
+ f"Vertex credentials (HTTP {response.status_code})."
+ ),
+ model=None,
+ llm_provider="vertex_ai",
+ )
+ return None
+
+ content_type = payload.get("contentType")
+ if isinstance(content_type, str) and len(content_type) > 0:
+ return content_type
+
+ if explicit_vertex_auth_provided:
+ preview = (response.text or "")[:1024]
+ raise litellm.BadRequestError(
+ message=(
+ "GCS metadata JSON did not include a non-empty contentType field when "
+ f"using provided Vertex credentials (HTTP {response.status_code}). "
+ f"Body (truncated): {preview!r}"
+ ),
+ model=None,
+ llm_provider="vertex_ai",
+ )
+ return None
+
+
+def _normalize_and_validate_gemini_mime_type(
+ mime_type: str, model: Optional[str]
+) -> str:
+ # Import lazily to avoid a module-level cyclic-import alert with
+ # litellm.types.files.
+ from litellm.types.files import get_file_extension_from_mime_type
+
+ normalized_mime_type = _apply_gemini_mime_type_aliases(mime_type)
+ try:
+ file_extension = get_file_extension_from_mime_type(normalized_mime_type)
+ file_type = get_file_type_from_extension(file_extension)
+ except ValueError:
+ raise litellm.BadRequestError(
+ message=f"File type not supported by gemini - {normalized_mime_type}",
+ model=model,
+ llm_provider="vertex_ai",
+ )
+
+ if not is_gemini_1_5_accepted_file_type(file_type):
+ raise litellm.BadRequestError(
+ message=f"File type not supported by gemini - {file_type}",
+ model=model,
+ llm_provider="vertex_ai",
+ )
+
+ return get_file_mime_type_for_file_type(file_type)
+
+
def _process_gemini_media(
image_url: str,
format: Optional[str] = None,
media_resolution_enum: Optional[Dict[str, str]] = None,
model: Optional[str] = None,
video_metadata: Optional[Dict[str, Any]] = None,
+ vertex_project: Optional[str] = None,
+ vertex_credentials: Optional[Any] = None,
) -> PartType:
"""
Given a media URL (image, audio, or video), return the appropriate PartType for Gemini
@@ -193,20 +522,63 @@ def _process_gemini_media(
try:
# GCS URIs
if "gs://" in image_url:
- # Figure out file type
extension_with_dot = os.path.splitext(image_url)[-1] # Ex: ".png"
extension = extension_with_dot[1:] # Ex: "png"
+ explicit_gcs_format = False
if not format:
- file_type = get_file_type_from_extension(extension)
+ mime_type: Optional[str] = None
+ # For extension-less gs:// URIs, we cannot infer from path.
+ # If callers pass `format`/`mime_type`, this branch is skipped.
+ if extension:
+ file_type = get_file_type_from_extension(extension)
- # Validate the file type is supported by Gemini
- if not is_gemini_1_5_accepted_file_type(file_type):
- raise Exception(f"File type not supported by gemini - {file_type}")
+ # Validate the file type is supported by Gemini
+ if not is_gemini_1_5_accepted_file_type(file_type):
+ raise litellm.BadRequestError(
+ message=f"File type not supported by gemini - {file_type}",
+ model=model,
+ llm_provider="vertex_ai",
+ )
- mime_type = get_file_mime_type_for_file_type(file_type)
+ mime_type = get_file_mime_type_for_file_type(file_type)
+ else:
+ mime_type = _get_gcs_object_content_type(
+ image_url=image_url,
+ vertex_project=vertex_project,
+ vertex_credentials=vertex_credentials,
+ )
+ if mime_type is None:
+ raise litellm.BadRequestError(
+ message=(
+ f"Unable to determine mime type for gs URI: {image_url}. "
+ "This gs:// URI has no file extension and GCS metadata "
+ "lookup failed. Set it explicitly using image_url.format "
+ "(or image_url.mime_type/content_type) or "
+ "message.content[].file.format."
+ ),
+ model=model,
+ llm_provider="vertex_ai",
+ )
else:
mime_type = format
+ explicit_gcs_format = True
+ if mime_type is None:
+ raise litellm.BadRequestError(
+ message=f"File type not supported by gemini - {image_url}",
+ model=model,
+ llm_provider="vertex_ai",
+ )
+ if explicit_gcs_format:
+ # Callers who pass format/mime_type explicitly for gs:// URIs
+ # rely on pass-through to Gemini (pre-PR behavior). Only apply
+ # known MIME aliases; skip litellm's file-type registry.
+ mime_type = _apply_gemini_mime_type_aliases(mime_type)
+ else:
+ mime_type = _normalize_and_validate_gemini_mime_type(
+ mime_type=mime_type,
+ model=model,
+ )
file_data = FileDataType(mime_type=mime_type, file_uri=image_url)
part: PartType = {"file_data": file_data}
return _apply_gemini_metadata(
@@ -258,8 +630,6 @@ def _snake_to_camel(snake_str: str) -> str:
def _camel_to_snake(camel_str: str) -> str:
"""Convert camelCase to snake_case"""
- import re
-
return re.sub(r"(? List[ContentType]:
"""
Converts given messages from OpenAI format to Gemini format
@@ -326,6 +698,16 @@ def _gemini_convert_messages_with_history( # noqa: PLR0915
msg_i = 0
tool_call_responses = []
+ vertex_project = None
+ vertex_credentials = None
+ if litellm_params:
+ vertex_project = litellm_params.get("vertex_project") or litellm_params.get(
+ "vertex_ai_project"
+ )
+ vertex_credentials = litellm_params.get(
+ "vertex_credentials"
+ ) or litellm_params.get("vertex_ai_credentials")
+
try:
while msg_i < len(messages):
user_content: List[PartType] = []
@@ -351,20 +733,42 @@ def _gemini_convert_messages_with_history( # noqa: PLR0915
img_element = element
format: Optional[str] = None
media_resolution_enum: Optional[Dict[str, str]] = None
- if isinstance(img_element["image_url"], dict):
- image_url = img_element["image_url"]["url"]
- format = img_element["image_url"].get("format")
- detail = img_element["image_url"].get("detail")
+ raw_image_url = img_element.get("image_url")
+ if raw_image_url is None:
+ raise litellm.BadRequestError(
+ message="Invalid message content: element type is 'image_url' but 'image_url' field is missing ",
+ model=model,
+ llm_provider="vertex_ai",
+ )
+ if isinstance(raw_image_url, dict):
+ image_url = raw_image_url.get("url")
+ if image_url is None:
+ raise litellm.BadRequestError(
+ message="Invalid message content: element type is 'image_url' but 'url' field is missing inside 'image_url' ",
+ model=model,
+ llm_provider="vertex_ai",
+ )
+ # TypedDict does not declare mime_type/content_type;
+ # read via Dict[str, Any] for caller-provided MIME fields.
+ image_url_dict = cast(Dict[str, Any], raw_image_url)
+ format = (
+ image_url_dict.get("format")
+ or image_url_dict.get("mime_type")
+ or image_url_dict.get("content_type")
+ )
+ detail = image_url_dict.get("detail")
media_resolution_enum = (
_convert_detail_to_media_resolution_enum(detail)
)
else:
- image_url = img_element["image_url"]
+ image_url = raw_image_url
_part = _process_gemini_media(
image_url=image_url,
format=format,
media_resolution_enum=media_resolution_enum,
model=model,
+ vertex_project=vertex_project,
+ vertex_credentials=vertex_credentials,
)
_parts.append(_part)
elif element["type"] == "input_audio":
@@ -390,15 +794,31 @@ def _gemini_convert_messages_with_history( # noqa: PLR0915
image_url=openai_image_str,
format=audio_format_modified,
model=model,
+ vertex_project=vertex_project,
+ vertex_credentials=vertex_credentials,
)
_parts.append(_part)
elif element["type"] == "file":
file_element = cast(ChatCompletionFileObject, element)
- file_id = file_element["file"].get("file_id")
- format = file_element["file"].get("format")
- file_data = file_element["file"].get("file_data")
- detail = file_element["file"].get("detail")
- video_metadata = file_element["file"].get("video_metadata")
+ _file_field = file_element.get("file")
+ if _file_field is None:
+ raise litellm.BadRequestError(
+ message="Content block has type='file' but is missing the required 'file' field",
+ model=model,
+ llm_provider="vertex_ai",
+ )
+ # TypedDict does not declare mime_type/content_type;
+ # read via Dict[str, Any] for caller-provided MIME fields.
+ file_dict = cast(Dict[str, Any], _file_field)
+ file_id = file_dict.get("file_id")
+ format = (
+ file_dict.get("format")
+ or file_dict.get("mime_type")
+ or file_dict.get("content_type")
+ )
+ file_data = file_dict.get("file_data")
+ detail = file_dict.get("detail")
+ video_metadata = file_dict.get("video_metadata")
passed_file = file_id or file_data
if passed_file is None:
raise Exception(
@@ -417,13 +837,23 @@ def _gemini_convert_messages_with_history( # noqa: PLR0915
model=model,
media_resolution_enum=media_resolution_enum,
video_metadata=video_metadata,
+ vertex_project=vertex_project,
+ vertex_credentials=vertex_credentials,
)
_parts.append(_part)
- except Exception:
- raise Exception(
- "Unable to determine mime type for file_id: {}, set this explicitly using message[{}].content[{}].file.format".format(
- file_id, msg_i, element_idx
- )
+ except litellm.BadRequestError:
+ raise
+ except Exception as e:
+ raise litellm.BadRequestError(
+ message=(
+ f"Unable to determine mime type for file: "
+ f"{file_id or 'provided data'}, set this explicitly "
+ f"using message[{msg_i}].content[{element_idx}].file.format "
+ f"(or file.mime_type/content_type). "
+ f"Original error: {str(e)}"
+ ),
+ model=model,
+ llm_provider="vertex_ai",
)
user_content.extend(_parts)
elif _message_content is not None and isinstance(_message_content, str):
@@ -528,7 +958,11 @@ def _gemini_convert_messages_with_history( # noqa: PLR0915
image_url_obj = image_item.get("image_url")
if isinstance(image_url_obj, dict):
assistant_image_url = image_url_obj.get("url")
- format = image_url_obj.get("format")
+ format = (
+ image_url_obj.get("format")
+ or image_url_obj.get("mime_type")
+ or image_url_obj.get("content_type")
+ )
detail = image_url_obj.get("detail")
media_resolution_enum = (
_convert_detail_to_media_resolution_enum(detail)
@@ -539,6 +973,8 @@ def _gemini_convert_messages_with_history( # noqa: PLR0915
format=format,
media_resolution_enum=media_resolution_enum,
model=model,
+ vertex_project=vertex_project,
+ vertex_credentials=vertex_credentials,
)
assistant_content.append(_part)
@@ -548,7 +984,9 @@ def _gemini_convert_messages_with_history( # noqa: PLR0915
or assistant_msg.get("function_call") is not None
): # support assistant tool invoke conversion
gemini_tool_call_parts = convert_to_gemini_tool_call_invoke(
- assistant_msg, model=model
+ assistant_msg,
+ model=model,
+ custom_llm_provider=custom_llm_provider,
)
## check if gemini_tool_call already exists in assistant_content
for gemini_tool_call_part in gemini_tool_call_parts:
@@ -607,7 +1045,10 @@ def _gemini_convert_messages_with_history( # noqa: PLR0915
and messages[msg_i]["role"] in tool_call_message_roles
):
_part = convert_to_gemini_tool_call_result(
- messages[msg_i], last_message_with_tool_calls # type: ignore
+ messages[msg_i], # type: ignore
+ last_message_with_tool_calls, # type: ignore
+ model=model,
+ custom_llm_provider=custom_llm_provider,
)
msg_i += 1
# Handle both single part and list of parts (for Computer Use with images)
@@ -632,16 +1073,14 @@ def _gemini_convert_messages_with_history( # noqa: PLR0915
contents.append(ContentType(role="user", parts=tool_call_responses))
if len(contents) == 0:
- verbose_logger.warning(
- """
+ verbose_logger.warning("""
No contents in messages. Contents are required. See
https://cloud.google.com/vertex-ai/docs/reference/rest/v1/projects.locations.publishers.models/generateContent#request-body.
If the original request did not comply to OpenAI API requirements it should have failed by now,
but LiteLLM does not check for missing messages.
Setting an empty content to prevent an 400 error.
Relevant Issue - https://github.com/BerriAI/litellm/issues/9733
- """
- )
+ """)
contents.append(ContentType(role="user", parts=[PartType(text=" ")]))
return contents
except Exception as e:
@@ -713,11 +1152,11 @@ def _transform_request_body( # noqa: PLR0915
try:
if custom_llm_provider == "gemini":
content = litellm.GoogleAIStudioGeminiConfig()._transform_messages(
- messages=messages, model=model
+ messages=messages, model=model, litellm_params=litellm_params
)
else:
content = litellm.VertexGeminiConfig()._transform_messages(
- messages=messages, model=model
+ messages=messages, model=model, litellm_params=litellm_params
)
tools: Optional[Tools] = optional_params.pop("tools", None)
tool_choice: Optional[ToolConfig] = optional_params.pop("tool_choice", None)
@@ -893,6 +1332,20 @@ async def async_transform_request_body(
vertex_auth_header=vertex_auth_header,
)
+ if _openai_messages_may_need_sync_gcs_metadata_fetch(messages):
+ # _transform_request_body may issue a sync httpx.get (up to 5s timeout)
+ # via _get_gcs_object_content_type to fetch GCS object metadata. Run the
+ # whole sync transformation on a worker thread so it does not block the
+ # async event loop.
+ return await asyncify(_transform_request_body)(
+ messages=messages,
+ model=model,
+ custom_llm_provider=custom_llm_provider,
+ litellm_params=litellm_params,
+ cached_content=cached_content,
+ optional_params=optional_params,
+ )
+
return _transform_request_body(
messages=messages,
model=model,
diff --git a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py
index 6278de662f8..189ac7a7f6a 100644
--- a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py
+++ b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py
@@ -280,6 +280,8 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
- gemini-3-pro-preview
- gemini-3-flash
- gemini-3-flash-preview (Gemini 3 Flash)
+ - gemini-3.1-pro-preview, gemini-3.1-flash, gemini-3.1-flash-lite-preview
+ - gemini-3.5-flash
- Any future Gemini 3.x models
"""
# Check for Gemini 3 models
@@ -287,6 +289,20 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
return True
return False
+ @staticmethod
+ def _forward_gemini_function_call_id(
+ model: str, custom_llm_provider: Optional[str] = None
+ ) -> bool:
+ """
+ Whether to include `id` on function_call / function_response parts.
+
+ Gemini 3+ on Google AI Studio accepts (and returns) `id` for strict
+ tool-call matching. Vertex AI rejects the field with HTTP 400.
+ """
+ if custom_llm_provider != "gemini":
+ return False
+ return VertexGeminiConfig._is_gemini_3_or_newer(model)
+
def _supports_penalty_parameters(self, model: str) -> bool:
# Gemini 3 models do not support penalty parameters
if VertexGeminiConfig._is_gemini_3_or_newer(model):
@@ -300,6 +316,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
supported_params = [
"temperature",
"top_p",
+ "top_k",
"max_tokens",
"max_completion_tokens",
"stream",
@@ -363,6 +380,66 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
"""
return Tools(googleSearch={})
+ @staticmethod
+ def _search_tool_keys() -> set:
+ return {
+ VertexToolName.GOOGLE_SEARCH.value,
+ VertexToolName.GOOGLE_SEARCH_RETRIEVAL.value,
+ VertexToolName.ENTERPRISE_WEB_SEARCH.value,
+ VertexToolName.URL_CONTEXT.value,
+ "google_search",
+ "google_search_retrieval",
+ "enterprise_web_search",
+ "urlContext",
+ }
+
+ @classmethod
+ def _drop_search_tools_mixed_with_functions(cls, optional_params: dict) -> None:
+ """
+ Drop search tools from optional_params when mixed with function declarations
+ and include_server_side_tool_invocations is not enabled.
+
+ Runs after map_openai_params merges tools and web_search_options so both
+ code paths (single _map_function call vs split tools + web_search_options)
+ get the same conflict resolution.
+ """
+ if optional_params.get("include_server_side_tool_invocations"):
+ return
+
+ tools = optional_params.get("tools")
+ if not isinstance(tools, list) or not tools:
+ return
+
+ search_tool_keys = cls._search_tool_keys()
+ has_function_declarations = any(
+ isinstance(tool, dict) and tool.get("function_declarations")
+ for tool in tools
+ )
+ if not has_function_declarations:
+ return
+
+ has_search_tools = any(
+ isinstance(tool, dict) and any(key in tool for key in search_tool_keys)
+ for tool in tools
+ )
+ if not has_search_tools:
+ return
+
+ verbose_logger.warning(
+ "Vertex AI does not support mixing function declarations with "
+ "search tools (googleSearch, enterpriseWebSearch, urlContext, "
+ "googleSearchRetrieval) in the same request. Dropping search "
+ "tools and keeping function declarations. To use search tools, "
+ "send a request without function calling tools."
+ )
+ optional_params["tools"] = [
+ tool
+ for tool in tools
+ if not (
+ isinstance(tool, dict) and any(key in tool for key in search_tool_keys)
+ )
+ ]
+
def _map_service_tier_param(self, value: str, optional_params: dict) -> None:
"""
Map OpenAI service_tier (string) to Gemini serviceTier.
@@ -884,9 +961,10 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
GeminiThinkingConfig with thinkingLevel and includeThoughts
"""
# Check if this is gemini-3-flash which supports MINIMAL thinking level
- # Covers gemini-3-flash, gemini-3-flash-preview, gemini-3.1-flash, gemini-3.1-flash-lite-preview, etc.
+ # Covers gemini-3-flash, gemini-3-flash-preview, gemini-3.1-flash, gemini-3.1-flash-lite-preview,
+ # gemini-3.5-flash, and any future 3.x-flash variants.
is_gemini3flash = model and (
- "gemini-3-flash" in model.lower() or "gemini-3.1-flash" in model.lower()
+ "flash" in model.lower() and "gemini-3" in model.lower()
)
is_gemini31pro = model and ("gemini-3.1-pro-preview" in model.lower())
if reasoning_effort == "minimal":
@@ -982,8 +1060,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
# Follow provider defaults unless explicitly opted into legacy behavior.
if litellm.enable_gemini_default_thinking_level_low is True:
is_gemini3flash = (
- "gemini-3-flash-preview" in model.lower()
- or "gemini-3-flash" in model.lower()
+ "gemini-3" in model.lower() and "flash" in model.lower()
)
params["thinkingLevel"] = (
"minimal" if is_gemini3flash else "low"
@@ -1077,6 +1154,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
model: str,
drop_params: bool,
) -> Dict:
+ gemini_sampling_params_warned: bool = False
for param, value in non_default_params.items():
if param == "temperature":
if VertexGeminiConfig._is_gemini_3_or_newer(model):
@@ -1086,9 +1164,41 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
"can cause infinite loops, degraded reasoning performance, and failure on complex tasks. "
"Strongly recommended to use temperature = 1.0 (default)."
)
+ if not gemini_sampling_params_warned:
+ verbose_logger.warning(
+ "DeprecationWarning: `temperature`, `top_p`, and `top_k` continue to "
+ f"function for Gemini 3+ ({model}) but are planned for removal in a "
+ "future release. Move sampling guidance into the `system` "
+ "instructions instead."
+ )
+ gemini_sampling_params_warned = True
optional_params["temperature"] = value
elif param == "top_p":
+ if (
+ VertexGeminiConfig._is_gemini_3_or_newer(model)
+ and not gemini_sampling_params_warned
+ ):
+ verbose_logger.warning(
+ "DeprecationWarning: `temperature`, `top_p`, and `top_k` continue to "
+ f"function for Gemini 3+ ({model}) but are planned for removal in a "
+ "future release. Move sampling guidance into the `system` "
+ "instructions instead."
+ )
+ gemini_sampling_params_warned = True
optional_params["top_p"] = value
+ elif param == "top_k":
+ if (
+ VertexGeminiConfig._is_gemini_3_or_newer(model)
+ and not gemini_sampling_params_warned
+ ):
+ verbose_logger.warning(
+ "DeprecationWarning: `temperature`, `top_p`, and `top_k` continue to "
+ f"function for Gemini 3+ ({model}) but are planned for removal in a "
+ "future release. Move sampling guidance into the `system` "
+ "instructions instead."
+ )
+ gemini_sampling_params_warned = True
+ optional_params["top_k"] = value
elif (
param == "stream" and value is True
): # sending stream = False, can cause it to get passed unchecked and raise issues
@@ -1139,11 +1249,14 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
if _tool_choice_value is not None:
optional_params["tool_choice"] = _tool_choice_value
elif param == "parallel_tool_calls":
- if value is False and not (
- drop_params or litellm.drop_params
- ): # if drop params is True, then we should just ignore this
- self.validate_parallel_tool_calls(value, non_default_params)
- else:
+ tools_list = non_default_params.get(
+ "tools", non_default_params.get("functions")
+ )
+ num_tools = len(tools_list) if isinstance(tools_list, list) else 0
+ # Gemini does not support parallel_tool_calls=False with multiple
+ # tools. Drop the param instead of failing — Responses API clients
+ # often send parallel_tool_calls=false by default.
+ if not (value is False and num_tools > 1):
optional_params["parallel_tool_calls"] = value
elif param == "seed":
optional_params["seed"] = value
@@ -1216,6 +1329,8 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
if "temperature" not in optional_params:
optional_params["temperature"] = 1.0
+ self._drop_search_tools_mixed_with_functions(optional_params)
+
return optional_params
def get_mapped_special_auth_params(self) -> dict:
@@ -1588,6 +1703,10 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
}
# Extract thought signature if present
thought_signature = part.get("thoughtSignature")
+ # Gemini 3.5+ returns a stable `id` per function call to enable
+ # strict response matching. Preserve it as the OpenAI
+ # tool_call_id so it can be echoed back unchanged.
+ gemini_call_id = part["functionCall"].get("id")
if is_function_call is True:
function_dict: Dict[str, Any] = dict(_function_chunk)
@@ -1605,6 +1724,11 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
"function": _function_chunk,
"index": cumulative_tool_call_idx,
}
+ # Gemini 3.5+ returns a stable native `id`; prefer it over
+ # the synthetic call_ so the same value can be echoed
+ # back on the matching `functionResponse`.
+ if gemini_call_id:
+ _tool_response_chunk["id"] = gemini_call_id
# Embed thought signature in ID for OpenAI client compatibility
if thought_signature:
_tool_response_chunk["provider_specific_fields"] = { # type: ignore
@@ -2533,9 +2657,17 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
return model_response
def _transform_messages(
- self, messages: List[AllMessageValues], model: Optional[str] = None
+ self,
+ messages: List[AllMessageValues],
+ model: Optional[str] = None,
+ litellm_params: Optional[dict] = None,
) -> List[ContentType]:
- return _gemini_convert_messages_with_history(messages=messages, model=model)
+ return _gemini_convert_messages_with_history(
+ messages=messages,
+ model=model,
+ litellm_params=litellm_params,
+ custom_llm_provider="vertex_ai",
+ )
def get_error_class(
self, error_message: str, status_code: int, headers: Union[Dict, httpx.Headers]
@@ -3139,6 +3271,31 @@ class ModelResponseIterator:
self.cumulative_tool_call_index: int = 0
self.has_seen_tool_calls: bool = False
+ @staticmethod
+ def _check_streaming_error(chunk: dict) -> None:
+ """Detect embedded errors (e.g. 429 RESOURCE_EXHAUSTED) in streaming chunks and raise VertexAIError."""
+ if "error" not in chunk:
+ return
+ error_data = chunk["error"]
+ if not isinstance(error_data, dict):
+ raise VertexAIError(
+ status_code=500,
+ message=f"Unexpected error format in mid-stream chunk: {error_data}",
+ )
+ raw_code = error_data.get("code", 500)
+ if raw_code is None:
+ raw_code = 500
+ try:
+ error_code = int(raw_code)
+ except (TypeError, ValueError):
+ error_code = 500
+ error_message = error_data.get("message", "Unknown error")
+ error_status = error_data.get("status", "UNKNOWN")
+ raise VertexAIError(
+ status_code=error_code,
+ message=f"{error_status} - {error_message}",
+ )
+
def _apply_stream_candidates(
self,
_candidates: List[Candidates],
@@ -3256,6 +3413,11 @@ class ModelResponseIterator:
def chunk_parser(self, chunk: dict) -> Optional["ModelResponseStream"]:
try:
verbose_logger.debug(f"RAW GEMINI CHUNK: {chunk}")
+
+ # Detect mid-stream error chunks (e.g. 429 RESOURCE_EXHAUSTED).
+ # Vertex AI can return errors as HTTP 200 but with an "error" field in the SSE body.
+ self._check_streaming_error(chunk)
+
from litellm.types.utils import ModelResponseStream
processed_chunk = GenerateContentResponseBody(**chunk) # type: ignore
diff --git a/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_transformation.py b/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_transformation.py
index e1b365c9f42..ba6e6f0c056 100644
--- a/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_transformation.py
+++ b/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_transformation.py
@@ -1,5 +1,5 @@
"""
-Transformation logic from OpenAI /v1/embeddings format to Google AI Studio /batchEmbedContents format.
+Transformation logic from OpenAI /v1/embeddings format to Google AI Studio /batchEmbedContents format.
Why separate file? Make it easy to see how transformation works
"""
diff --git a/litellm/llms/vertex_ai/text_to_speech/text_to_speech_handler.py b/litellm/llms/vertex_ai/text_to_speech/text_to_speech_handler.py
index 9d9015c2b91..b835ad7d8fa 100644
--- a/litellm/llms/vertex_ai/text_to_speech/text_to_speech_handler.py
+++ b/litellm/llms/vertex_ai/text_to_speech/text_to_speech_handler.py
@@ -139,7 +139,7 @@ class VertexTextToSpeechAPI(VertexLLM):
########## End of logging ############
####### Send the request ###################
if _is_async is True:
- return self.async_audio_speech( # type:ignore
+ return self.async_audio_speech( # type: ignore
logging_obj=logging_obj, url=url, headers=headers, request=request
)
sync_handler = _get_httpx_client()
diff --git a/litellm/llms/vertex_ai/vertex_ai_partner_models/main.py b/litellm/llms/vertex_ai/vertex_ai_partner_models/main.py
index 123d925f7c1..13aa2a5350e 100644
--- a/litellm/llms/vertex_ai/vertex_ai_partner_models/main.py
+++ b/litellm/llms/vertex_ai/vertex_ai_partner_models/main.py
@@ -45,7 +45,7 @@ class PartnerModelPrefixes(str, Enum):
class VertexAIPartnerModels(VertexBase):
def __init__(self) -> None:
- pass
+ super().__init__()
@staticmethod
def is_vertex_partner_model(model: str):
@@ -116,9 +116,6 @@ class VertexAIPartnerModels(VertexBase):
CodestralTextCompletion,
)
from litellm.llms.openai_like.chat.handler import OpenAILikeChatHandler
- from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
- VertexLLM,
- )
except Exception as e:
raise VertexAIError(
status_code=400,
@@ -133,9 +130,7 @@ class VertexAIPartnerModels(VertexBase):
message="""Upgrade vertex ai. Run `pip install "google-cloud-aiplatform>=1.38"`""",
)
try:
- vertex_httpx_logic = VertexLLM()
-
- access_token, project_id = vertex_httpx_logic._ensure_access_token(
+ access_token, project_id = self._ensure_access_token(
credentials=vertex_credentials,
project_id=vertex_project,
custom_llm_provider="vertex_ai",
@@ -292,22 +287,15 @@ class VertexAIPartnerModels(VertexBase):
Returns:
Dict containing token count information
"""
- try:
- import vertexai
- except Exception as e:
- raise VertexAIError(
- status_code=400,
- message=f"""vertexai import failed please run `pip install -U "google-cloud-aiplatform>=1.38"`. Got error: {e}""",
- )
-
- if not (
- hasattr(vertexai, "preview") or hasattr(vertexai.preview, "language_models")
- ):
- raise VertexAIError(
- status_code=400,
- message="""Upgrade vertex ai. Run `pip install "google-cloud-aiplatform>=1.38"`""",
- )
-
+ # Note: we intentionally do not import `vertexai` (the Gemini SDK shipped
+ # by `google-cloud-aiplatform`) on this path. Partner models such as
+ # Claude on Vertex use the Anthropic Messages API protocol directly via
+ # `:rawPredict`, and `VertexAIPartnerModelsTokenCounter` reaches that
+ # endpoint with an authenticated httpx client — it never touches the
+ # Gemini SDK. Requiring `google-cloud-aiplatform>=1.38` here turned a
+ # SDK-free Anthropic-protocol call into a hard dependency on the Gemini
+ # SDK (see #28084), breaking `/v1/messages/count_tokens` for Claude-on-
+ # Vertex on any LiteLLM install without that extra. Stay SDK-free.
try:
from litellm.llms.vertex_ai.vertex_ai_partner_models.count_tokens.handler import (
VertexAIPartnerModelsTokenCounter,
diff --git a/litellm/llms/vertex_ai/vertex_gemma_models/main.py b/litellm/llms/vertex_ai/vertex_gemma_models/main.py
index 82cfe6de984..b6bf2f73b72 100644
--- a/litellm/llms/vertex_ai/vertex_gemma_models/main.py
+++ b/litellm/llms/vertex_ai/vertex_gemma_models/main.py
@@ -31,7 +31,7 @@ from ..vertex_llm_base import VertexBase
class VertexAIGemmaModels(VertexBase):
def __init__(self) -> None:
- pass
+ super().__init__()
def completion(
self,
@@ -62,9 +62,6 @@ class VertexAIGemmaModels(VertexBase):
try:
import vertexai
- from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
- VertexLLM,
- )
from litellm.llms.vertex_ai.vertex_gemma_models.transformation import (
VertexGemmaConfig,
)
@@ -83,9 +80,8 @@ class VertexAIGemmaModels(VertexBase):
)
try:
model = get_vertex_base_model_name(model=model)
- vertex_httpx_logic = VertexLLM()
- access_token, project_id = vertex_httpx_logic._ensure_access_token(
+ access_token, project_id = self._ensure_access_token(
credentials=vertex_credentials,
project_id=vertex_project,
custom_llm_provider="vertex_ai",
diff --git a/litellm/llms/vertex_ai/vertex_gemma_models/transformation.py b/litellm/llms/vertex_ai/vertex_gemma_models/transformation.py
index 6c6446958bc..35cd54d65f6 100644
--- a/litellm/llms/vertex_ai/vertex_gemma_models/transformation.py
+++ b/litellm/llms/vertex_ai/vertex_gemma_models/transformation.py
@@ -91,6 +91,10 @@ class VertexGemmaConfig(OpenAIGPTConfig):
"stream", None
) # Streaming not supported, will be faked client-side
openai_request.pop("stream_options", None) # Stream options not supported
+ # Vertex Gemma's chatCompletions wrapper does not understand
+ # `context_management` (an Anthropic/Responses API concept). Strip it
+ # so the upstream endpoint does not 400 on the unknown field.
+ openai_request.pop("context_management", None)
# Wrap in Vertex Gemma format
return {
diff --git a/litellm/llms/vertex_ai/vertex_llm_base.py b/litellm/llms/vertex_ai/vertex_llm_base.py
index 6f687dae7e8..990063bb9fb 100644
--- a/litellm/llms/vertex_ai/vertex_llm_base.py
+++ b/litellm/llms/vertex_ai/vertex_llm_base.py
@@ -4,8 +4,10 @@ Base Vertex, Google AI Studio LLM Class
Handles Authentication and generating request urls for Vertex AI and Google AI Studio
"""
+import asyncio
import json
import os
+import threading
from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Tuple
import litellm
@@ -30,6 +32,7 @@ GOOGLE_IMPORT_ERROR_MESSAGE = (
if TYPE_CHECKING:
from google.auth.credentials import Credentials as GoogleCredentialsObject
+ from google.auth.credentials import TokenState
else:
GoogleCredentialsObject = Any
@@ -42,10 +45,28 @@ class VertexBase:
self._credentials: Optional[GoogleCredentialsObject] = None
self._credentials_project_mapping: Dict[
Tuple[Optional[VERTEX_CREDENTIALS_TYPES], Optional[str]],
- Tuple[GoogleCredentialsObject, str],
+ Tuple[GoogleCredentialsObject, Optional[str]],
] = {}
self.project_id: Optional[str] = None
self.async_handler: Optional[AsyncHTTPHandler] = None
+ # Per-credential-key asyncio.Lock for single-flight async refresh.
+ # Prevents thundering herd when token expires under high concurrency.
+ # Uses a regular dict (not WeakValueDictionary) so the lock identity is
+ # stable across concurrent callers — a weak reference can be GC'd
+ # between two coroutines arriving at the lock, breaking single-flight.
+ # An explicit refcount tracks the number of coroutines currently using
+ # each lock; the entry is pruned when the count reaches zero, so the
+ # dict stays bounded even in long-running high-cardinality deployments
+ # without depending on any private asyncio internals.
+ self._async_refresh_locks: Dict[tuple, asyncio.Lock] = {}
+ self._async_refresh_lock_refcounts: Dict[tuple, int] = {}
+ # Tracks in-flight background refresh tasks to avoid duplicate refreshes.
+ self._background_refresh_tasks: Dict[tuple, asyncio.Task] = {}
+ # Protects the sync get_access_token refresh path.
+ # Use RLock so that the reauthentication retry path (which calls
+ # back into get_access_token while still holding the lock) can
+ # re-acquire it without deadlocking the current thread.
+ self._sync_refresh_lock = threading.RLock()
def get_vertex_region(self, vertex_region: Optional[str], model: str) -> str:
import litellm
@@ -77,7 +98,9 @@ class VertexBase:
return vertex_region or "us-central1"
def load_auth(
- self, credentials: Optional[VERTEX_CREDENTIALS_TYPES], project_id: Optional[str]
+ self,
+ credentials: Optional[VERTEX_CREDENTIALS_TYPES],
+ project_id: Optional[str],
) -> Tuple[Any, str]:
if credentials is not None:
if isinstance(credentials, str):
@@ -343,7 +366,241 @@ class VertexBase:
except ImportError:
raise ImportError(GOOGLE_IMPORT_ERROR_MESSAGE)
- credentials.refresh(Request())
+ # Serialize all refreshes on this VertexBase across threads.
+ # ``credentials.refresh()`` is not safe to call concurrently on the
+ # same credentials object, and this method is invoked from three
+ # places that can run on different threads:
+ # - sync ``get_access_token`` (already holds ``_sync_refresh_lock``)
+ # - the async slow path (via ``asyncify`` in a worker thread)
+ # - the background proactive refresh task (via ``asyncify``)
+ # ``_sync_refresh_lock`` is an ``RLock`` so reentrant acquisition
+ # from the sync path is safe.
+ with self._sync_refresh_lock:
+ credentials.refresh(Request())
+
+ def _acquire_async_refresh_lock(self, credential_cache_key: tuple) -> asyncio.Lock:
+ """Increment the refcount and return the lock for ``credential_cache_key``.
+
+ Every call must be paired with ``_release_async_refresh_lock`` once the
+ caller is done with the lock so the entry can be pruned when no other
+ coroutine is holding or waiting on it.
+ """
+ lock = self._async_refresh_locks.setdefault(
+ credential_cache_key, asyncio.Lock()
+ )
+ self._async_refresh_lock_refcounts[credential_cache_key] = (
+ self._async_refresh_lock_refcounts.get(credential_cache_key, 0) + 1
+ )
+ return lock
+
+ def _release_async_refresh_lock(
+ self, credential_cache_key: tuple, lock: asyncio.Lock
+ ) -> None:
+ """Decrement the refcount and drop the lock entry when it reaches zero.
+
+ Must be called only after the caller has released ``lock`` (i.e. once
+ the surrounding ``async with`` has exited). asyncio is cooperative, so
+ the decrement-then-pop sequence below runs atomically with respect to
+ other coroutines.
+ """
+ remaining = self._async_refresh_lock_refcounts.get(credential_cache_key, 0) - 1
+ if remaining > 0:
+ self._async_refresh_lock_refcounts[credential_cache_key] = remaining
+ return
+ self._async_refresh_lock_refcounts.pop(credential_cache_key, None)
+ if self._async_refresh_locks.get(credential_cache_key) is lock:
+ self._async_refresh_locks.pop(credential_cache_key, None)
+
+ def _try_get_cached_token(
+ self,
+ credential_cache_key: tuple,
+ project_id: Optional[str],
+ ) -> Optional[Tuple[str, str]]:
+ """
+ Look up cached credentials and return (token, project_id) if the token
+ is FRESH. Returns None if not cached or not fresh.
+ """
+ from google.auth.credentials import TokenState
+
+ creds, cached_project_id = self._unpack_cached_credentials(credential_cache_key)
+ if (
+ creds is not None
+ and self._get_token_state(creds) == TokenState.FRESH
+ and creds.token is not None
+ and isinstance(creds.token, str)
+ ):
+ resolved_project = project_id or cached_project_id
+ if resolved_project:
+ return creds.token, resolved_project
+ return None
+
+ def _try_get_usable_cached_token(
+ self,
+ credential_cache_key: tuple,
+ project_id: Optional[str],
+ ) -> Optional[Tuple[str, str, "TokenState", Any, Optional[str]]]:
+ """
+ Look up cached credentials and return usable token info for FRESH or
+ STALE tokens (both are still valid for outbound requests). STALE
+ tokens are returned along with their state and the underlying
+ credentials object so the caller can schedule a background refresh
+ without holding the per-key async lock.
+ """
+ from google.auth.credentials import TokenState
+
+ creds, cached_project_id = self._unpack_cached_credentials(credential_cache_key)
+ if creds is None:
+ return None
+ token_state = self._get_token_state(creds)
+ if token_state not in (TokenState.FRESH, TokenState.STALE):
+ return None
+ if creds.token is None or not isinstance(creds.token, str):
+ return None
+ resolved_project = project_id or cached_project_id
+ if not resolved_project:
+ return None
+ return creds.token, resolved_project, token_state, creds, cached_project_id
+
+ def _unpack_cached_credentials(
+ self, credential_cache_key: tuple
+ ) -> Tuple[Any, Optional[str]]:
+ """
+ Return (credentials, project_id) from the cache, or (None, None) if
+ not cached. Handles both tuple and legacy cache formats.
+ """
+ if credential_cache_key not in self._credentials_project_mapping:
+ return None, None
+ cached_entry = self._credentials_project_mapping[credential_cache_key]
+ if isinstance(cached_entry, tuple):
+ return cached_entry
+ return cached_entry, cached_entry.quota_project_id or getattr(
+ cached_entry, "project_id", None
+ )
+
+ def _get_token_state(self, credentials: Any) -> "TokenState":
+ """
+ Return the token state using google-auth's TokenState enum.
+
+ Falls back to expired/valid checks if token_state is unavailable
+ (e.g. older google-auth versions or mock objects in tests).
+ """
+ from google.auth.credentials import TokenState as _TokenState
+
+ token_state = getattr(credentials, "token_state", None)
+ if isinstance(token_state, _TokenState):
+ return token_state
+ # Fallback for credentials without a real token_state (e.g. mocks)
+ if getattr(credentials, "expired", True):
+ return _TokenState.INVALID
+ if getattr(credentials, "valid", False):
+ return _TokenState.FRESH
+ return _TokenState.INVALID
+
+ async def _load_and_cache_credentials(
+ self,
+ credentials: Optional[VERTEX_CREDENTIALS_TYPES],
+ project_id: Optional[str],
+ credential_cache_key: tuple,
+ ) -> Tuple[Any, Optional[str]]:
+ """Load credentials via load_auth (in thread) and cache the result."""
+ try:
+ _credentials, credential_project_id = await asyncify(self.load_auth)(
+ credentials=credentials,
+ project_id=project_id,
+ )
+ except Exception as e:
+ verbose_logger.exception("Failed to load vertex credentials: %s", str(e))
+ raise
+ if _credentials is None:
+ raise ValueError("Could not resolve credentials")
+ self._credentials_project_mapping[credential_cache_key] = (
+ _credentials,
+ credential_project_id,
+ )
+ return _credentials, credential_project_id
+
+ async def _background_refresh_credentials(
+ self,
+ credentials: Any,
+ credential_cache_key: tuple,
+ credential_project_id: Optional[str],
+ ) -> None:
+ """
+ Refresh credentials in the background without blocking the calling request.
+
+ Called when the token is still valid but nearing expiry (proactive refresh).
+ Errors are logged but not raised — the current token is still usable.
+ """
+ try:
+ verbose_logger.debug("Background proactive credential refresh")
+ await asyncify(self.refresh_auth)(credentials)
+ # Only update the cache if it still points at the credentials
+ # object we just refreshed. The per-key async lock is not held
+ # here, so a concurrent INVALID path may have already replaced
+ # this entry (e.g. via _handle_reauthentication_async, which
+ # creates a fresh credentials object). In that case our write
+ # would clobber the newer entry with a stale reference.
+ cached_creds, _ = self._unpack_cached_credentials(credential_cache_key)
+ if cached_creds is credentials:
+ self._credentials_project_mapping[credential_cache_key] = (
+ credentials,
+ credential_project_id,
+ )
+ except Exception:
+ verbose_logger.debug(
+ "Background credential refresh failed, will retry on next request",
+ exc_info=True,
+ )
+
+ async def _await_in_flight_background_refresh(
+ self, credential_cache_key: tuple
+ ) -> None:
+ """Wait for an in-flight background refresh to finish, if any.
+
+ google-auth's ``Credentials.refresh()`` is not safe to invoke
+ concurrently on the same credentials object. Coroutines that need a
+ blocking refresh must first drain any background refresh that was
+ scheduled while a previous STALE token was being served.
+ """
+ existing_task = self._background_refresh_tasks.get(credential_cache_key)
+ if existing_task is None or existing_task.done():
+ return
+ try:
+ await existing_task
+ except Exception:
+ # Background refresh failures are already logged inside
+ # _background_refresh_credentials; the caller will fall through
+ # to its own blocking refresh.
+ pass
+
+ def _schedule_background_refresh(
+ self,
+ credentials: Any,
+ credential_cache_key: tuple,
+ credential_project_id: Optional[str],
+ ) -> None:
+ """Kick off a single background refresh for ``credential_cache_key``.
+
+ Skips scheduling if a refresh is already in flight. The done-callback
+ guards against removing a newer task that has replaced this one in the
+ tracking dict (done_callbacks are scheduled via ``call_soon``).
+ """
+ existing = self._background_refresh_tasks.get(credential_cache_key)
+ if existing is not None and not existing.done():
+ return
+ self._background_refresh_tasks.pop(credential_cache_key, None)
+ task = asyncio.create_task(
+ self._background_refresh_credentials(
+ credentials, credential_cache_key, credential_project_id
+ )
+ )
+
+ def _drop_background_refresh_task(_fut: asyncio.Future[Any]) -> None:
+ if self._background_refresh_tasks.get(credential_cache_key) is _fut:
+ self._background_refresh_tasks.pop(credential_cache_key, None)
+
+ task.add_done_callback(_drop_background_refresh_task)
+ self._background_refresh_tasks[credential_cache_key] = task
def _ensure_access_token(
self,
@@ -563,6 +820,65 @@ class VertexBase:
# Re-raise the original error for better context
raise error
+ async def _handle_reauthentication_async(
+ self,
+ credentials: Optional[VERTEX_CREDENTIALS_TYPES],
+ project_id: Optional[str],
+ credential_cache_key: Tuple,
+ error: Exception,
+ ) -> Tuple[str, str]:
+ """
+ Async reauthentication retry that stays within the per-key async lock.
+ """
+ verbose_logger.debug(
+ f"Handling async reauthentication for project_id: {project_id}. "
+ f"Clearing cache and retrying once."
+ )
+
+ self._credentials_project_mapping.pop(credential_cache_key, None)
+
+ try:
+ _credentials, credential_project_id = (
+ await self._load_and_cache_credentials(
+ credentials=credentials,
+ project_id=project_id,
+ credential_cache_key=credential_cache_key,
+ )
+ )
+ if project_id is None and isinstance(credential_project_id, str):
+ project_id = credential_project_id
+ cache_credentials = (
+ json.dumps(credentials)
+ if isinstance(credentials, dict)
+ else credentials
+ )
+ resolved_cache_key = (cache_credentials, project_id)
+ # Always overwrite — any pre-existing entry at the resolved key
+ # references the OLD credentials object we just replaced, and
+ # leaving it would force the next request to do a redundant
+ # refresh/reauth before realizing the cached creds are stale.
+ self._credentials_project_mapping[resolved_cache_key] = (
+ _credentials,
+ credential_project_id,
+ )
+
+ if _credentials.token is None or not isinstance(_credentials.token, str):
+ raise ValueError(
+ "Could not resolve credentials token. Got None or non-string token (type={})".format(
+ type(_credentials.token).__name__
+ )
+ )
+ if project_id is None:
+ raise ValueError("Could not resolve project_id")
+
+ return _credentials.token, project_id
+ except Exception as retry_error:
+ verbose_logger.error(
+ f"Async reauthentication retry failed for project_id: {project_id}. "
+ f"Original error: {str(error)}. Retry error: {str(retry_error)}"
+ )
+ raise error
+
def get_access_token(
self,
credentials: Optional[VERTEX_CREDENTIALS_TYPES],
@@ -646,7 +962,7 @@ class VertexBase:
)
## VALIDATE CREDENTIALS
- verbose_logger.debug(f"Validating credentials for project_id: {project_id}")
+ verbose_logger.debug("Validating credentials")
if (
project_id is None
and credential_project_id is not None
@@ -666,26 +982,27 @@ class VertexBase:
raise ValueError("Credentials are None after loading")
if _credentials.expired:
- try:
- verbose_logger.debug(
- f"Credentials expired, refreshing for project_id: {project_id}"
- )
- self.refresh_auth(_credentials)
- self._credentials_project_mapping[credential_cache_key] = (
- _credentials,
- credential_project_id,
- )
- except Exception as e:
- # if refresh fails, it's possible the user has re-authenticated via `gcloud auth application-default login`
- # in this case, we should try to reload the credentials by clearing the cache and retrying
- if "Reauthentication is needed" in str(e) and not _retry_reauth:
- return self._handle_reauthentication(
- credentials=credentials,
- project_id=project_id,
- credential_cache_key=credential_cache_key,
- error=e,
- )
- raise e
+ with self._sync_refresh_lock:
+ # Double-check after acquiring lock
+ if _credentials.expired:
+ try:
+ verbose_logger.debug("Credentials expired, refreshing")
+ self.refresh_auth(_credentials)
+ self._credentials_project_mapping[credential_cache_key] = (
+ _credentials,
+ credential_project_id,
+ )
+ except Exception as e:
+ # if refresh fails, it's possible the user has re-authenticated via `gcloud auth application-default login`
+ # in this case, we should try to reload the credentials by clearing the cache and retrying
+ if "Reauthentication is needed" in str(e) and not _retry_reauth:
+ return self._handle_reauthentication(
+ credentials=credentials,
+ project_id=project_id,
+ credential_cache_key=credential_cache_key,
+ error=e,
+ )
+ raise e
## VALIDATION STEP
if _credentials.token is None or not isinstance(_credentials.token, str):
@@ -700,6 +1017,149 @@ class VertexBase:
return _credentials.token, project_id
+ async def get_access_token_async(
+ self,
+ credentials: Optional[VERTEX_CREDENTIALS_TYPES],
+ project_id: Optional[str],
+ ) -> Tuple[str, str]:
+ """
+ Async version of get_access_token with single-flight refresh coordination.
+
+ Prevents thundering herd: when credentials expire under high concurrency,
+ only one coroutine refreshes while others wait on the lock. Uses native
+ async refresh for service_account and authorized_user credentials.
+ """
+ from google.auth.credentials import TokenState
+
+ cache_credentials = (
+ json.dumps(credentials) if isinstance(credentials, dict) else credentials
+ )
+ credential_cache_key = (cache_credentials, project_id)
+
+ # === FAST PATH (no lock) ===
+ # If credentials are FRESH or STALE, return immediately without
+ # touching the per-key async lock. STALE tokens are still usable;
+ # we kick off a deduplicated background refresh so subsequent
+ # requests get a fresh token, but we must not serialize concurrent
+ # callers on the lock just to schedule that refresh.
+ usable = self._try_get_usable_cached_token(credential_cache_key, project_id)
+ if usable is not None:
+ cached_token, resolved_project, token_state, creds, cached_project_id = (
+ usable
+ )
+ if token_state == TokenState.STALE:
+ self._schedule_background_refresh(
+ creds, credential_cache_key, cached_project_id
+ )
+ return cached_token, resolved_project
+
+ # === SLOW PATH (per-key lock) ===
+ lock = self._acquire_async_refresh_lock(credential_cache_key)
+ try:
+ async with lock:
+ # Double-check after acquiring lock — another coroutine may have refreshed.
+ cached = self._try_get_cached_token(credential_cache_key, project_id)
+ if cached is not None:
+ return cached
+
+ _credentials, credential_project_id = self._unpack_cached_credentials(
+ credential_cache_key
+ )
+
+ # Load credentials if not cached
+ if _credentials is None:
+ _credentials, credential_project_id = (
+ await self._load_and_cache_credentials(
+ credentials, project_id, credential_cache_key
+ )
+ )
+
+ # Resolve project_id from credentials if not provided
+ if project_id is None and isinstance(credential_project_id, str):
+ project_id = credential_project_id
+ resolved_cache_key = (cache_credentials, project_id)
+ # Always overwrite — a pre-existing entry at the resolved
+ # key may reference stale credentials (e.g. from before a
+ # reauth that only repopulated the unresolved key), which
+ # would force the next request through an unnecessary
+ # refresh/reauth cycle.
+ self._credentials_project_mapping[resolved_cache_key] = (
+ _credentials,
+ credential_project_id,
+ )
+
+ # Use google-auth's token_state to decide refresh strategy:
+ # - STALE: token is usable but within REFRESH_THRESHOLD (3:45) of
+ # expiry — return it immediately and refresh in the background.
+ # - INVALID: token is expired or missing — must block on refresh.
+ token_state = self._get_token_state(_credentials)
+
+ if token_state == TokenState.STALE:
+ if project_id is None:
+ raise ValueError("Could not resolve project_id")
+ current_token = _credentials.token
+ if current_token is None or not isinstance(current_token, str):
+ # Token is malformed despite STALE state — block on a full
+ # refresh using the same path as INVALID credentials.
+ token_state = TokenState.INVALID
+ else:
+ self._schedule_background_refresh(
+ _credentials,
+ credential_cache_key,
+ credential_project_id,
+ )
+ return current_token, project_id
+
+ if token_state == TokenState.INVALID:
+ # Drain any in-flight background refresh before invoking
+ # refresh_auth ourselves; google-auth's
+ # Credentials.refresh() is not safe to call concurrently
+ # on the same credentials object, and the background task
+ # runs outside this lock.
+ await self._await_in_flight_background_refresh(credential_cache_key)
+ cached = self._try_get_cached_token(
+ credential_cache_key, project_id
+ )
+ if cached is not None:
+ return cached
+
+ # Token is expired or missing — must block until refresh completes.
+ try:
+ verbose_logger.debug("Credentials expired, refreshing")
+ await asyncify(self.refresh_auth)(_credentials)
+ self._credentials_project_mapping[credential_cache_key] = (
+ _credentials,
+ credential_project_id,
+ )
+ except Exception as e:
+ if "Reauthentication is needed" in str(e):
+ verbose_logger.debug(
+ "Reauthentication needed, clearing cache and retrying"
+ )
+ return await self._handle_reauthentication_async(
+ credentials=credentials,
+ project_id=project_id,
+ credential_cache_key=credential_cache_key,
+ error=e,
+ )
+ raise
+
+ # Final validation
+ if _credentials.token is None or not isinstance(
+ _credentials.token, str
+ ):
+ raise ValueError(
+ "Could not resolve credentials token. Got None or non-string token (type={})".format(
+ type(_credentials.token).__name__
+ )
+ )
+ if project_id is None:
+ raise ValueError("Could not resolve project_id")
+
+ return _credentials.token, project_id
+ finally:
+ self._release_async_refresh_lock(credential_cache_key, lock)
+
async def _ensure_access_token_async(
self,
credentials: Optional[VERTEX_CREDENTIALS_TYPES],
@@ -714,13 +1174,10 @@ class VertexBase:
if custom_llm_provider == "gemini":
return "", ""
else:
- try:
- return await asyncify(self.get_access_token)(
- credentials=credentials,
- project_id=project_id,
- )
- except Exception as e:
- raise e
+ return await self.get_access_token_async(
+ credentials=credentials,
+ project_id=project_id,
+ )
def set_headers(
self, auth_header: Optional[str], extra_headers: Optional[dict]
diff --git a/litellm/llms/vertex_ai/vertex_model_garden/main.py b/litellm/llms/vertex_ai/vertex_model_garden/main.py
index 7240d9dce57..732d5f90dc2 100644
--- a/litellm/llms/vertex_ai/vertex_model_garden/main.py
+++ b/litellm/llms/vertex_ai/vertex_model_garden/main.py
@@ -57,7 +57,7 @@ def create_vertex_url(
class VertexAIModelGardenModels(VertexBase):
def __init__(self) -> None:
- pass
+ super().__init__()
def completion(
self,
@@ -89,9 +89,6 @@ class VertexAIModelGardenModels(VertexBase):
import vertexai
from litellm.llms.openai_like.chat.handler import OpenAILikeChatHandler
- from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
- VertexLLM,
- )
except Exception as e:
raise VertexAIError(
status_code=400,
@@ -107,9 +104,8 @@ class VertexAIModelGardenModels(VertexBase):
)
try:
model = get_vertex_base_model_name(model=model)
- vertex_httpx_logic = VertexLLM()
- access_token, project_id = vertex_httpx_logic._ensure_access_token(
+ access_token, project_id = self._ensure_access_token(
credentials=vertex_credentials,
project_id=vertex_project,
custom_llm_provider="vertex_ai",
diff --git a/litellm/llms/vllm/completion/transformation.py b/litellm/llms/vllm/completion/transformation.py
index ec4c07e95d8..e03b07f9897 100644
--- a/litellm/llms/vllm/completion/transformation.py
+++ b/litellm/llms/vllm/completion/transformation.py
@@ -1,5 +1,5 @@
"""
-Translates from OpenAI's `/v1/chat/completions` to the VLLM sdk `llm.generate`.
+Translates from OpenAI's `/v1/chat/completions` to the VLLM sdk `llm.generate`.
NOT RECOMMENDED FOR PRODUCTION USE. Use `hosted_vllm/` instead.
"""
diff --git a/litellm/llms/voyage/embedding/transformation_contextual.py b/litellm/llms/voyage/embedding/transformation_contextual.py
index 40328062e09..1f5ca99f47d 100644
--- a/litellm/llms/voyage/embedding/transformation_contextual.py
+++ b/litellm/llms/voyage/embedding/transformation_contextual.py
@@ -1,6 +1,6 @@
"""
-This module is used to transform the request and response for the Voyage contextualized embeddings API.
-This would be used for all the contextualized embeddings models in Voyage.
+This module is used to transform the request and response for the Voyage contextualized embeddings API.
+This would be used for all the contextualized embeddings models in Voyage.
"""
from typing import List, Optional, Union
diff --git a/litellm/llms/xai/chat/transformation.py b/litellm/llms/xai/chat/transformation.py
index 6300868a641..7325c0596a6 100644
--- a/litellm/llms/xai/chat/transformation.py
+++ b/litellm/llms/xai/chat/transformation.py
@@ -1,4 +1,4 @@
-from typing import Any, AsyncIterator, Iterator, List, Optional, Tuple, Union
+from typing import Any, AsyncIterator, Dict, Iterator, List, Optional, Tuple, Union
import httpx
@@ -26,6 +26,7 @@ from ...openai.chat.gpt_transformation import (
class XAIChatConfig(OpenAIGPTConfig):
+
@property
def custom_llm_provider(self) -> Optional[str]:
return "xai"
@@ -225,21 +226,57 @@ class XAIChatConfig(OpenAIGPTConfig):
verbose_logger.debug(f"Error extracting X.AI web search usage: {e}")
self._fold_reasoning_tokens_into_completion(response)
+ self._normalize_openai_compatible_usage_totals(getattr(response, "usage", None))
return response
@staticmethod
- def _fold_reasoning_tokens_into_completion(model_response: ModelResponse) -> None:
+ def _fold_reasoning_tokens_into_completion(
+ target: Union[ModelResponse, Usage, Dict[str, Any], None],
+ ) -> None:
"""Reconcile xAI Usage to the OpenAI invariant.
xAI accounts ``reasoning_tokens`` separately from
``completion_tokens`` while still summing them into ``total_tokens``.
OpenAI's contract (o1/o3) folds reasoning into ``completion_tokens``,
so fold here to keep ``total = prompt + completion``. Idempotent.
+
+ Accepts a ``ModelResponse`` (non-streaming), a ``Usage`` object, or a
+ raw usage ``dict`` (streaming chunk) so streaming and non-streaming
+ paths stay in sync.
"""
- usage = getattr(model_response, "usage", None)
+ if target is None:
+ return
+
+ if isinstance(target, ModelResponse):
+ usage: Union[Usage, Dict[str, Any], None] = getattr(target, "usage", None)
+ else:
+ usage = target
if usage is None:
return
+ if isinstance(usage, dict):
+ details = usage.get("completion_tokens_details") or {}
+ if isinstance(details, dict):
+ reasoning_tokens = int(details.get("reasoning_tokens") or 0)
+ else:
+ reasoning_tokens = int(getattr(details, "reasoning_tokens", 0) or 0)
+ if reasoning_tokens <= 0:
+ return
+
+ prompt_tokens = int(usage.get("prompt_tokens") or 0)
+ completion_tokens = int(usage.get("completion_tokens") or 0)
+ total_tokens = int(usage.get("total_tokens") or 0)
+
+ if total_tokens == prompt_tokens + completion_tokens:
+ return
+
+ # Guard against double-counting if xAI changes accounting.
+ if total_tokens != prompt_tokens + completion_tokens + reasoning_tokens:
+ return
+
+ usage["completion_tokens"] = completion_tokens + reasoning_tokens
+ return
+
details = getattr(usage, "completion_tokens_details", None)
reasoning_tokens = (
int(getattr(details, "reasoning_tokens", 0) or 0) if details else 0
@@ -284,6 +321,25 @@ class XAIChatConfig(OpenAIGPTConfig):
setattr(usage, "num_sources_used", int(num_sources_used))
verbose_logger.debug(f"X.AI web search sources used: {num_sources_used}")
+ @staticmethod
+ def _normalize_openai_compatible_usage_totals(
+ usage: Union[Usage, Dict[str, Any], None],
+ ) -> None:
+ if usage is None:
+ return
+ if isinstance(usage, dict):
+ prompt_tokens = int(usage.get("prompt_tokens") or 0)
+ completion_tokens = int(usage.get("completion_tokens") or 0)
+ expected_total = prompt_tokens + completion_tokens
+ if int(usage.get("total_tokens") or 0) < expected_total:
+ usage["total_tokens"] = expected_total
+ return
+ prompt_tokens = int(usage.prompt_tokens or 0)
+ completion_tokens = int(usage.completion_tokens or 0)
+ expected_total = prompt_tokens + completion_tokens
+ if int(usage.total_tokens or 0) < expected_total:
+ usage.total_tokens = expected_total
+
class XAIChatCompletionStreamingHandler(OpenAIChatCompletionStreamingHandler):
def chunk_parser(self, chunk: dict) -> ModelResponseStream:
@@ -304,4 +360,8 @@ class XAIChatCompletionStreamingHandler(OpenAIChatCompletionStreamingHandler):
# Add a dummy choice with empty delta to ensure proper processing
chunk["choices"] = [{"index": 0, "delta": {}, "finish_reason": None}]
+ if "usage" in chunk and chunk["usage"] is not None:
+ XAIChatConfig._fold_reasoning_tokens_into_completion(chunk["usage"])
+ XAIChatConfig._normalize_openai_compatible_usage_totals(chunk["usage"])
+
return super().chunk_parser(chunk)
diff --git a/litellm/main.py b/litellm/main.py
index c3d1c2e05b0..09c70998cf7 100644
--- a/litellm/main.py
+++ b/litellm/main.py
@@ -1491,7 +1491,9 @@ def completion( # type: ignore # noqa: PLR0915
provider.value for provider in LlmProviders
]:
provider_config = ProviderConfigManager.get_provider_chat_config(
- model=model, provider=LlmProviders(custom_llm_provider)
+ model=model,
+ provider=LlmProviders(custom_llm_provider),
+ base_model=base_model,
)
if provider_config is not None:
@@ -1550,6 +1552,7 @@ def completion( # type: ignore # noqa: PLR0915
"safety_identifier": safety_identifier,
"service_tier": service_tier,
"allowed_openai_params": kwargs.get("allowed_openai_params"),
+ "base_model": base_model,
}
optional_params = get_optional_params(
**optional_param_args, **non_default_params
@@ -1670,6 +1673,10 @@ def completion( # type: ignore # noqa: PLR0915
reasoning_summary=_reasoning_summary_for_bridge,
)
+ # Use base_model (the true underlying model) for Azure model-type
+ # detection when the deployment name differs from the model name.
+ _azure_detection_model = base_model or model
+
if responses_api_model_info.get("mode") == "responses":
from litellm.completion_extras import responses_api_bridge
@@ -1713,7 +1720,9 @@ def completion( # type: ignore # noqa: PLR0915
and OpenAIGPT5Config.is_model_gpt_5_model(model)
) or (
custom_llm_provider == "azure"
- and litellm.AzureOpenAIGPT5Config.is_model_gpt_5_model(model)
+ and litellm.AzureOpenAIGPT5Config.is_model_gpt_5_model(
+ _azure_detection_model
+ )
):
optional_params, _ = strip_reasoning_summary_aliases_from_optional_params(
optional_params
@@ -1766,7 +1775,9 @@ def completion( # type: ignore # noqa: PLR0915
if max_retries is not None:
optional_params["max_retries"] = max_retries
- if litellm.AzureOpenAIO1Config().is_o_series_model(model=model):
+ 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():
@@ -5116,6 +5127,24 @@ def embedding( # noqa: PLR0915
client=client,
aembedding=aembedding,
)
+ elif custom_llm_provider == "oci":
+ if headers is None:
+ headers = {}
+ response = base_llm_http_handler.embedding(
+ model=model,
+ input=input,
+ custom_llm_provider=custom_llm_provider,
+ api_base=api_base,
+ api_key=api_key,
+ logging_obj=logging,
+ timeout=timeout,
+ model_response=EmbeddingResponse(),
+ optional_params=optional_params,
+ client=client,
+ aembedding=aembedding,
+ litellm_params=litellm_params_dict,
+ headers=headers,
+ )
elif custom_llm_provider == "cohere" or custom_llm_provider == "cohere_chat":
cohere_key = (
api_key
@@ -5720,6 +5749,33 @@ def embedding( # noqa: PLR0915
aembedding=aembedding,
headers=headers,
)
+ elif custom_llm_provider == "dashscope":
+ dashscope_key = (
+ api_key or litellm.api_key or get_secret_str("DASHSCOPE_API_KEY")
+ )
+ if dashscope_key is None:
+ raise ValueError(
+ "Missing API key for DashScope. Set DASHSCOPE_API_KEY environment variable or pass api_key parameter."
+ )
+ if extra_headers is not None and isinstance(extra_headers, dict):
+ headers = extra_headers
+ else:
+ headers = {}
+ response = base_llm_http_handler.embedding(
+ model=model,
+ input=input,
+ timeout=timeout,
+ custom_llm_provider=custom_llm_provider,
+ logging_obj=logging,
+ api_base=api_base,
+ optional_params=optional_params,
+ litellm_params={},
+ model_response=EmbeddingResponse(),
+ api_key=dashscope_key,
+ client=client,
+ aembedding=aembedding,
+ headers=headers,
+ )
elif custom_llm_provider == "ovhcloud":
api_key = api_key or litellm.api_key or get_secret_str("OVHCLOUD_API_KEY")
api_base = (
@@ -5769,22 +5825,6 @@ def embedding( # noqa: PLR0915
aembedding=aembedding,
litellm_params={},
)
- elif custom_llm_provider == "oci":
- response = base_llm_http_handler.embedding(
- model=model,
- input=input,
- custom_llm_provider=custom_llm_provider,
- api_base=api_base,
- api_key=api_key,
- logging_obj=logging,
- timeout=timeout,
- model_response=EmbeddingResponse(),
- optional_params=optional_params,
- client=client,
- aembedding=aembedding,
- litellm_params=litellm_params_dict,
- headers=headers,
- )
elif custom_llm_provider in litellm._custom_providers:
custom_handler: Optional[CustomLLM] = None
for item in litellm.custom_provider_map:
@@ -6575,8 +6615,7 @@ def transcription(
api_key=api_key,
) # type: ignore
- if dynamic_api_key is not None:
- api_key = dynamic_api_key
+ api_key = dynamic_api_key if dynamic_api_key is not None else api_key
optional_params = get_optional_params_transcription(
model=model,
@@ -6616,7 +6655,7 @@ def transcription(
provider=LlmProviders(custom_llm_provider),
)
- if custom_llm_provider == "azure":
+ if custom_llm_provider == "azure" and provider_config is None:
# azure configs
api_base = api_base or litellm.api_base or get_secret_str("AZURE_API_BASE")
diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json
index fb5bfa6cf4e..62e576ea0f7 100644
--- a/litellm/model_prices_and_context_window_backup.json
+++ b/litellm/model_prices_and_context_window_backup.json
@@ -1448,6 +1448,35 @@
"supports_native_structured_output": true,
"supports_minimal_reasoning_effort": true
},
+ "jp.anthropic.claude-sonnet-4-6": {
+ "cache_creation_input_token_cost": 4.125e-06,
+ "cache_read_input_token_cost": 3.3e-07,
+ "input_cost_per_token": 3.3e-06,
+ "litellm_provider": "bedrock_converse",
+ "max_input_tokens": 1000000,
+ "max_output_tokens": 64000,
+ "max_tokens": 64000,
+ "mode": "chat",
+ "output_cost_per_token": 1.65e-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_assistant_prefill": true,
+ "supports_computer_use": true,
+ "supports_function_calling": true,
+ "supports_pdf_input": true,
+ "supports_prompt_caching": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_max_reasoning_effort": true,
+ "supports_tool_choice": true,
+ "supports_vision": true,
+ "tool_use_system_prompt_tokens": 346,
+ "supports_native_structured_output": true,
+ "supports_minimal_reasoning_effort": true
+ },
"anthropic.claude-sonnet-4-20250514-v1:0": {
"cache_creation_input_token_cost": 3.75e-06,
"cache_read_input_token_cost": 3e-07,
@@ -6025,6 +6054,17 @@
"mode": "audio_speech",
"source": "https://azure.microsoft.com/en-us/pricing/calculator/"
},
+ "azure/speech/azure-stt": {
+ "audio_transcription_config": "azure_speech",
+ "input_cost_per_second": 0.0002777778,
+ "litellm_provider": "azure",
+ "mode": "audio_transcription",
+ "output_cost_per_second": 0.0,
+ "source": "https://azure.microsoft.com/en-us/pricing/details/cognitive-services/speech-services/",
+ "supported_endpoints": [
+ "/v1/audio/transcriptions"
+ ]
+ },
"azure/tts-1": {
"input_cost_per_character": 1.5e-05,
"litellm_provider": "azure",
@@ -9602,6 +9642,7 @@
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
+ "supports_adaptive_thinking": true,
"supports_assistant_prefill": true,
"supports_computer_use": true,
"supports_function_calling": true,
@@ -9795,6 +9836,7 @@
"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,
@@ -9828,6 +9870,7 @@
"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,
@@ -9861,6 +9904,7 @@
"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,
@@ -9895,6 +9939,7 @@
"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,
@@ -13948,6 +13993,21 @@
"supports_response_schema": true,
"supports_tool_choice": true
},
+ "fireworks_ai/accounts/fireworks/models/glm-5p1": {
+ "cache_read_input_token_cost": 2.6e-07,
+ "input_cost_per_token": 1.4e-06,
+ "litellm_provider": "fireworks_ai",
+ "max_input_tokens": 202800,
+ "max_output_tokens": 202800,
+ "max_tokens": 202800,
+ "mode": "chat",
+ "output_cost_per_token": 4.4e-06,
+ "source": "https://fireworks.ai/models/fireworks/glm-5p1",
+ "supports_function_calling": false,
+ "supports_reasoning": true,
+ "supports_response_schema": false,
+ "supports_tool_choice": false
+ },
"fireworks_ai/accounts/fireworks/models/gpt-oss-120b": {
"input_cost_per_token": 1.5e-07,
"litellm_provider": "fireworks_ai",
@@ -14214,6 +14274,21 @@
"supports_response_schema": true,
"supports_tool_choice": true
},
+ "fireworks_ai/glm-5p1": {
+ "cache_read_input_token_cost": 2.6e-07,
+ "input_cost_per_token": 1.4e-06,
+ "litellm_provider": "fireworks_ai",
+ "max_input_tokens": 202800,
+ "max_output_tokens": 202800,
+ "max_tokens": 202800,
+ "mode": "chat",
+ "output_cost_per_token": 4.4e-06,
+ "source": "https://fireworks.ai/models/fireworks/glm-5p1",
+ "supports_function_calling": false,
+ "supports_reasoning": true,
+ "supports_response_schema": false,
+ "supports_tool_choice": false
+ },
"fireworks_ai/kimi-k2p5": {
"cache_read_input_token_cost": 1e-07,
"input_cost_per_token": 6e-07,
@@ -14883,7 +14958,65 @@
"mode": "chat",
"output_cost_per_reasoning_token": 1.5e-06,
"output_cost_per_token": 1.5e-06,
- "source": "https://ai.google.dev/gemini-api/docs/models",
+ "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models",
+ "supported_endpoints": [
+ "/v1/chat/completions",
+ "/v1/completions",
+ "/v1/batch"
+ ],
+ "supported_modalities": [
+ "text",
+ "image",
+ "audio",
+ "video"
+ ],
+ "supported_output_modalities": [
+ "text"
+ ],
+ "supports_audio_input": true,
+ "supports_audio_output": false,
+ "supports_code_execution": true,
+ "supports_file_search": true,
+ "supports_function_calling": 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_url_context": true,
+ "supports_video_input": true,
+ "supports_vision": true,
+ "supports_web_search": true,
+ "supports_native_streaming": true,
+ "search_context_cost_per_query": {
+ "search_context_size_low": 0.014,
+ "search_context_size_medium": 0.014,
+ "search_context_size_high": 0.014
+ },
+ "web_search_billing_unit": "per_query",
+ "supports_service_tier": true
+ },
+ "gemini-3.1-flash-lite": {
+ "cache_read_input_token_cost": 4.5e-08,
+ "cache_read_input_token_cost_per_audio_token": 9e-08,
+ "input_cost_per_audio_token": 9e-07,
+ "input_cost_per_token": 4.5e-07,
+ "litellm_provider": "vertex_ai-language-models",
+ "max_audio_length_hours": 8.4,
+ "max_audio_per_prompt": 1,
+ "max_images_per_prompt": 3000,
+ "max_input_tokens": 1048576,
+ "max_output_tokens": 65536,
+ "max_pdf_size_mb": 30,
+ "max_tokens": 65536,
+ "max_video_length": 1,
+ "max_videos_per_prompt": 10,
+ "mode": "chat",
+ "output_cost_per_reasoning_token": 2.7e-06,
+ "output_cost_per_token": 2.7e-06,
+ "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/completions",
@@ -15611,6 +15744,64 @@
},
"web_search_billing_unit": "per_query"
},
+ "vertex_ai/gemini-3.5-flash": {
+ "cache_read_input_token_cost": 1.5e-07,
+ "input_cost_per_token": 1.5e-06,
+ "input_cost_per_audio_token": 1e-06,
+ "litellm_provider": "vertex_ai",
+ "max_audio_length_hours": 8.4,
+ "max_audio_per_prompt": 1,
+ "max_images_per_prompt": 3000,
+ "max_input_tokens": 1048576,
+ "max_output_tokens": 65535,
+ "max_pdf_size_mb": 30,
+ "max_tokens": 65535,
+ "max_video_length": 1,
+ "max_videos_per_prompt": 10,
+ "mode": "chat",
+ "output_cost_per_reasoning_token": 9e-06,
+ "output_cost_per_token": 9e-06,
+ "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing",
+ "supported_endpoints": [
+ "/v1/chat/completions",
+ "/v1/completions",
+ "/v1/batch"
+ ],
+ "supported_modalities": [
+ "text",
+ "image",
+ "audio",
+ "video"
+ ],
+ "supported_output_modalities": [
+ "text"
+ ],
+ "supports_audio_input": true,
+ "supports_function_calling": 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_url_context": true,
+ "supports_video_input": true,
+ "supports_vision": true,
+ "supports_web_search": true,
+ "supports_native_streaming": true,
+ "input_cost_per_token_priority": 2.7e-06,
+ "input_cost_per_audio_token_priority": 1.8e-06,
+ "output_cost_per_token_priority": 1.62e-05,
+ "cache_read_input_token_cost_priority": 2.7e-07,
+ "supports_service_tier": true,
+ "search_context_cost_per_query": {
+ "search_context_size_low": 0.014,
+ "search_context_size_medium": 0.014,
+ "search_context_size_high": 0.014
+ },
+ "web_search_billing_unit": "per_query"
+ },
"vertex_ai/gemini-3.1-pro-preview": {
"cache_read_input_token_cost": 2e-07,
"cache_read_input_token_cost_above_200k_tokens": 4e-07,
@@ -16929,6 +17120,66 @@
"web_search_billing_unit": "per_query",
"supports_service_tier": true
},
+ "gemini/gemini-3.1-flash-lite": {
+ "cache_read_input_token_cost": 4.5e-08,
+ "cache_read_input_token_cost_per_audio_token": 9e-08,
+ "input_cost_per_audio_token": 9e-07,
+ "input_cost_per_token": 4.5e-07,
+ "litellm_provider": "gemini",
+ "max_audio_length_hours": 8.4,
+ "max_audio_per_prompt": 1,
+ "max_images_per_prompt": 3000,
+ "max_input_tokens": 1048576,
+ "max_output_tokens": 65536,
+ "max_pdf_size_mb": 30,
+ "max_tokens": 65536,
+ "max_video_length": 1,
+ "max_videos_per_prompt": 10,
+ "mode": "chat",
+ "output_cost_per_reasoning_token": 2.7e-06,
+ "output_cost_per_token": 2.7e-06,
+ "rpm": 15,
+ "source": "https://ai.google.dev/gemini-api/docs/pricing",
+ "supported_endpoints": [
+ "/v1/chat/completions",
+ "/v1/completions",
+ "/v1/batch"
+ ],
+ "supported_modalities": [
+ "text",
+ "image",
+ "audio",
+ "video"
+ ],
+ "supported_output_modalities": [
+ "text"
+ ],
+ "supports_audio_input": true,
+ "supports_audio_output": false,
+ "supports_code_execution": true,
+ "supports_file_search": true,
+ "supports_function_calling": 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_url_context": true,
+ "supports_video_input": true,
+ "supports_vision": true,
+ "supports_web_search": true,
+ "supports_native_streaming": true,
+ "tpm": 250000,
+ "search_context_cost_per_query": {
+ "search_context_size_low": 0.014,
+ "search_context_size_medium": 0.014,
+ "search_context_size_high": 0.014
+ },
+ "web_search_billing_unit": "per_query",
+ "supports_service_tier": true
+ },
"gemini/gemini-3-flash-preview": {
"cache_read_input_token_cost": 5e-08,
"input_cost_per_audio_token": 1e-06,
@@ -16988,6 +17239,67 @@
},
"web_search_billing_unit": "per_query"
},
+ "gemini/gemini-3.5-flash": {
+ "cache_read_input_token_cost": 1.5e-07,
+ "input_cost_per_audio_token": 1e-06,
+ "input_cost_per_token": 1.5e-06,
+ "litellm_provider": "gemini",
+ "max_audio_length_hours": 8.4,
+ "max_audio_per_prompt": 1,
+ "max_images_per_prompt": 3000,
+ "max_input_tokens": 1048576,
+ "max_output_tokens": 65535,
+ "max_pdf_size_mb": 30,
+ "max_tokens": 65535,
+ "max_video_length": 1,
+ "max_videos_per_prompt": 10,
+ "mode": "chat",
+ "output_cost_per_reasoning_token": 9e-06,
+ "output_cost_per_token": 9e-06,
+ "rpm": 2000,
+ "source": "https://ai.google.dev/pricing/gemini-3",
+ "supported_endpoints": [
+ "/v1/chat/completions",
+ "/v1/completions",
+ "/v1/batch"
+ ],
+ "supported_modalities": [
+ "text",
+ "image",
+ "audio",
+ "video"
+ ],
+ "supported_output_modalities": [
+ "text"
+ ],
+ "supports_audio_output": false,
+ "supports_audio_input": true,
+ "supports_function_calling": 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_url_context": true,
+ "supports_video_input": true,
+ "supports_vision": true,
+ "supports_web_search": true,
+ "supports_native_streaming": true,
+ "tpm": 800000,
+ "input_cost_per_token_priority": 2.7e-06,
+ "input_cost_per_audio_token_priority": 1.8e-06,
+ "output_cost_per_token_priority": 1.62e-05,
+ "cache_read_input_token_cost_priority": 2.7e-07,
+ "supports_service_tier": true,
+ "search_context_cost_per_query": {
+ "search_context_size_low": 0.014,
+ "search_context_size_medium": 0.014,
+ "search_context_size_high": 0.014
+ },
+ "web_search_billing_unit": "per_query"
+ },
"gemini/gemini-3.1-pro-preview": {
"cache_read_input_token_cost": 2e-07,
"cache_read_input_token_cost_above_200k_tokens": 4e-07,
@@ -17173,6 +17485,65 @@
},
"web_search_billing_unit": "per_query"
},
+ "gemini-3.5-flash": {
+ "cache_read_input_token_cost": 1.5e-07,
+ "input_cost_per_audio_token": 1e-06,
+ "input_cost_per_token": 1.5e-06,
+ "litellm_provider": "vertex_ai-language-models",
+ "max_audio_length_hours": 8.4,
+ "max_audio_per_prompt": 1,
+ "max_images_per_prompt": 3000,
+ "max_input_tokens": 1048576,
+ "max_output_tokens": 65535,
+ "max_pdf_size_mb": 30,
+ "max_tokens": 65535,
+ "max_video_length": 1,
+ "max_videos_per_prompt": 10,
+ "mode": "chat",
+ "output_cost_per_reasoning_token": 9e-06,
+ "output_cost_per_token": 9e-06,
+ "source": "https://ai.google.dev/pricing/gemini-3",
+ "supported_endpoints": [
+ "/v1/chat/completions",
+ "/v1/completions",
+ "/v1/batch"
+ ],
+ "supported_modalities": [
+ "text",
+ "image",
+ "audio",
+ "video"
+ ],
+ "supported_output_modalities": [
+ "text"
+ ],
+ "supports_audio_output": false,
+ "supports_audio_input": true,
+ "supports_function_calling": 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_url_context": true,
+ "supports_video_input": true,
+ "supports_vision": true,
+ "supports_web_search": true,
+ "supports_native_streaming": true,
+ "input_cost_per_token_priority": 2.7e-06,
+ "input_cost_per_audio_token_priority": 1.8e-06,
+ "output_cost_per_token_priority": 1.62e-05,
+ "cache_read_input_token_cost_priority": 2.7e-07,
+ "supports_service_tier": true,
+ "search_context_cost_per_query": {
+ "search_context_size_low": 0.014,
+ "search_context_size_medium": 0.014,
+ "search_context_size_high": 0.014
+ },
+ "web_search_billing_unit": "per_query"
+ },
"gemini/gemini-2.5-pro-preview-tts": {
"cache_read_input_token_cost": 1.25e-07,
"cache_read_input_token_cost_above_200k_tokens": 2.5e-07,
@@ -24107,6 +24478,21 @@
"supports_tool_choice": true,
"supports_vision": true
},
+ "mistral/ministral-8b-2512": {
+ "input_cost_per_token": 1.5e-07,
+ "litellm_provider": "mistral",
+ "max_input_tokens": 262144,
+ "max_output_tokens": 262144,
+ "max_tokens": 262144,
+ "mode": "chat",
+ "output_cost_per_token": 1.5e-07,
+ "source": "https://mistral.ai/pricing",
+ "supports_assistant_prefill": true,
+ "supports_function_calling": true,
+ "supports_response_schema": true,
+ "supports_tool_choice": true,
+ "supports_vision": true
+ },
"mistral/mistral-tiny": {
"input_cost_per_token": 2.5e-07,
"litellm_provider": "mistral",
@@ -25922,6 +26308,51 @@
"supports_function_calling": true,
"supports_response_schema": false
},
+ "oci/openai.gpt-5": {
+ "input_cost_per_token": 1.25e-06,
+ "litellm_provider": "oci",
+ "max_input_tokens": 272000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "chat",
+ "output_cost_per_token": 1e-05,
+ "source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing",
+ "supports_function_calling": true,
+ "supports_native_streaming": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_vision": true
+ },
+ "oci/openai.gpt-5-mini": {
+ "input_cost_per_token": 2.5e-07,
+ "litellm_provider": "oci",
+ "max_input_tokens": 272000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "chat",
+ "output_cost_per_token": 2e-06,
+ "source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing",
+ "supports_function_calling": true,
+ "supports_native_streaming": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_vision": true
+ },
+ "oci/openai.gpt-5-nano": {
+ "input_cost_per_token": 5e-08,
+ "litellm_provider": "oci",
+ "max_input_tokens": 272000,
+ "max_output_tokens": 128000,
+ "max_tokens": 128000,
+ "mode": "chat",
+ "output_cost_per_token": 4e-07,
+ "source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing",
+ "supports_function_calling": true,
+ "supports_native_streaming": true,
+ "supports_reasoning": true,
+ "supports_response_schema": true,
+ "supports_vision": true
+ },
"oci/google.gemini-2.5-pro": {
"input_cost_per_token": 1.25e-06,
"litellm_provider": "oci",
@@ -26951,6 +27382,58 @@
"supports_web_search": true,
"tpm": 800000
},
+ "openrouter/google/gemini-3.1-flash-lite": {
+ "cache_read_input_token_cost": 2.5e-08,
+ "cache_read_input_token_cost_per_audio_token": 5e-08,
+ "input_cost_per_audio_token": 5e-07,
+ "input_cost_per_token": 2.5e-07,
+ "litellm_provider": "openrouter",
+ "max_audio_length_hours": 8.4,
+ "max_audio_per_prompt": 1,
+ "max_images_per_prompt": 3000,
+ "max_input_tokens": 1048576,
+ "max_output_tokens": 65536,
+ "max_pdf_size_mb": 30,
+ "max_tokens": 65536,
+ "max_video_length": 1,
+ "max_videos_per_prompt": 10,
+ "mode": "chat",
+ "output_cost_per_reasoning_token": 1.5e-06,
+ "output_cost_per_token": 1.5e-06,
+ "rpm": 2000,
+ "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-lite",
+ "supported_endpoints": [
+ "/v1/chat/completions",
+ "/v1/completions",
+ "/v1/batch"
+ ],
+ "supported_modalities": [
+ "text",
+ "image",
+ "audio",
+ "video"
+ ],
+ "supported_output_modalities": [
+ "text"
+ ],
+ "supports_audio_input": true,
+ "supports_audio_output": false,
+ "supports_code_execution": true,
+ "supports_file_search": true,
+ "supports_function_calling": 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_url_context": true,
+ "supports_video_input": true,
+ "supports_vision": true,
+ "supports_web_search": true,
+ "tpm": 800000
+ },
"openrouter/google/gemini-3.1-pro-preview": {
"cache_read_input_token_cost": 2e-07,
"cache_read_input_token_cost_above_200k_tokens": 4e-07,
@@ -28725,6 +29208,24 @@
"supports_tool_choice": true,
"source": "https://aws.amazon.com/bedrock/pricing/"
},
+ "reducto/parse-legacy": {
+ "litellm_provider": "reducto",
+ "mode": "ocr",
+ "ocr_cost_per_credit": 0.015,
+ "source": "https://reducto.ai/pricing",
+ "supported_endpoints": [
+ "/v1/ocr"
+ ]
+ },
+ "reducto/parse-v3": {
+ "litellm_provider": "reducto",
+ "mode": "ocr",
+ "ocr_cost_per_credit": 0.015,
+ "source": "https://reducto.ai/pricing",
+ "supported_endpoints": [
+ "/v1/ocr"
+ ]
+ },
"recraft/recraftv2": {
"litellm_provider": "recraft",
"mode": "image_generation",
@@ -33427,6 +33928,64 @@
},
"web_search_billing_unit": "per_query"
},
+ "vertex_ai/gemini-3.1-flash-lite": {
+ "cache_read_input_token_cost": 4.5e-08,
+ "cache_read_input_token_cost_per_audio_token": 9e-08,
+ "input_cost_per_audio_token": 9e-07,
+ "input_cost_per_token": 4.5e-07,
+ "litellm_provider": "vertex_ai-language-models",
+ "max_audio_length_hours": 8.4,
+ "max_audio_per_prompt": 1,
+ "max_images_per_prompt": 3000,
+ "max_input_tokens": 1048576,
+ "max_output_tokens": 65536,
+ "max_pdf_size_mb": 30,
+ "max_tokens": 65536,
+ "max_video_length": 1,
+ "max_videos_per_prompt": 10,
+ "mode": "chat",
+ "output_cost_per_reasoning_token": 2.7e-06,
+ "output_cost_per_token": 2.7e-06,
+ "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models",
+ "supported_endpoints": [
+ "/v1/chat/completions",
+ "/v1/completions",
+ "/v1/batch"
+ ],
+ "supported_modalities": [
+ "text",
+ "image",
+ "audio",
+ "video"
+ ],
+ "supported_output_modalities": [
+ "text"
+ ],
+ "supports_audio_input": true,
+ "supports_audio_output": false,
+ "supports_code_execution": true,
+ "supports_file_search": true,
+ "supports_function_calling": 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_url_context": true,
+ "supports_video_input": true,
+ "supports_vision": true,
+ "supports_web_search": true,
+ "supports_native_streaming": true,
+ "search_context_cost_per_query": {
+ "search_context_size_low": 0.014,
+ "search_context_size_medium": 0.014,
+ "search_context_size_high": 0.014
+ },
+ "web_search_billing_unit": "per_query",
+ "supports_service_tier": true
+ },
"vertex_ai/deep-research-pro-preview-12-2025": {
"input_cost_per_image": 0.0011,
"input_cost_per_token": 2e-06,
diff --git a/litellm/passthrough/utils.py b/litellm/passthrough/utils.py
index d39a0dda152..9484922833a 100644
--- a/litellm/passthrough/utils.py
+++ b/litellm/passthrough/utils.py
@@ -71,6 +71,11 @@ class BasePassthroughUtils:
request_headers.pop("content-length", None)
request_headers.pop("host", None)
+ custom_header_names = {header_name.lower() for header_name in headers}
+ for header_name in list(request_headers.keys()):
+ if header_name.lower() in custom_header_names:
+ request_headers.pop(header_name, None)
+
# Combine request headers with custom headers
headers = {**request_headers, **headers}
diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
index d1b49039e8e..d0e9ad7b2a4 100644
--- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
+++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
@@ -12,6 +12,7 @@ import hashlib
import json
import os
import re
+import time
from typing import Any, Callable, Dict, List, Literal, Optional, Set, Tuple, Union, cast
from urllib.parse import urlparse
@@ -250,6 +251,10 @@ class MCPServerManager:
}
"""
self._upstream_initialize_instructions_by_server_id: Dict[str, str] = {}
+ # Per-server monotonic timestamp of last upstream prefetch attempt (success,
+ # empty result, or failure). Used to throttle re-probes for servers that do
+ # not return instructions, and to apply a short cooldown after failures.
+ self._upstream_initialize_instructions_probed_at: Dict[str, float] = {}
def _remember_upstream_initialize_instructions(
self, server: MCPServer, client: MCPClient
@@ -260,6 +265,80 @@ class MCPServerManager:
raw
).strip()
+ async def _ensure_upstream_initialize_instructions_cached(
+ self, server: MCPServer
+ ) -> None:
+ """
+ Open one upstream session and cache InitializeResult.instructions if missing.
+
+ No-op when:
+ - YAML/DB instructions are set on the server record,
+ - server is OpenAPI (spec_path),
+ - non-empty upstream instructions are already cached,
+ - auth preconditions match health_check_server's skip rules
+ (per-user auth / missing static auth token),
+ - a prior probe attempt for this server is within
+ MCP_HEALTH_CHECK_TIMEOUT seconds (the probe is a health-check-shaped
+ op and already uses this knob for its inner call timeout; reusing it
+ as the cooldown avoids reconnecting on every gateway initialize when
+ upstream returns empty or fails).
+ """
+ if server.spec_path:
+ return
+ if server.instructions and server.instructions.strip():
+ return
+ if self._upstream_initialize_instructions_by_server_id.get(server.server_id):
+ return
+ if server.requires_per_user_auth:
+ return
+ if (
+ server.auth_type
+ and server.auth_type != MCPAuth.none
+ and server.auth_type != MCPAuth.aws_sigv4
+ and not server.authentication_token
+ ):
+ return
+
+ last_probed_at = self._upstream_initialize_instructions_probed_at.get(
+ server.server_id
+ )
+ if (
+ last_probed_at is not None
+ and (time.monotonic() - last_probed_at) < MCP_HEALTH_CHECK_TIMEOUT
+ ):
+ return
+
+ # Record the attempt up-front so that a failure / empty response does not
+ # cause every subsequent initialize request to re-open the upstream session.
+ self._upstream_initialize_instructions_probed_at[server.server_id] = (
+ time.monotonic()
+ )
+
+ try:
+ extra_headers: Optional[Dict[str, str]] = (
+ dict(server.static_headers) if server.static_headers else None
+ )
+ client = await self._create_mcp_client(
+ server=server,
+ mcp_auth_header=None,
+ extra_headers=extra_headers,
+ stdio_env=None,
+ )
+
+ async def _noop(_session):
+ return "ok"
+
+ await asyncio.wait_for(
+ client.run_with_session(_noop), timeout=MCP_HEALTH_CHECK_TIMEOUT
+ )
+ self._remember_upstream_initialize_instructions(server, client)
+ except Exception as e:
+ verbose_logger.debug(
+ "Upstream initialize instructions prefetch failed for %s: %s",
+ server.name,
+ e,
+ )
+
def get_registry(self) -> Dict[str, MCPServer]:
"""
Get the registered MCP Servers from the registry and union with the config MCP Servers
@@ -280,6 +359,7 @@ class MCPServerManager:
"""
verbose_logger.debug("Loading MCP Servers from config-----")
self._upstream_initialize_instructions_by_server_id.clear()
+ self._upstream_initialize_instructions_probed_at.clear()
# Track which aliases have been used to ensure only first occurrence is used
used_aliases = set()
@@ -1212,11 +1292,17 @@ class MCPServerManager:
return []
# Get server-specific auth header if available
- server_auth_header = None
- if mcp_server_auth_headers and server.alias:
- server_auth_header = mcp_server_auth_headers.get(server.alias)
- elif mcp_server_auth_headers and server.server_name:
- server_auth_header = mcp_server_auth_headers.get(server.server_name)
+ server_auth_header: Optional[Union[str, Dict[str, str]]] = None
+ if mcp_server_auth_headers:
+ from litellm.proxy._experimental.mcp_server.utils import (
+ lookup_mcp_server_auth_in_headers,
+ )
+
+ server_auth_header = lookup_mcp_server_auth_in_headers(
+ mcp_server_auth_headers,
+ alias=server.alias,
+ server_name=server.server_name,
+ )
# Fall back to deprecated mcp_auth_header if no server-specific header found
if server_auth_header is None:
@@ -1226,6 +1312,7 @@ class MCPServerManager:
tools = await self._get_tools_from_server(
server=server,
mcp_auth_header=server_auth_header,
+ user_api_key_auth=user_api_key_auth,
)
return tools
except Exception as e:
@@ -1406,6 +1493,7 @@ class MCPServerManager:
extra_headers: Optional[Dict[str, str]] = None,
add_prefix: bool = True,
raw_headers: Optional[Dict[str, str]] = None,
+ user_api_key_auth: Optional[UserAPIKeyAuth] = None,
) -> List[MCPTool]:
"""
Helper method to get tools from a single MCP server with prefixed names.
@@ -1432,6 +1520,46 @@ class MCPServerManager:
extra_headers = {}
extra_headers.update(server.static_headers)
+ # MCPJWTSigner: inject signed JWT for tools/list (list path skips pre_call_hook).
+ # Skip entirely when the signer is not configured (avoid an unnecessary
+ # dict copy on every list call), when the server has its own static
+ # Authorization header, when a per-user mcp_auth_header has already
+ # been resolved, or when the caller already supplied an Authorization
+ # entry in extra_headers (e.g. a per-user OAuth token resolved
+ # upstream) — admin-configured static auth and per-user OAuth must
+ # take precedence so the signer doesn't silently overwrite e.g. an
+ # upstream API key or a user's OAuth token (MCPClient._get_auth_headers
+ # applies extra_headers after writing Authorization from auth_value, so
+ # an injected JWT would otherwise clobber the per-user token).
+ if user_api_key_auth is not None and not server.spec_path:
+ from litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer import (
+ get_mcp_jwt_signer,
+ inject_mcp_jwt_headers_for_upstream,
+ )
+
+ static_headers = server.static_headers or {}
+ has_static_authorization = any(
+ isinstance(k, str) and k.lower() == "authorization"
+ for k in static_headers.keys()
+ )
+ has_extra_authorization = bool(extra_headers) and any(
+ isinstance(k, str) and k.lower() == "authorization"
+ for k in (extra_headers or {}).keys()
+ )
+
+ if (
+ get_mcp_jwt_signer() is not None
+ and not has_static_authorization
+ and not mcp_auth_header
+ and not has_extra_authorization
+ ):
+ extra_headers = await inject_mcp_jwt_headers_for_upstream(
+ user_api_key_dict=user_api_key_auth,
+ extra_headers=extra_headers,
+ raw_headers=raw_headers,
+ for_list_tools=True,
+ )
+
stdio_env = self._build_stdio_env(server, raw_headers)
client = await self._create_mcp_client(
@@ -2665,16 +2793,15 @@ class MCPServerManager:
server_auth_header: Optional[Union[Dict[str, str], str]] = None
if mcp_server_auth_headers:
# Normalize keys for case-insensitive lookup
- normalized_headers = {
- k.lower(): v for k, v in mcp_server_auth_headers.items()
- }
+ from litellm.proxy._experimental.mcp_server.utils import (
+ lookup_mcp_server_auth_in_headers,
+ )
- if mcp_server.alias:
- server_auth_header = normalized_headers.get(mcp_server.alias.lower())
- if server_auth_header is None and mcp_server.server_name:
- server_auth_header = normalized_headers.get(
- mcp_server.server_name.lower()
- )
+ server_auth_header = lookup_mcp_server_auth_in_headers(
+ mcp_server_auth_headers,
+ alias=mcp_server.alias,
+ server_name=mcp_server.server_name,
+ )
# Fall back to deprecated mcp_auth_header if no server-specific header found
if server_auth_header is None:
@@ -2791,6 +2918,112 @@ class MCPServerManager:
return cast(CallToolResult, result)
+ def _resolve_mcp_server_for_tool_call(
+ self,
+ server_name: str,
+ name: str,
+ ) -> MCPServer:
+ """Resolve MCP server for call_tool (prefixed name, registry, fallback)."""
+ prefixed_tool_name = add_server_prefix_to_name(name, server_name)
+ mcp_server = self._get_mcp_server_from_tool_name(prefixed_tool_name)
+ resolved_by_server_name_only = False
+ normalized_server_name = normalize_server_name(server_name)
+
+ def _candidate_matches_server_name(candidate: MCPServer) -> bool:
+ for identifier in (
+ candidate.alias,
+ candidate.server_name,
+ candidate.name,
+ ):
+ if identifier and normalize_server_name(identifier) == (
+ normalized_server_name
+ ):
+ return True
+ return False
+
+ if mcp_server is None:
+ for candidate in self.get_registry().values():
+ if _candidate_matches_server_name(candidate):
+ mcp_server = candidate
+ resolved_by_server_name_only = True
+ break
+ if mcp_server is None:
+ fallback = self._get_mcp_server_from_tool_name(name)
+ if fallback is not None and (
+ not server_name or _candidate_matches_server_name(fallback)
+ ):
+ mcp_server = fallback
+ if mcp_server is None:
+ raise ValueError(f"Tool {name} not found")
+
+ if resolved_by_server_name_only:
+ tool_known = (
+ name in self.tool_name_to_mcp_server_name_mapping
+ or prefixed_tool_name in self.tool_name_to_mcp_server_name_mapping
+ )
+ if not tool_known:
+ raise ValueError(f"Tool {name} not found")
+
+ return mcp_server
+
+ async def _resolve_oauth2_headers_for_tool_call(
+ self,
+ mcp_server: MCPServer,
+ oauth2_headers: Optional[Dict[str, str]],
+ user_api_key_auth: Optional[UserAPIKeyAuth],
+ ) -> Optional[Dict[str, str]]:
+ """Look up per-user OAuth headers when the client did not supply a token."""
+ if (
+ not mcp_server.needs_user_oauth_token
+ or oauth2_headers
+ or user_api_key_auth is None
+ ):
+ return oauth2_headers
+
+ user_id = getattr(user_api_key_auth, "user_id", None)
+ if not user_id:
+ return oauth2_headers
+
+ try:
+ from litellm.proxy._experimental.mcp_server.server import ( # noqa: PLC0415
+ _get_user_oauth_extra_headers_from_db,
+ )
+
+ stored_headers = await _get_user_oauth_extra_headers_from_db(
+ server=mcp_server,
+ user_api_key_auth=user_api_key_auth,
+ )
+ if stored_headers:
+ return stored_headers
+ except Exception as _lookup_exc:
+ verbose_logger.debug(
+ "call_tool: per-user token lookup failed for " "user=%s server=%s: %s",
+ user_id,
+ mcp_server.server_id,
+ _lookup_exc,
+ )
+ return oauth2_headers
+
+ async def _gather_openapi_tool_tasks(
+ self,
+ tasks: List[Any],
+ proxy_logging_obj: Optional[ProxyLogging],
+ ) -> CallToolResult:
+ """Await OpenAPI tool tasks and return the tool call result."""
+ try:
+ mcp_responses = await asyncio.gather(*tasks)
+ result_index = 1 if proxy_logging_obj else 0
+ return cast(CallToolResult, mcp_responses[result_index])
+ except (
+ BlockedPiiEntityError,
+ GuardrailRaisedException,
+ HTTPException,
+ ) as e:
+ verbose_logger.error(
+ f"Guardrail blocked MCP tool call during result check: {str(e)}"
+ )
+ raise e
+
async def call_tool(
self,
server_name: str,
@@ -2821,12 +3054,7 @@ class MCPServerManager:
CallToolResult from the MCP server
"""
start_time = datetime.datetime.now()
-
- # Get the MCP server
- prefixed_tool_name = add_server_prefix_to_name(name, server_name)
- mcp_server = self._get_mcp_server_from_tool_name(prefixed_tool_name)
- if mcp_server is None:
- raise ValueError(f"Tool {name} not found")
+ mcp_server = self._resolve_mcp_server_for_tool_call(server_name, name)
#########################################################
# Pre MCP Tool Call Hook
@@ -2860,36 +3088,9 @@ class MCPServerManager:
)
tasks.append(during_hook_task)
- # For per-user OAuth servers: if the client didn't supply a token in
- # oauth2_headers, look up the stored token from Redis / DB. This is the
- # call_tool equivalent of _get_user_oauth_extra_headers_from_db used in
- # list_tools.
- if (
- mcp_server.needs_user_oauth_token
- and not oauth2_headers
- and user_api_key_auth is not None
- ):
- user_id = getattr(user_api_key_auth, "user_id", None)
- if user_id:
- try:
- from litellm.proxy._experimental.mcp_server.server import ( # noqa: PLC0415
- _get_user_oauth_extra_headers_from_db,
- )
-
- stored_headers = await _get_user_oauth_extra_headers_from_db(
- server=mcp_server,
- user_api_key_auth=user_api_key_auth,
- )
- if stored_headers:
- oauth2_headers = stored_headers
- except Exception as _lookup_exc:
- verbose_logger.debug(
- "call_tool: per-user token lookup failed for "
- "user=%s server=%s: %s",
- user_id,
- mcp_server.server_id,
- _lookup_exc,
- )
+ oauth2_headers = await self._resolve_oauth2_headers_for_tool_call(
+ mcp_server, oauth2_headers, user_api_key_auth
+ )
# For OpenAPI servers, call the tool handler directly instead of via MCP client
if mcp_server.spec_path:
@@ -2925,26 +3126,7 @@ class MCPServerManager:
hook_extra_headers=hook_result.get("extra_headers"),
)
- # For OpenAPI tools, await outside the client context
- try:
- mcp_responses = await asyncio.gather(*tasks)
-
- # If proxy_logging_obj is None, the tool call result is at index 0
- # If proxy_logging_obj is not None, the tool call result is at index 1 (after the during hook task)
- result_index = 1 if proxy_logging_obj else 0
- result = mcp_responses[result_index]
-
- return cast(CallToolResult, result)
- except (
- BlockedPiiEntityError,
- GuardrailRaisedException,
- HTTPException,
- ) as e:
- # Re-raise guardrail exceptions to properly fail the MCP call
- verbose_logger.error(
- f"Guardrail blocked MCP tool call during result check: {str(e)}"
- )
- raise e
+ return await self._gather_openapi_tool_tasks(tasks, proxy_logging_obj)
#########################################################
# End of Methods that call the upstream MCP servers
@@ -3039,6 +3221,7 @@ class MCPServerManager:
verbose_logger.debug("Loading MCP servers from database into registry...")
self._upstream_initialize_instructions_by_server_id.clear()
+ self._upstream_initialize_instructions_probed_at.clear()
# perform authz check to filter the mcp servers user has access to
prisma_client = get_prisma_client_or_throw(
diff --git a/litellm/proxy/_experimental/mcp_server/oauth_utils.py b/litellm/proxy/_experimental/mcp_server/oauth_utils.py
index 8541a691e88..e8b591c39cf 100644
--- a/litellm/proxy/_experimental/mcp_server/oauth_utils.py
+++ b/litellm/proxy/_experimental/mcp_server/oauth_utils.py
@@ -3,8 +3,8 @@
import os
from ipaddress import ip_address
-from typing import List, Optional
-from urllib.parse import urlparse, urlunparse
+from typing import Any, Dict, List, NoReturn, Optional
+from urllib.parse import ParseResult, urlparse, urlunparse
from fastapi import HTTPException, Request
@@ -29,10 +29,47 @@ _DEFAULT_PORTS = {"http": 80, "https": 443}
# subdomain. HTTPS only.
_TRUSTED_REDIRECT_ORIGINS_ENV = "MCP_TRUSTED_REDIRECT_ORIGINS"
+# Comma-separated private-use URI allowlist for native MCP clients.
+# A trailing ``*`` is a prefix match; end the prefix with ``/`` (e.g.
+# ``myapp://host/oauth/*``) so ``.../oauth/callback*`` does not also
+# match ``.../oauth/callback-2``.
+_TRUSTED_NATIVE_REDIRECT_URIS_ENV = "MCP_TRUSTED_NATIVE_REDIRECT_URIS"
+
+# Default allowlist for trusted native redirect URIs.
+_DEFAULT_NATIVE_REDIRECT_URIS: List[str] = [
+ "cursor://anysphere.cursor-mcp/oauth/callback",
+]
_warned_invalid_proxy_base_url: Optional[str] = None
+def _oauth_invalid_request(
+ error_description: str,
+ *,
+ hint: Optional[str] = None,
+ **extra: Any,
+) -> NoReturn:
+ """Raise ``invalid_request`` (RFC 6749) with a debuggable description.
+
+ FastAPI serializes ``detail`` as JSON. Callers still see ``error``:
+ ``invalid_request``; ``error_description`` and ``hint`` explain what
+ failed and how to fix it (e.g. reverse-proxy / PROXY_BASE_URL issues).
+ """
+ detail: Dict[str, Any] = {
+ "error": "invalid_request",
+ "error_description": error_description,
+ }
+ if hint:
+ detail["hint"] = hint
+ detail.update(extra)
+ raise HTTPException(status_code=400, detail=detail)
+
+
+def _origin_label(scheme: str, netloc: str) -> str:
+ """Human-readable origin for error messages (scheme + host[:port])."""
+ return f"{scheme}://{netloc}" if netloc else f"{scheme}://"
+
+
def _resolve_proxy_base_url_env() -> Optional[str]:
global _warned_invalid_proxy_base_url
configured = os.environ.get("PROXY_BASE_URL", "").strip()
@@ -108,17 +145,15 @@ def validate_loopback_redirect_uri(redirect_uri: str) -> None:
``"127.0.0.1"`` alone would miss ``127.0.0.2`` and the full-form
IPv6 loopback ``0:0:0:0:0:0:0:1``.
"""
- try:
- parsed = urlparse(redirect_uri)
- except ValueError:
- raise HTTPException(status_code=400, detail="invalid_request")
+ parsed = _parse_redirect_uri_for_validation(redirect_uri)
if parsed.scheme not in ("http", "https"):
- raise HTTPException(status_code=400, detail="invalid_request")
- # Fragments are not allowed in OAuth redirect URIs (RFC 6749 §3.1.2)
- # — rejecting them prevents a ``http://127.0.0.1/cb#frag?code=...``
- # from silently eating the authorization code.
+ _oauth_invalid_request(
+ f"redirect_uri scheme {parsed.scheme!r} is not allowed; use http or https.",
+ )
if parsed.fragment:
- raise HTTPException(status_code=400, detail="invalid_request")
+ _oauth_invalid_request(
+ "redirect_uri must not contain a URL fragment (#...).",
+ )
host = (parsed.hostname or "").lower()
if host == "localhost":
return
@@ -129,7 +164,10 @@ def validate_loopback_redirect_uri(redirect_uri: str) -> None:
# Unparseable host (malformed IPv6, etc.) — treat as invalid,
# don't let it bubble up as a 500.
pass
- raise HTTPException(status_code=400, detail="invalid_request")
+ _oauth_invalid_request(
+ "redirect_uri must use a loopback host (localhost or 127.0.0.0/8).",
+ hint="Native MCP clients should register a callback on http://127.0.0.1:/...",
+ )
def _strip_default_port(scheme: str, netloc: str) -> str:
@@ -212,10 +250,256 @@ def _matches_trusted_origin_entry(netloc: str, entry: str) -> bool:
return netloc == entry
+def _normalize_native_redirect_uri(
+ parsed,
+) -> str:
+ """Lowercase scheme, netloc, and path for allowlist comparison."""
+ return urlunparse(
+ (
+ (parsed.scheme or "").lower(),
+ (parsed.netloc or "").lower(),
+ (parsed.path or "").lower(),
+ "",
+ "",
+ "",
+ )
+ )
+
+
+def _parse_trusted_native_redirect_uris() -> List[str]:
+ """Built-in native MCP callbacks plus ``MCP_TRUSTED_NATIVE_REDIRECT_URIS``."""
+ entries: List[str] = [uri.lower() for uri in _DEFAULT_NATIVE_REDIRECT_URIS]
+ raw = os.environ.get(_TRUSTED_NATIVE_REDIRECT_URIS_ENV, "").strip()
+ if not raw:
+ return entries
+ for token in raw.split(","):
+ entry = token.strip().lower()
+ if entry and entry not in entries:
+ entries.append(entry)
+ return entries
+
+
+def _native_wildcard_prefix_matches(normalized: str, prefix: str) -> bool:
+ """Prefix match for ``entry*`` allowlist rows.
+
+ When the prefix does not end with ``/``, only exact matches or
+ deeper path segments (``prefix/...``) are accepted — not siblings
+ like ``prefix-2``.
+ """
+ if not normalized.startswith(prefix):
+ return False
+ suffix = normalized[len(prefix) :]
+ if not suffix:
+ return True
+ if prefix.endswith("/"):
+ return True
+ return suffix[0] == "/"
+
+
+def _matches_trusted_native_redirect_uri(parsed) -> bool:
+ """Allowlisted private-use / custom-scheme OAuth callbacks for native MCP clients."""
+ if parsed.fragment:
+ return False
+ # Query strings are not part of registered redirect_uris (RFC 6749 §3.1.2).
+ # Rejecting them prevents allowlist bypass via ``.../callback?injected=...``.
+ if parsed.query:
+ return False
+ if not parsed.netloc:
+ return False
+ if parsed.username is not None or parsed.password is not None:
+ return False
+ if "\\" in parsed.netloc:
+ return False
+
+ normalized = _normalize_native_redirect_uri(parsed)
+ for entry in _parse_trusted_native_redirect_uris():
+ if entry.endswith("*"):
+ if _native_wildcard_prefix_matches(normalized, entry[:-1]):
+ return True
+ elif normalized == entry:
+ return True
+ return False
+
+
+def _parse_redirect_uri_for_validation(redirect_uri: str) -> ParseResult:
+ try:
+ return urlparse(redirect_uri)
+ except ValueError:
+ _oauth_invalid_request(
+ "redirect_uri is not a valid URL.",
+ hint="Use a full absolute URL for redirect_uri (e.g. https://your-host/ui/mcp/oauth/callback).",
+ )
+
+
+def _validate_trusted_http_redirect_shape(parsed: ParseResult) -> bool:
+ """Return True when ``parsed`` is an allowlisted native callback (caller may return)."""
+ if parsed.scheme not in ("http", "https"):
+ if _matches_trusted_native_redirect_uri(parsed):
+ return True
+ _oauth_invalid_request(
+ f"redirect_uri scheme {parsed.scheme!r} is not allowed; use http/https "
+ "or a registered native callback (e.g. cursor://).",
+ hint="Add the full URI to MCP_TRUSTED_NATIVE_REDIRECT_URIS for custom native clients.",
+ )
+ if parsed.fragment:
+ _oauth_invalid_request(
+ "redirect_uri must not contain a URL fragment (#...).",
+ )
+ if not parsed.netloc:
+ _oauth_invalid_request(
+ "redirect_uri must include a host (e.g. https://your-host/path).",
+ )
+ if parsed.username is not None or parsed.password is not None:
+ _oauth_invalid_request(
+ "redirect_uri must not contain userinfo (user:pass@host).",
+ )
+ if "\\" in parsed.netloc:
+ _oauth_invalid_request(
+ "redirect_uri host must not contain backslashes.",
+ )
+ return False
+
+
+def _resolve_proxy_base_for_redirect(request: Request) -> Optional[str]:
+ try:
+ return get_request_base_url(request)
+ except Exception as exc:
+ verbose_logger.warning(
+ "validate_trusted_redirect_uri: could not determine proxy origin, "
+ "falling back to loopback + allowlist. error=%s",
+ exc,
+ )
+ return None
+
+
+def _trusted_redirect_uri_is_allowed(
+ parsed: ParseResult,
+ redirect_netloc: str,
+ proxy_base: Optional[str],
+) -> bool:
+ if proxy_base:
+ proxy_parsed = urlparse(proxy_base)
+ if (
+ parsed.scheme == proxy_parsed.scheme
+ and redirect_netloc
+ == _strip_default_port(proxy_parsed.scheme, proxy_parsed.netloc)
+ ):
+ return True
+
+ host = (parsed.hostname or "").lower()
+ if host == "localhost":
+ return True
+ try:
+ if ip_address(host).is_loopback:
+ return True
+ except ValueError:
+ pass
+
+ if parsed.scheme == "https":
+ for entry in _parse_trusted_redirect_origins():
+ if _matches_trusted_origin_entry(redirect_netloc, entry):
+ return True
+ return False
+
+
+def _build_trusted_redirect_rejection_message(
+ redirect_uri: str,
+ parsed: ParseResult,
+ redirect_netloc: str,
+ proxy_base: Optional[str],
+) -> str:
+ """Build a client-facing rejection message.
+
+ Intentionally omits the proxy's resolved scheme / host / port to avoid
+ leaking internal network topology (e.g. ``http://litellm-internal:4000``)
+ through an unauthenticated endpoint. Full diagnostic detail — including
+ the computed proxy base — is logged server-side by the caller.
+ """
+ redirect_origin = _origin_label(parsed.scheme, redirect_netloc)
+ proxy_parsed = urlparse(proxy_base) if proxy_base else None
+ proxy_netloc_norm = (
+ _strip_default_port(proxy_parsed.scheme, proxy_parsed.netloc)
+ if proxy_parsed and proxy_parsed.netloc
+ else ""
+ )
+
+ mismatch_parts: List[str] = []
+ if proxy_parsed and proxy_parsed.netloc:
+ if parsed.scheme != proxy_parsed.scheme:
+ mismatch_parts.append(
+ f"scheme: redirect_uri uses {parsed.scheme!r}, but the proxy "
+ "resolved a different scheme "
+ "(TLS often terminates at ingress — set PROXY_BASE_URL to https://… "
+ "or trust X-Forwarded-Proto from your ingress)"
+ )
+ if redirect_netloc != proxy_netloc_norm:
+ mismatch_parts.append(
+ f"host/port: redirect_uri {redirect_netloc!r} does not match "
+ "the proxy origin"
+ )
+
+ if mismatch_parts:
+ return (
+ f"redirect_uri origin ({redirect_origin}) does not match the proxy "
+ "origin. " + "; ".join(mismatch_parts)
+ )
+ return (
+ f"redirect_uri ({redirect_uri!r}) is not allowed: not same-origin with "
+ f"the proxy origin, not loopback, and not listed in "
+ f"{_TRUSTED_REDIRECT_ORIGINS_ENV}."
+ )
+
+
+def _raise_trusted_redirect_uri_rejected(
+ request: Request,
+ redirect_uri: str,
+ parsed: ParseResult,
+ redirect_netloc: str,
+ proxy_base: Optional[str],
+) -> NoReturn:
+ description = _build_trusted_redirect_rejection_message(
+ redirect_uri, parsed, redirect_netloc, proxy_base
+ )
+
+ hint = (
+ "Align the proxy public URL with the browser URL. Set PROXY_BASE_URL to your "
+ "HTTPS origin (e.g. https://litellm.example.com), or enable "
+ "general_settings.use_x_forwarded_for with mcp_trusted_proxy_ranges for your "
+ "ingress. Verify: curl https:///.well-known/oauth-authorization-server "
+ "| jq .issuer — issuer must match window.location.origin in the UI."
+ )
+
+ verbose_logger.warning(
+ "MCP OAuth: rejecting redirect_uri %r. %s "
+ "Computed proxy base=%r (PROXY_BASE_URL=%r). "
+ "Inbound headers: X-Forwarded-Proto=%r X-Forwarded-Host=%r "
+ "X-Forwarded-Port=%r Host=%r. "
+ "Trusted-redirect-origins env=%r. "
+ "Trusted-native-redirect-uris env=%r.",
+ redirect_uri,
+ description,
+ proxy_base,
+ os.environ.get("PROXY_BASE_URL"),
+ request.headers.get("X-Forwarded-Proto"),
+ request.headers.get("X-Forwarded-Host"),
+ request.headers.get("X-Forwarded-Port"),
+ request.headers.get("Host"),
+ os.environ.get(_TRUSTED_REDIRECT_ORIGINS_ENV),
+ os.environ.get(_TRUSTED_NATIVE_REDIRECT_URIS_ENV),
+ )
+
+ _oauth_invalid_request(
+ description,
+ hint=hint,
+ redirect_uri=redirect_uri,
+ )
+
+
def validate_trusted_redirect_uri(request: Request, redirect_uri: str) -> None:
"""Accept ``redirect_uri`` when it is (a) same-origin with the
- proxy's own request origin, (b) loopback, or (c) listed in the
- ``MCP_TRUSTED_REDIRECT_ORIGINS`` ops allowlist.
+ proxy's own request origin, (b) loopback, (c) listed in the
+ ``MCP_TRUSTED_REDIRECT_ORIGINS`` ops allowlist, or (d) a built-in /
+ env-configured native MCP client callback (e.g. ``cursor://``).
Same-origin is VERIA-57's threat-model-safe equivalent of loopback:
an attacker who can host content on the proxy's own HTTPS origin
@@ -234,92 +518,13 @@ def validate_trusted_redirect_uri(request: Request, redirect_uri: str) -> None:
BYOK endpoints, which only serve native MCP clients, retain
:func:`validate_loopback_redirect_uri`.
"""
- try:
- parsed = urlparse(redirect_uri)
- except ValueError:
- raise HTTPException(status_code=400, detail="invalid_request")
- if parsed.scheme not in ("http", "https"):
- raise HTTPException(status_code=400, detail="invalid_request")
- if parsed.fragment:
- raise HTTPException(status_code=400, detail="invalid_request")
- if not parsed.netloc or parsed.username is not None or parsed.password is not None:
- raise HTTPException(status_code=400, detail="invalid_request")
- # Reject userinfo (``user:pass@host``) outright: OAuth redirect_uris
- # have no legitimate reason to carry credentials, and allowing them
- # opens a host-confusion attack where the netloc *looks* allowlisted
- # (``app.example.com:443@attacker.example``) but the browser navigates
- # to the post-``@`` host and hands the authorization code to the
- # attacker. We compare against ``hostname`` after this, but defense in
- # depth keeps malformed netloc strings from reaching the wildcard
- # splitter.
- if parsed.username is not None or parsed.password is not None:
- raise HTTPException(status_code=400, detail="invalid_request")
- # Reject backslash in netloc: urlparse keeps ``\`` as part of netloc,
- # but browsers normalize ``\`` to ``/`` for http(s) URLs and treat it
- # as the start of the path. An attacker can exploit that split by
- # crafting ``https://attacker.net\app.example.com/cb`` — urlparse sees
- # ``attacker.net\app.example.com`` (matches ``*.example.com``) while
- # the browser navigates to ``attacker.net`` with the auth code.
- if "\\" in parsed.netloc:
- raise HTTPException(status_code=400, detail="invalid_request")
-
- redirect_netloc = _strip_default_port(parsed.scheme, parsed.netloc)
-
- # (a) Same-origin. Swallow ``get_request_base_url`` failures so the
- # loopback + allowlist paths remain reachable when the origin can't
- # be determined (e.g. request came from an untrusted proxy and
- # ``get_request_base_url`` raised).
- proxy_base: Optional[str] = None
- try:
- proxy_base = get_request_base_url(request)
- except Exception as exc:
- verbose_logger.warning(
- "validate_trusted_redirect_uri: could not determine proxy origin, "
- "falling back to loopback + allowlist. error=%s",
- exc,
- )
- proxy_base = None
- if proxy_base:
- proxy_parsed = urlparse(proxy_base)
- if (
- parsed.scheme == proxy_parsed.scheme
- and redirect_netloc
- == _strip_default_port(proxy_parsed.scheme, proxy_parsed.netloc)
- ):
- return
-
- # (b) Loopback — same rule as validate_loopback_redirect_uri.
- host = (parsed.hostname or "").lower()
- if host == "localhost":
+ parsed = _parse_redirect_uri_for_validation(redirect_uri)
+ if _validate_trusted_http_redirect_shape(parsed):
return
- try:
- if ip_address(host).is_loopback:
- return
- except ValueError:
- pass
-
- # (c) Ops allowlist. https only.
- if parsed.scheme == "https":
- for entry in _parse_trusted_redirect_origins():
- if _matches_trusted_origin_entry(redirect_netloc, entry):
- return
-
- verbose_logger.warning(
- "MCP OAuth: rejecting redirect_uri %r as invalid_request. "
- "Computed proxy base=%r (PROXY_BASE_URL=%r). "
- "Inbound headers: X-Forwarded-Proto=%r X-Forwarded-Host=%r "
- "X-Forwarded-Port=%r Host=%r. "
- "Trusted-redirect-origins env=%r. "
- "If this should be accepted, either align ingress X-Forwarded-* "
- "with the browser URL, set PROXY_BASE_URL to your public origin, "
- "or add the redirect_uri host to MCP_TRUSTED_REDIRECT_ORIGINS.",
- redirect_uri,
- proxy_base,
- os.environ.get("PROXY_BASE_URL"),
- request.headers.get("X-Forwarded-Proto"),
- request.headers.get("X-Forwarded-Host"),
- request.headers.get("X-Forwarded-Port"),
- request.headers.get("Host"),
- os.environ.get(_TRUSTED_REDIRECT_ORIGINS_ENV),
+ redirect_netloc = _strip_default_port(parsed.scheme, parsed.netloc)
+ proxy_base = _resolve_proxy_base_for_redirect(request)
+ if _trusted_redirect_uri_is_allowed(parsed, redirect_netloc, proxy_base):
+ return
+ _raise_trusted_redirect_uri_rejected(
+ request, redirect_uri, parsed, redirect_netloc, proxy_base
)
- raise HTTPException(status_code=400, detail="invalid_request")
diff --git a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py
index 271517bb1e6..de70fe1331e 100644
--- a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py
+++ b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py
@@ -305,7 +305,7 @@ def build_input_schema(operation: Dict[str, Any]) -> Dict[str, Any]:
def _merge_openapi_tool_request_headers(
- static_headers: Dict[str, str]
+ static_headers: Dict[str, str],
) -> Dict[str, str]:
"""Merge static closure headers with per-request ContextVar overrides.
diff --git a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py
index 829863d2dbb..cec5224e183 100644
--- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py
+++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py
@@ -1,6 +1,17 @@
import importlib
from datetime import datetime
-from typing import Any, Awaitable, Callable, Dict, List, Literal, Optional, Set, Union
+from typing import (
+ Any,
+ Awaitable,
+ Callable,
+ Dict,
+ List,
+ Literal,
+ Optional,
+ Set,
+ Tuple,
+ Union,
+)
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
@@ -51,20 +62,16 @@ if MCP_AVAILABLE:
mcp_auth_header: Optional[str],
) -> Optional[Union[Dict[str, str], str]]:
"""Helper function to get server-specific auth header with case-insensitive matching."""
- if mcp_server_auth_headers and server.alias:
- normalized_server_alias = server.alias.lower()
- normalized_headers = {
- k.lower(): v for k, v in mcp_server_auth_headers.items()
- }
- server_auth = normalized_headers.get(normalized_server_alias)
- if server_auth is not None:
- return server_auth
- elif mcp_server_auth_headers and server.server_name:
- normalized_server_name = server.server_name.lower()
- normalized_headers = {
- k.lower(): v for k, v in mcp_server_auth_headers.items()
- }
- server_auth = normalized_headers.get(normalized_server_name)
+ from litellm.proxy._experimental.mcp_server.utils import (
+ lookup_mcp_server_auth_in_headers,
+ )
+
+ if mcp_server_auth_headers:
+ server_auth = lookup_mcp_server_auth_in_headers(
+ mcp_server_auth_headers,
+ alias=getattr(server, "alias", None),
+ server_name=getattr(server, "server_name", None),
+ )
if server_auth is not None:
return server_auth
return mcp_auth_header
@@ -231,11 +238,32 @@ if MCP_AVAILABLE:
)
return mcp_auth_header, mcp_server_auth_headers, raw_headers
+ def _resolve_mcp_server_id_for_rest(
+ server_id: str,
+ allowed_server_ids: Union[Set[str], List[str]],
+ client_ip: Optional[str] = None,
+ ) -> str:
+ """
+ Map REST ``server_id`` (UUID, server_name, or alias) to canonical server_id.
+
+ tools/list already did this; tools/call must match so clients can pass
+ server names like ``order_status_mcp`` instead of only UUIDs.
+ """
+ allowed = set(allowed_server_ids)
+ if server_id in allowed:
+ return server_id
+ by_name = global_mcp_server_manager.get_mcp_server_by_name(
+ server_id, client_ip=client_ip
+ )
+ if by_name is not None and by_name.server_id in allowed:
+ return by_name.server_id
+ return server_id
+
async def _resolve_allowed_mcp_servers_with_ip_filter(
request: Request,
user_api_key_dict: UserAPIKeyAuth,
server_id: str,
- ) -> List[MCPServer]:
+ ) -> Tuple[List[MCPServer], str]:
"""
Resolve allowed MCP servers for a tool call with IP filtering.
@@ -245,10 +273,10 @@ if MCP_AVAILABLE:
server_id: The server ID to validate access for
Returns:
- List of allowed MCPServer objects
+ Tuple of (allowed MCPServer objects, canonical server_id)
Raises:
- HTTPException: If the server_id is not allowed
+ HTTPException: If the server_id is not allowed or not found
"""
# Get all auth contexts
auth_contexts = await build_effective_auth_contexts(user_api_key_dict)
@@ -268,8 +296,41 @@ if MCP_AVAILABLE:
)
)
- # Check if the specified server_id is allowed
- if server_id not in allowed_server_ids_set:
+ canonical_server_id = _resolve_mcp_server_id_for_rest(
+ server_id, allowed_server_ids_set, _rest_client_ip
+ )
+
+ if canonical_server_id not in allowed_server_ids_set:
+ _server = global_mcp_server_manager.get_mcp_server_by_id(
+ server_id
+ ) or global_mcp_server_manager.get_mcp_server_by_name(server_id)
+ if (
+ _server is not None
+ and _rest_client_ip is not None
+ and not global_mcp_server_manager._is_server_accessible_from_ip(
+ _server, _rest_client_ip
+ )
+ ):
+ raise HTTPException(
+ status_code=403,
+ detail={
+ "error": "ip_filtering",
+ "message": (
+ f"MCP server '{server_id}' is not accessible from your IP address "
+ f"({_rest_client_ip}). This server is restricted to internal "
+ "networks only. To make it externally accessible, set "
+ "'available_on_public_internet: true' in the server configuration."
+ ),
+ },
+ )
+ if _server is None:
+ raise HTTPException(
+ status_code=404,
+ detail={
+ "error": "server_not_found",
+ "message": f"MCP server '{server_id}' was not found",
+ },
+ )
raise HTTPException(
status_code=403,
detail={
@@ -285,7 +346,7 @@ if MCP_AVAILABLE:
if server is not None:
allowed_mcp_servers.append(server)
- return allowed_mcp_servers
+ return allowed_mcp_servers, canonical_server_id
async def _get_tools_for_single_server(
server,
@@ -301,6 +362,7 @@ if MCP_AVAILABLE:
extra_headers=extra_headers,
add_prefix=False,
raw_headers=raw_headers,
+ user_api_key_auth=user_api_key_auth,
)
# Filter tools based on allowed_tools configuration
@@ -753,7 +815,7 @@ if MCP_AVAILABLE:
},
)
- tool_arguments = data.get("arguments")
+ tool_arguments = data.get("arguments") or {}
proxy_base_llm_response_processor = ProxyBaseLLMRequestProcessing(data=data)
(
@@ -786,14 +848,18 @@ if MCP_AVAILABLE:
data["user_api_key_auth"] = data["metadata"]["user_api_key_auth"]
# Resolve allowed MCP servers with IP filtering
- allowed_mcp_servers = await _resolve_allowed_mcp_servers_with_ip_filter(
+ (
+ allowed_mcp_servers,
+ canonical_server_id,
+ ) = await _resolve_allowed_mcp_servers_with_ip_filter(
request, user_api_key_dict, server_id
)
# Look up per-user OAuth headers for this server (mirrors list_tool_rest_api).
user_oauth_extra_headers: Optional[Dict[str, str]] = None
target_server = next(
- (s for s in allowed_mcp_servers if s.server_id == server_id), None
+ (s for s in allowed_mcp_servers if s.server_id == canonical_server_id),
+ None,
)
if target_server is not None:
user_oauth_extra_headers = await _get_user_oauth_extra_headers(
@@ -812,6 +878,7 @@ if MCP_AVAILABLE:
oauth2_headers=user_oauth_extra_headers or data.get("oauth2_headers"),
raw_headers=data.get("raw_headers"),
litellm_logging_obj=data.get("litellm_logging_obj"),
+ requested_server_id=canonical_server_id,
)
return result
except BlockedPiiEntityError as e:
diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py
index 0a74a92f9ce..f31005be0cb 100644
--- a/litellm/proxy/_experimental/mcp_server/server.py
+++ b/litellm/proxy/_experimental/mcp_server/server.py
@@ -1114,10 +1114,16 @@ if MCP_AVAILABLE:
) -> Tuple[Optional[Union[Dict[str, str], str]], Optional[Dict[str, str]]]:
"""Build auth and extra headers for a server."""
server_auth_header: Optional[Union[Dict[str, str], str]] = None
- if mcp_server_auth_headers and server.alias is not None:
- server_auth_header = mcp_server_auth_headers.get(server.alias)
- elif mcp_server_auth_headers and server.server_name is not None:
- server_auth_header = mcp_server_auth_headers.get(server.server_name)
+ if mcp_server_auth_headers:
+ from litellm.proxy._experimental.mcp_server.utils import (
+ lookup_mcp_server_auth_in_headers,
+ )
+
+ server_auth_header = lookup_mcp_server_auth_in_headers(
+ mcp_server_auth_headers,
+ alias=server.alias,
+ server_name=server.server_name,
+ )
extra_headers: Optional[Dict[str, str]] = None
if server.auth_type == MCPAuth.oauth2:
@@ -1159,7 +1165,7 @@ if MCP_AVAILABLE:
def _merge_gateway_initialize_instructions(
allowed_mcp_servers: List[MCPServer],
) -> Optional[str]:
- """YAML/DB override, else in-memory upstream text from list_tools / health_check / call_tool."""
+ """YAML/DB override, else upstream text (prefetch on init, or list_tools / health_check / call_tool cache)."""
if not allowed_mcp_servers:
return None
@@ -1200,6 +1206,20 @@ if MCP_AVAILABLE:
mcp_servers=mcp_servers,
client_ip=client_ip,
)
+ if allowed:
+ # return_exceptions=True: a per-server probe failure (incl. CancelledError
+ # bubbled from anyio task group teardown on connection refused) must not
+ # cancel sibling probes or 500 the gateway initialize request.
+ await asyncio.gather(
+ *[
+ global_mcp_server_manager._ensure_upstream_initialize_instructions_cached(
+ s
+ )
+ for s in allowed
+ if s is not None
+ ],
+ return_exceptions=True,
+ )
merged = _merge_gateway_initialize_instructions(allowed_mcp_servers=allowed)
tok = _mcp_gateway_initialize_instructions.set(merged)
try:
@@ -1368,6 +1388,7 @@ if MCP_AVAILABLE:
extra_headers=extra_headers,
add_prefix=True, # Always add server prefix
raw_headers=raw_headers,
+ user_api_key_auth=user_api_key_auth,
)
filtered_tools = filter_tools_by_allowed_tools(tools, server)
@@ -2074,6 +2095,7 @@ if MCP_AVAILABLE:
"""
# Track resolved MCP server for both permission checks and dispatch
mcp_server: Optional[MCPServer] = None
+ requested_server_id: Optional[str] = kwargs.get("requested_server_id")
# If the client called with a display-name override (e.g. "Get Pet"),
# translate it back to the original prefixed name before any routing.
@@ -2082,14 +2104,55 @@ if MCP_AVAILABLE:
# Remove prefix from tool name for logging and processing
original_tool_name, server_name = split_server_prefix_from_name(name)
+ requested_server: Optional[MCPServer] = None
+ if requested_server_id:
+ requested_server = next(
+ (s for s in allowed_mcp_servers if s.server_id == requested_server_id),
+ 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
+ # 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
+
# Only enforce server-level permissions when we can resolve a server
if server_name:
if not MCPRequestHandler.is_tool_allowed(
diff --git a/litellm/proxy/_experimental/mcp_server/utils.py b/litellm/proxy/_experimental/mcp_server/utils.py
index df5705c3425..b8b9207555e 100644
--- a/litellm/proxy/_experimental/mcp_server/utils.py
+++ b/litellm/proxy/_experimental/mcp_server/utils.py
@@ -2,7 +2,8 @@
MCP Server Utilities
"""
-from typing import Any, Dict, Iterator, Mapping, Optional, Tuple
+import re
+from typing import Any, Dict, Iterator, Mapping, Optional, Tuple, Union
import hashlib
import importlib
@@ -117,6 +118,50 @@ def normalize_server_name(server_name: str) -> str:
return server_name.replace(" ", "_")
+_MCP_ALIAS_HEADER_INVALID_RE = re.compile(r"[^a-z0-9_]")
+
+
+def sanitize_mcp_alias_for_header(alias: str) -> str:
+ """
+ Sanitize an MCP server alias for x-mcp-{alias}-{header} HTTP headers.
+
+ Must stay in sync with ui/litellm-dashboard/src/utils/mcpHeaderUtils.ts.
+ """
+ sanitized = _MCP_ALIAS_HEADER_INVALID_RE.sub("_", alias.lower().strip())
+ sanitized = re.sub(r"_+", "_", sanitized)
+ return sanitized.strip("_")
+
+
+def lookup_mcp_server_auth_in_headers(
+ mcp_server_auth_headers: Mapping[str, Union[str, Dict[str, str]]],
+ *,
+ alias: Optional[str] = None,
+ server_name: Optional[str] = None,
+) -> Optional[Union[str, Dict[str, str]]]:
+ """
+ Resolve server-specific auth headers with case-insensitive matching.
+
+ Tries the raw alias/server_name (lowercased) and the header-safe sanitized
+ alias so dashboard clients using sanitize_mcp_alias_for_header() still match.
+ """
+ if not mcp_server_auth_headers:
+ return None
+
+ normalized_headers = {k.lower(): v for k, v in mcp_server_auth_headers.items()}
+
+ for identifier in (alias, server_name):
+ if not identifier:
+ continue
+ keys_to_try = [identifier.lower()]
+ sanitized = sanitize_mcp_alias_for_header(identifier)
+ if sanitized and sanitized not in keys_to_try:
+ keys_to_try.append(sanitized)
+ for key in keys_to_try:
+ if key in normalized_headers:
+ return normalized_headers[key]
+ return None
+
+
def validate_and_normalize_mcp_server_payload(payload: Any) -> None:
"""
Validate and normalize MCP server payload fields (server_name and alias).
diff --git a/litellm/proxy/_experimental/out/404.html b/litellm/proxy/_experimental/out/404.html
index 32fb9e1d0a7..dc8887d33e3 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 32fb9e1d0a7..dc8887d33e3 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