diff --git a/.circleci/config.yml b/.circleci/config.yml
index 3139bd3cb26..cf69ff68da6 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -2477,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
@@ -2497,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
@@ -2533,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 49a36aa23f0..2d4e85630dc 100644
--- a/.github/workflows/test-unit-proxy-db.yml
+++ b/.github/workflows/test-unit-proxy-db.yml
@@ -218,6 +218,7 @@ jobs:
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/docker/Dockerfile.non_root b/docker/Dockerfile.non_root
index 4de4a55981d..2729babb6d6 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/__init__.py b/litellm/__init__.py
index d8d48b5865f..7c92623358d 100644
--- a/litellm/__init__.py
+++ b/litellm/__init__.py
@@ -413,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
@@ -636,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()
@@ -903,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)
@@ -1014,6 +1023,7 @@ model_list = list(
| ovhcloud_models
| lemonade_models
| docker_model_runner_models
+ | reducto_models
| bedrock_mantle_models
| set(clarifai_models)
)
@@ -1120,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,
}
@@ -1866,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,
)
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/_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/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py
index e3cbf422e5d..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)
@@ -1237,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 2257861aff6..98e00cf5788 100644
--- a/litellm/cost_calculator.py
+++ b/litellm/cost_calculator.py
@@ -1879,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
@@ -1890,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/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/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 e1a3cecfce5..81fdc5a1e21 100644
--- a/litellm/integrations/opentelemetry.py
+++ b/litellm/integrations/opentelemetry.py
@@ -702,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)
@@ -726,9 +734,57 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
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,
@@ -750,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)
@@ -937,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."""
@@ -1023,8 +1082,10 @@ 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.
@@ -1617,6 +1678,37 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
"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_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))
@@ -2962,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,
@@ -3012,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 2c63455565c..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,
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/agents/main.py b/litellm/interactions/agents/main.py
index 7375fd6273f..f56c6f3ed5e 100644
--- a/litellm/interactions/agents/main.py
+++ b/litellm/interactions/agents/main.py
@@ -49,7 +49,6 @@ from litellm.types.interactions import InteractionEnvironment
from litellm.types.router import GenericLiteLLMParams
from litellm.utils import client
-
# ------------------------------------------------------------------ #
# Shared helpers #
# ------------------------------------------------------------------ #
diff --git a/litellm/interactions/main.py b/litellm/interactions/main.py
index c6eca410fa7..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="...")
"""
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 3ee56dfc5ca..32ae61d7f58 100644
--- a/litellm/litellm_core_utils/prompt_templates/common_utils.py
+++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py
@@ -1204,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 a29f5005570..f169f86079a 100644
--- a/litellm/litellm_core_utils/prompt_templates/factory.py
+++ b/litellm/litellm_core_utils/prompt_templates/factory.py
@@ -5590,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/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_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/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/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/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 2c0dc834144..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
"""
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/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py
index d2af0a3dd52..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,
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/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/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/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/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/transformation.py b/litellm/llms/gemini/agents/transformation.py
index 150918c4737..f6e0b95cf28 100644
--- a/litellm/llms/gemini/agents/transformation.py
+++ b/litellm/llms/gemini/agents/transformation.py
@@ -23,7 +23,6 @@ from litellm.types.agents import (
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")
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/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/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 3d532113ba0..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.
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 2995edd1e07..4f5846cc5b6 100644
--- a/litellm/llms/vertex_ai/gemini/transformation.py
+++ b/litellm/llms/vertex_ai/gemini/transformation.py
@@ -1073,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:
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 eb67e3aa828..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",
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 b5364f8ba17..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
@@ -5796,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:
@@ -6602,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,
@@ -6643,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 41f73ddca5e..62e576ea0f7 100644
--- a/litellm/model_prices_and_context_window_backup.json
+++ b/litellm/model_prices_and_context_window_backup.json
@@ -6054,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",
@@ -13982,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",
@@ -14248,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,
@@ -26267,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",
@@ -29122,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",
diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
index bbf40f6e9ef..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:
@@ -2707,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:
@@ -3136,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 09176f7253a..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
@@ -43,6 +43,33 @@ _DEFAULT_NATIVE_REDIRECT_URIS: List[str] = [
_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()
@@ -118,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
@@ -139,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:
@@ -293,6 +321,180 @@ def _matches_trusted_native_redirect_uri(parsed) -> bool:
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, (c) listed in the
@@ -316,98 +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"):
- if _matches_trusted_native_redirect_uri(parsed):
- return
- 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. "
- "Trusted-native-redirect-uris env=%r. "
- "If this should be accepted, either align ingress X-Forwarded-* "
- "with the browser URL, set PROXY_BASE_URL to your public origin, "
- "add the redirect_uri host to MCP_TRUSTED_REDIRECT_ORIGINS, or "
- "for native MCP clients (cursor://, etc.) add the full redirect_uri "
- "to MCP_TRUSTED_NATIVE_REDIRECT_URIS.",
- 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),
- os.environ.get(_TRUSTED_NATIVE_REDIRECT_URIS_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 7150dee10cf..cec5224e183 100644
--- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py
+++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py
@@ -62,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
diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py
index 5676aaf0d22..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:
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/index.html b/litellm/proxy/_experimental/out/404.html
similarity index 98%
rename from litellm/proxy/_experimental/out/404/index.html
rename to litellm/proxy/_experimental/out/404.html
index 46e13ca9931..38a2c3bd836 100644
--- a/litellm/proxy/_experimental/out/404/index.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