diff --git a/docs/my-website/docs/index.md b/docs/my-website/docs/index.md index 2f9ed281b49..111dcf9c437 100644 --- a/docs/my-website/docs/index.md +++ b/docs/my-website/docs/index.md @@ -9,6 +9,10 @@ import TabItem from '@theme/TabItem'; import NavigationCards from '@site/src/components/NavigationCards'; import Image from '@theme/IdealImage'; +:::note Security Update +The Trivy supply-chain compromise has been contained :tada: . All affected packages have been deleted and current releases are free of the compromised code/component. Please refer to our [Security Townhall](/blog/security-townhall-updates) for a deeper understanding of the problem, and [CI/CD v2](/blog/ci-cd-v2-improvements) for how we're improving moving forward. +::: + **LiteLLM** is an open-source library that gives you a single, unified interface to call 100+ LLMs — OpenAI, Anthropic, Vertex AI, Bedrock, and more — using the OpenAI format. diff --git a/docs/my-website/docs/providers/github_copilot.md b/docs/my-website/docs/providers/github_copilot.md index e9fd3444f5f..1100afcc68c 100644 --- a/docs/my-website/docs/providers/github_copilot.md +++ b/docs/my-website/docs/providers/github_copilot.md @@ -192,6 +192,13 @@ export GITHUB_COPILOT_ACCESS_TOKEN_FILE="access-token" # Optional: Custom API key file name export GITHUB_COPILOT_API_KEY_FILE="api-key.json" + +# Optional: Custom Copilot endpoints for authentication and usage +# (needed when using GitHub Enterprise subscriptions with custom endpoints or self-hosted GitHub servers +export GITHUB_COPILOT_API_BASE="https://copilot-api.my-company.ghe.com" +export GITHUB_COPILOT_DEVICE_CODE_URL="https://my-company.ghe.com/login/device/code" +export GITHUB_COPILOT_ACCESS_TOKEN_URL="https://my-company.ghe.com/login/oauth/access_token" +export GITHUB_COPILOT_API_KEY_URL="https://my-company.ghe.com/api/v3/copilot_internal/v2/token" ``` ### Headers diff --git a/docs/my-website/docs/proxy/config_settings.md b/docs/my-website/docs/proxy/config_settings.md index 7a4c63b9515..be819a04ca1 100644 --- a/docs/my-website/docs/proxy/config_settings.md +++ b/docs/my-website/docs/proxy/config_settings.md @@ -720,6 +720,11 @@ router_settings: | GITHUB_COPILOT_TOKEN_DIR | Directory to store GitHub Copilot token for `github_copilot` llm provider | GITHUB_COPILOT_API_KEY_FILE | File to store GitHub Copilot API key for `github_copilot` llm provider | GITHUB_COPILOT_ACCESS_TOKEN_FILE | File to store GitHub Copilot access token for `github_copilot` llm provider +| GITHUB_COPILOT_API_BASE | Base URL for GitHub Copilot API. For GitHub Enterprise subscriptions with custom host, it is similar to https://copilot-api.my-company.ghe.com. Default is https://api.githubcopilot.com +| GITHUB_COPILOT_DEVICE_CODE_URL | URL for GitHub Copilot device code authentication. For GitHub Enterprise subscriptions with custom host, it is similar to https://my-company.ghe.com/login/device/code. Default is https://github.com/login/device/code +| GITHUB_COPILOT_ACCESS_TOKEN_URL | URL for GitHub Copilot access token retrieval. For GitHub Enterprise subscriptions with custom host, it is similar to https://my-company.ghe.com/login/oauth/access_token. Default is https://github.com/login/oauth/access_token +| GITHUB_COPILOT_API_KEY_URL | URL for GitHub Copilot API key retrieval. For GitHub Enterprise subscriptions with custom host, it is similar to https://my-company.ghe.com/api/v3/copilot_internal/v2/token. Default is https://api.github.com/copilot_internal/v2/token +| GITHUB_COPILOT_CLIENT_ID | Client ID for GitHub Copilot device flow authentication. This is used by the `github_copilot` provider for device code authentication. Default is "Iv1.b507a08c87ecfe98" | GREENSCALE_API_KEY | API key for Greenscale service | GREENSCALE_ENDPOINT | Endpoint URL for Greenscale service | GRAYSWAN_API_BASE | Base URL for GraySwan API. Default is https://api.grayswan.ai diff --git a/docs/my-website/src/pages/index.md b/docs/my-website/src/pages/index.md index 5dc4ba2d4d4..5329b365c8c 100644 --- a/docs/my-website/src/pages/index.md +++ b/docs/my-website/src/pages/index.md @@ -1,6 +1,10 @@ import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; +:::note Security Update +The Trivy supply-chain compromise has been contained :tada: . All affected packages have been deleted and current releases are free of the compromised code/component. Please refer to our [Security Townhall](/blog/security-townhall-updates) for a deeper understanding of the problem, and [CI/CD v2](/blog/ci-cd-v2-improvements) for how we're improving moving forward. +::: + # LiteLLM - Getting Started https://github.com/BerriAI/litellm diff --git a/litellm-proxy-extras/pyproject.toml b/litellm-proxy-extras/pyproject.toml index 5931a9821bd..88ec8e6de96 100644 --- a/litellm-proxy-extras/pyproject.toml +++ b/litellm-proxy-extras/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "litellm-proxy-extras" -version = "0.4.65" +version = "0.4.66" description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package." readme = "README.md" requires-python = ">=3.9" @@ -25,7 +25,7 @@ required-version = "==0.10.9" module-root = "" [tool.commitizen] -version = "0.4.65" +version = "0.4.66" version_files = [ "pyproject.toml:^version", "../pyproject.toml:litellm-proxy-extras==", diff --git a/litellm/llms/github_copilot/authenticator.py b/litellm/llms/github_copilot/authenticator.py index 85c22516f95..f4698861edc 100644 --- a/litellm/llms/github_copilot/authenticator.py +++ b/litellm/llms/github_copilot/authenticator.py @@ -17,11 +17,11 @@ from .common_utils import ( RefreshAPIKeyError, ) -# Constants -GITHUB_CLIENT_ID = "Iv1.b507a08c87ecfe98" -GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code" -GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token" -GITHUB_API_KEY_URL = "https://api.github.com/copilot_internal/v2/token" +# Constants (default values — overridable via environment variables at call time) +DEFAULT_GITHUB_CLIENT_ID = "Iv1.b507a08c87ecfe98" +DEFAULT_GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code" +DEFAULT_GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token" +DEFAULT_GITHUB_API_KEY_URL = "https://api.github.com/copilot_internal/v2/token" class Authenticator: @@ -161,12 +161,15 @@ class Authenticator: """ access_token = self.get_access_token() headers = self._get_github_headers(access_token) + api_key_url = os.getenv( + "GITHUB_COPILOT_API_KEY_URL", DEFAULT_GITHUB_API_KEY_URL + ) max_retries = 3 for attempt in range(max_retries): try: sync_client = _get_httpx_client() - response = sync_client.get(GITHUB_API_KEY_URL, headers=headers) + response = sync_client.get(api_key_url, headers=headers) response.raise_for_status() response_json = response.json() @@ -232,10 +235,14 @@ class Authenticator: """ try: sync_client = _get_httpx_client() + device_code_url = os.getenv( + "GITHUB_COPILOT_DEVICE_CODE_URL", DEFAULT_GITHUB_DEVICE_CODE_URL + ) + client_id = os.getenv("GITHUB_COPILOT_CLIENT_ID", DEFAULT_GITHUB_CLIENT_ID) resp = sync_client.post( - GITHUB_DEVICE_CODE_URL, + device_code_url, headers=self._get_github_headers(), - json={"client_id": GITHUB_CLIENT_ID, "scope": "read:user"}, + json={"client_id": client_id, "scope": "read:user"}, ) resp.raise_for_status() resp_json = resp.json() @@ -284,13 +291,20 @@ class Authenticator: sync_client = _get_httpx_client() max_attempts = 12 # 1 minute (12 * 5 seconds) + access_token_url = os.getenv( + "GITHUB_COPILOT_ACCESS_TOKEN_URL", DEFAULT_GITHUB_ACCESS_TOKEN_URL + ) + client_id = os.getenv( + "GITHUB_COPILOT_CLIENT_ID", DEFAULT_GITHUB_CLIENT_ID + ) + for attempt in range(max_attempts): try: resp = sync_client.post( - GITHUB_ACCESS_TOKEN_URL, + access_token_url, headers=self._get_github_headers(), json={ - "client_id": GITHUB_CLIENT_ID, + "client_id": client_id, "device_code": device_code, "grant_type": "urn:ietf:params:oauth:grant-type:device_code", }, diff --git a/litellm/llms/github_copilot/chat/transformation.py b/litellm/llms/github_copilot/chat/transformation.py index be8ad7d0877..6651a3c60b7 100644 --- a/litellm/llms/github_copilot/chat/transformation.py +++ b/litellm/llms/github_copilot/chat/transformation.py @@ -1,5 +1,6 @@ from typing import List, Optional, Tuple +import os from litellm.exceptions import AuthenticationError from litellm.llms.openai.openai import OpenAIConfig @@ -7,7 +8,7 @@ from litellm.types.llms.openai import AllMessageValues from ..authenticator import Authenticator from ..common_utils import ( - GITHUB_COPILOT_API_BASE, + DEFAULT_GITHUB_COPILOT_API_BASE, GetAPIKeyError, get_copilot_default_headers, ) @@ -30,7 +31,12 @@ class GithubCopilotConfig(OpenAIConfig): api_key: Optional[str], custom_llm_provider: str, ) -> Tuple[Optional[str], Optional[str], str]: - dynamic_api_base = self.authenticator.get_api_base() or GITHUB_COPILOT_API_BASE + dynamic_api_base = ( + api_base + or self.authenticator.get_api_base() + or os.getenv("GITHUB_COPILOT_API_BASE") + or DEFAULT_GITHUB_COPILOT_API_BASE + ) try: dynamic_api_key = self.authenticator.get_api_key() except GetAPIKeyError as e: diff --git a/litellm/llms/github_copilot/common_utils.py b/litellm/llms/github_copilot/common_utils.py index d3169e3ca94..2413cdd63d7 100644 --- a/litellm/llms/github_copilot/common_utils.py +++ b/litellm/llms/github_copilot/common_utils.py @@ -1,6 +1,7 @@ """ Constants for Copilot integration """ + from typing import Optional, Union from uuid import uuid4 @@ -13,7 +14,7 @@ COPILOT_VERSION = "0.26.7" EDITOR_PLUGIN_VERSION = f"copilot-chat/{COPILOT_VERSION}" USER_AGENT = f"GitHubCopilotChat/{COPILOT_VERSION}" API_VERSION = "2025-04-01" -GITHUB_COPILOT_API_BASE = "https://api.githubcopilot.com" +DEFAULT_GITHUB_COPILOT_API_BASE = "https://api.githubcopilot.com" class GithubCopilotError(BaseLLMException): diff --git a/litellm/llms/github_copilot/embedding/transformation.py b/litellm/llms/github_copilot/embedding/transformation.py index fa7bd4e3223..da2dc339d6e 100644 --- a/litellm/llms/github_copilot/embedding/transformation.py +++ b/litellm/llms/github_copilot/embedding/transformation.py @@ -6,8 +6,11 @@ This module provides the configuration for GitHub Copilot's Embedding API. Implementation based on analysis of the copilot-api project by caozhiyuan: https://github.com/caozhiyuan/copilot-api """ + from typing import TYPE_CHECKING, Any, Optional +import os + import httpx from litellm._logging import verbose_logger @@ -20,7 +23,7 @@ from litellm.utils import convert_to_model_response_object from ..authenticator import Authenticator from ..common_utils import ( GetAPIKeyError, - GITHUB_COPILOT_API_BASE, + DEFAULT_GITHUB_COPILOT_API_BASE, get_copilot_default_headers, ) @@ -99,15 +102,18 @@ class GithubCopilotEmbeddingConfig(BaseEmbeddingConfig): Get the complete URL for GitHub Copilot Embedding API endpoint. """ # Use provided api_base or fall back to authenticator's base or default - api_base = ( - self.authenticator.get_api_base() or api_base or GITHUB_COPILOT_API_BASE + effective_api_base = ( + api_base + or self.authenticator.get_api_base() + or os.getenv("GITHUB_COPILOT_API_BASE") + or DEFAULT_GITHUB_COPILOT_API_BASE ) # Remove trailing slashes - api_base = api_base.rstrip("/") + effective_api_base = effective_api_base.rstrip("/") # Return the embeddings endpoint - return f"{api_base}/embeddings" + return f"{effective_api_base}/embeddings" def transform_embedding_request( self, diff --git a/litellm/llms/github_copilot/responses/transformation.py b/litellm/llms/github_copilot/responses/transformation.py index 46efc124b1d..0929f95cf43 100644 --- a/litellm/llms/github_copilot/responses/transformation.py +++ b/litellm/llms/github_copilot/responses/transformation.py @@ -7,8 +7,11 @@ which is required for models like gpt-5.1-codex that only support the /responses Implementation based on analysis of the copilot-api project by caozhiyuan: https://github.com/caozhiyuan/copilot-api """ + from typing import TYPE_CHECKING, Any, Dict, Optional, Union +import os + from litellm._logging import verbose_logger from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH from litellm.exceptions import AuthenticationError @@ -22,7 +25,7 @@ from litellm.types.utils import LlmProviders from ..authenticator import Authenticator from ..common_utils import ( - GITHUB_COPILOT_API_BASE, + DEFAULT_GITHUB_COPILOT_API_BASE, GetAPIKeyError, get_copilot_default_headers, ) @@ -157,23 +160,20 @@ class GithubCopilotResponsesAPIConfig(OpenAIResponsesAPIConfig): ) -> str: """ Get the complete URL for GitHub Copilot Responses API endpoint. - - Returns: https://api.githubcopilot.com/responses - - Note: Currently only supports individual accounts. - Business/enterprise accounts (api.business.githubcopilot.com) can be - added in the future by detecting account type. """ # Use provided api_base or fall back to authenticator's base or default - api_base = ( - api_base or self.authenticator.get_api_base() or GITHUB_COPILOT_API_BASE + effective_api_base = ( + api_base + or self.authenticator.get_api_base() + or os.getenv("GITHUB_COPILOT_API_BASE") + or DEFAULT_GITHUB_COPILOT_API_BASE ) # Remove trailing slashes - api_base = api_base.rstrip("/") + effective_api_base = effective_api_base.rstrip("/") # Return the responses endpoint - return f"{api_base}/responses" + return f"{effective_api_base}/responses" def _handle_reasoning_item(self, item: Dict[str, Any]) -> Dict[str, Any]: """ diff --git a/litellm/llms/mistral/ocr/transformation.py b/litellm/llms/mistral/ocr/transformation.py index 3d5e8763027..21e0e27a314 100644 --- a/litellm/llms/mistral/ocr/transformation.py +++ b/litellm/llms/mistral/ocr/transformation.py @@ -1,6 +1,7 @@ """ Mistral OCR transformation implementation. """ + from typing import Any, Dict, Optional import httpx @@ -36,8 +37,12 @@ class MistralOCRConfig(BaseOCRConfig): - image_min_size: Minimum size of images to include - bbox_annotation_format: Format for bounding box annotations - document_annotation_format: Format for document annotations + - document_annotation_prompt: Prompt for document annotation extraction - extract_header: Whether to extract document header - extract_footer: Whether to extract document footer + - table_format: Table output format ("markdown" or "html") + - confidence_scores_granularity: Confidence score level ("word" or "page") + - id: Request identifier """ return [ "pages", @@ -46,8 +51,12 @@ class MistralOCRConfig(BaseOCRConfig): "image_min_size", "bbox_annotation_format", "document_annotation_format", + "document_annotation_prompt", "extract_header", "extract_footer", + "table_format", + "confidence_scores_granularity", + "id", ] def map_ocr_params( diff --git a/litellm/llms/together_ai/chat.py b/litellm/llms/together_ai/chat.py index e8a784d2779..7efb12fc1b2 100644 --- a/litellm/llms/together_ai/chat.py +++ b/litellm/llms/together_ai/chat.py @@ -8,7 +8,7 @@ Docs: https://docs.together.ai/reference/completions-1 from typing import Optional -from litellm.utils import get_model_info +from litellm.utils import supports_function_calling from litellm._logging import verbose_logger from ..openai.chat.gpt_transformation import OpenAIGPTConfig @@ -21,18 +21,23 @@ class TogetherAIConfig(OpenAIGPTConfig): Docs: https://docs.together.ai/docs/json-mode """ - supports_function_calling: Optional[bool] = None + # Use supports_function_calling() — which reads _get_model_info_helper + # directly — instead of get_model_info(). get_model_info() calls + # get_supported_openai_params() as its first step, which routes back + # into this method for together_ai models, creating a recursion that + # only terminates when Python's recursion limit or the "not mapped" + # exception in _get_model_info_helper is hit (~332 deep calls). + supports_fc: Optional[bool] = None try: - model_info = get_model_info(model, custom_llm_provider="together_ai") - supports_function_calling = model_info.get( - "supports_function_calling", False + supports_fc = supports_function_calling( + model, custom_llm_provider="together_ai" ) except Exception as e: verbose_logger.debug(f"Error getting supported openai params: {e}") pass optional_params = super().get_supported_openai_params(model) - if supports_function_calling is not True: + if supports_fc is not True: verbose_logger.debug( "Only some together models support function calling/response_format. Docs - https://docs.together.ai/docs/function-calling" ) diff --git a/litellm/router.py b/litellm/router.py index 9185e437a3a..5ec89403b74 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -8702,6 +8702,8 @@ class Router: and self.routing_strategy == "latency-based-routing" ): _settings_to_return[var] = self.lowestlatency_logger.routing_args.json() + elif var == "routing_strategy_args": + _settings_to_return[var] = None return _settings_to_return def update_settings(self, **kwargs): diff --git a/pyproject.toml b/pyproject.toml index 6a446e0bf69..79c66897b13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "litellm" -version = "1.83.8" +version = "1.83.9" description = "Library to easily interface with LLM API providers" readme = "README.md" requires-python = ">=3.9, <3.14" @@ -52,7 +52,7 @@ proxy = [ "azure-identity==1.25.2; python_version >= '3.9'", "azure-storage-blob==12.28.0", "mcp==1.26.0; python_version >= '3.10'", - "litellm-proxy-extras==0.4.65", + "litellm-proxy-extras==0.4.66", "litellm-enterprise==0.1.37", "RestrictedPython==8.1", "rich==13.9.4", @@ -243,7 +243,7 @@ source-exclude = [ profile = "black" [tool.commitizen] -version = "1.83.8" +version = "1.83.9" version_files = [ "pyproject.toml:^version", ] diff --git a/tests/llm_translation/test_bedrock_moonshot.py b/tests/llm_translation/test_bedrock_moonshot.py index c6066c7db42..a9f4a86b3b6 100644 --- a/tests/llm_translation/test_bedrock_moonshot.py +++ b/tests/llm_translation/test_bedrock_moonshot.py @@ -16,10 +16,13 @@ import pytest import sys import os import json +from typing import Optional +from unittest.mock import AsyncMock, Mock, patch sys.path.insert(0, os.path.abspath("../..")) import litellm from litellm.llms.bedrock.common_utils import get_bedrock_chat_config +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler class TestBedrockMoonshotInvoke(BaseLLMChatTest): @@ -27,17 +30,255 @@ class TestBedrockMoonshotInvoke(BaseLLMChatTest): Test suite for Bedrock Moonshot via invoke route. Inherits all standard LLM tests from BaseLLMChatTest. """ - + def get_base_completion_call_args(self) -> dict: litellm._turn_on_debug() return { "model": "bedrock/invoke/moonshot.kimi-k2-thinking", } - + def test_tool_call_no_arguments(self, tool_call_no_arguments): """Test that tool calls with no arguments is translated correctly.""" pass + # --------------------------------------------------------------------- + # The overrides below replace inherited BaseLLMChatTest tests that would + # otherwise make live AWS Bedrock calls. The live versions were + # consistently crashing llm_translation xdist workers. Each override + # patches the HTTP client's post() so no network request is sent, and + # asserts on the outgoing request body (and, where needed, parses a + # canned response) — which is what the translation lane is actually + # supposed to cover. + # --------------------------------------------------------------------- + + @staticmethod + def _make_moonshot_response(content: str = "Hi!") -> Mock: + """Build a Mock httpx.Response that AmazonMoonshotConfig.transform_response + (which delegates to MoonshotChatConfig → OpenAI) can parse.""" + body = { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 1234567890, + "model": "moonshot.kimi-k2-thinking", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": content}, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 15, + }, + } + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.headers = {"Content-Type": "application/json"} + mock_resp.text = json.dumps(body) + mock_resp.json = lambda: body + return mock_resp + + def _invoke_with_mocked_post( + self, + *, + messages: list, + extra_kwargs: Optional[dict] = None, + response_content: str = "Hi!", + ) -> "tuple[Mock, object]": + """Run a sync litellm.completion() with HTTPHandler.post patched to + return a canned moonshot response. Returns (mock_post, response).""" + client = HTTPHandler() + mock_resp = self._make_moonshot_response(content=response_content) + with patch.object( + client, "post", new=Mock(return_value=mock_resp) + ) as mock_post: + response = litellm.completion( + model="bedrock/invoke/moonshot.kimi-k2-thinking", + messages=messages, + aws_access_key_id="fake", + aws_secret_access_key="fake", + aws_region_name="us-west-2", + client=client, + **(extra_kwargs or {}), + ) + return mock_post, response + + def test_developer_role_translation(self): + """Verify LiteLLM maps the ``developer`` role to ``system`` on the + outgoing Bedrock invoke request, without hitting the network.""" + mock_post, response = self._invoke_with_mocked_post( + messages=[ + {"role": "developer", "content": "Be a good bot!"}, + {"role": "user", "content": "Hello, how are you?"}, + ], + ) + mock_post.assert_called_once() + body = json.loads(mock_post.call_args.kwargs["data"]) + assert body["messages"][0]["role"] == "system" + assert body["messages"][0]["content"] == "Be a good bot!" + assert body["messages"][1]["role"] == "user" + assert response.choices[0].message.content is not None + + def test_message_with_name(self): + """Verify a user message carrying a ``name`` field is serialized into + the outgoing Bedrock invoke request without breaking the call.""" + mock_post, response = self._invoke_with_mocked_post( + messages=[{"role": "user", "content": "Hello", "name": "test_name"}], + ) + mock_post.assert_called_once() + body = json.loads(mock_post.call_args.kwargs["data"]) + assert body["messages"][0]["role"] == "user" + assert body["messages"][0]["content"] == "Hello" + assert response is not None + + def test_content_list_handling(self): + """Verify the inherited content-list-handling test passes against a + mocked moonshot response (no network).""" + mock_post, response = self._invoke_with_mocked_post( + messages=[ + { + "role": "user", + "content": [{"type": "text", "text": "Hello, how are you?"}], + } + ], + ) + mock_post.assert_called_once() + assert response.choices[0].message.content is not None + + def test_pydantic_model_input(self): + """Verify a completion call with a pydantic ``Message`` as input does + not raise and produces a parseable response.""" + from litellm import Message + + mock_post, response = self._invoke_with_mocked_post( + messages=[Message(content="Hello, how are you?", role="user")], + ) + mock_post.assert_called_once() + assert response is not None + + @pytest.mark.parametrize("response_format", [{"type": "text"}]) + def test_response_format_type_text_with_tool_calls_no_tool_choice( + self, response_format + ): + """Verify response_format + tools + drop_params sends a valid request + and produces a response object.""" + tools = [ + { + "type": "function", + "function": { + "name": "get_current_weather", + "description": "Get the current weather in a given location", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA", + }, + "unit": { + "type": "string", + "enum": ["celsius", "fahrenheit"], + }, + }, + "required": ["location"], + }, + }, + } + ] + mock_post, response = self._invoke_with_mocked_post( + messages=[ + {"role": "user", "content": "What's the weather like in Boston today?"} + ], + extra_kwargs={ + "response_format": response_format, + "tools": tools, + "drop_params": True, + }, + ) + mock_post.assert_called_once() + body = json.loads(mock_post.call_args.kwargs["data"]) + assert "tools" in body + assert body["tools"][0]["function"]["name"] == "get_current_weather" + assert response is not None + + def test_streaming(self): + """Verify stream=True routes to the invoke-with-response-stream + endpoint with the messages body. Iteration of the stream itself is + not exercised here — moonshot streaming delegates to the OpenAI + parser and is covered by the OpenAI test suite. + + Note: bedrock invoke streaming cannot be intercepted by patching + the caller-supplied client, because ``CustomStreamWrapper.fetch_sync_stream`` + at streaming_handler.py invokes the stored ``make_call`` partial with + ``client=litellm.module_level_client``, which overrides any client the + caller passed. Patch ``make_sync_call`` at its import site in + ``base_invoke_transformation`` so we observe the exact kwargs the + partial was built with at stream-wrapper construction time. + """ + from litellm.utils import CustomStreamWrapper + + captured: dict = {} + + def fake_make_sync_call(**kwargs): + captured.update(kwargs) + # Return an empty iterator so the stream wrapper's iteration + # doesn't try to parse real bytes. + return iter([]) + + with patch( + "litellm.llms.bedrock.chat.invoke_transformations." + "base_invoke_transformation.make_sync_call", + new=fake_make_sync_call, + ): + response = litellm.completion( + model="bedrock/invoke/moonshot.kimi-k2-thinking", + messages=[ + { + "role": "user", + "content": [{"type": "text", "text": "Hello, how are you?"}], + } + ], + stream=True, + aws_access_key_id="fake", + aws_secret_access_key="fake", + aws_region_name="us-west-2", + ) + assert isinstance(response, CustomStreamWrapper) + # Trigger fetch_sync_stream → make_call(...) → fake_make_sync_call. + try: + next(iter(response)) + except StopIteration: + pass + + assert captured, "make_sync_call was never invoked" + assert captured["api_base"].endswith("/invoke-with-response-stream") + body = json.loads(captured["data"]) + # Bedrock invoke does not put stream=true in the body (the URL + # carries the streaming flag); verify the user message is present. + assert body["messages"][0]["role"] == "user" + + async def test_completion_cost(self): + """Verify LiteLLM computes a positive cost from a mocked Bedrock + Moonshot response, using the local model cost map.""" + os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + litellm.model_cost = litellm.get_model_cost_map(url="") + + mock_response = self._make_moonshot_response() + client = AsyncHTTPHandler() + with patch.object(client, "post", new=AsyncMock(return_value=mock_response)): + response = await litellm.acompletion( + model="bedrock/invoke/moonshot.kimi-k2-thinking", + messages=[{"role": "user", "content": "Hello, how are you?"}], + aws_access_key_id="fake", + aws_secret_access_key="fake", + aws_region_name="us-west-2", + client=client, + ) + + assert response._hidden_params["response_cost"] > 0 + class TestBedrockMoonshotBasic: """Unit tests for Bedrock Moonshot configuration and transformations.""" @@ -47,7 +288,7 @@ class TestBedrockMoonshotBasic: config = get_bedrock_chat_config("bedrock/invoke/moonshot.kimi-k2-thinking") assert config is not None assert config.__class__.__name__ == "AmazonMoonshotConfig" - + def test_provider_detection_converse(self): """Test that Bedrock Moonshot converse models are correctly detected.""" config = get_bedrock_chat_config("bedrock/moonshot.kimi-k2-thinking") @@ -62,8 +303,10 @@ class TestBedrockMoonshotBasic: def test_supported_params(self): """Test that supported OpenAI params are correctly defined.""" config = get_bedrock_chat_config("invoke/moonshot.kimi-k2-thinking") - supported_params = config.get_supported_openai_params("moonshot.kimi-k2-thinking") - + supported_params = config.get_supported_openai_params( + "moonshot.kimi-k2-thinking" + ) + # Should support these params assert "temperature" in supported_params assert "max_tokens" in supported_params @@ -71,10 +314,10 @@ class TestBedrockMoonshotBasic: assert "stream" in supported_params assert "tools" in supported_params assert "tool_choice" in supported_params - + # Should NOT support stop sequences on Bedrock assert "stop" not in supported_params - + # Should NOT support functions (use tools instead) assert "functions" not in supported_params @@ -83,20 +326,20 @@ class TestBedrockMoonshotBasic: from litellm.llms.bedrock.chat.invoke_transformations.amazon_moonshot_transformation import ( AmazonMoonshotConfig, ) - + config = AmazonMoonshotConfig() - + messages = [{"role": "user", "content": "Hello"}] - + # Test that bedrock/invoke/ prefix is stripped transformed = config.transform_request( model="bedrock/invoke/moonshot.kimi-k2-thinking", messages=messages, optional_params={}, litellm_params={}, - headers={} + headers={}, ) - + # The model ID in the request body should be stripped assert transformed["model"] == "moonshot.kimi-k2-thinking" @@ -109,21 +352,27 @@ class TestBedrockMoonshotReasoningContent: from litellm.llms.bedrock.chat.invoke_transformations.amazon_moonshot_transformation import ( AmazonMoonshotConfig, ) - + config = AmazonMoonshotConfig() - + # Test with reasoning tags - content_with_reasoning = "This is my thought processThis is the answer" - reasoning, content = config._extract_reasoning_from_content(content_with_reasoning) - + content_with_reasoning = ( + "This is my thought processThis is the answer" + ) + reasoning, content = config._extract_reasoning_from_content( + content_with_reasoning + ) + assert reasoning == "This is my thought process" assert content == "This is the answer" assert "" not in content - + # Test without reasoning tags content_without_reasoning = "This is just a regular answer" - reasoning, content = config._extract_reasoning_from_content(content_without_reasoning) - + reasoning, content = config._extract_reasoning_from_content( + content_without_reasoning + ) + assert reasoning is None assert content == "This is just a regular answer" @@ -134,8 +383,10 @@ class TestBedrockMoonshotToolCalling: def test_tool_calling_supported(self): """Test that tool calling is supported for Kimi K2 Thinking model.""" config = get_bedrock_chat_config("invoke/moonshot.kimi-k2-thinking") - supported_params = config.get_supported_openai_params("moonshot.kimi-k2-thinking") - + supported_params = config.get_supported_openai_params( + "moonshot.kimi-k2-thinking" + ) + # Kimi K2 Thinking DOES support tool calls (unlike kimi-thinking-preview) assert "tools" in supported_params assert "tool_choice" in supported_params @@ -145,13 +396,11 @@ class TestBedrockMoonshotToolCalling: from litellm.llms.bedrock.chat.invoke_transformations.amazon_moonshot_transformation import ( AmazonMoonshotConfig, ) - + config = AmazonMoonshotConfig() - - messages = [ - {"role": "user", "content": "What's the weather in San Francisco?"} - ] - + + messages = [{"role": "user", "content": "What's the weather in San Francisco?"}] + optional_params = { "tools": [ { @@ -161,27 +410,25 @@ class TestBedrockMoonshotToolCalling: "description": "Get the current weather", "parameters": { "type": "object", - "properties": { - "location": {"type": "string"} - }, - "required": ["location"] - } - } + "properties": {"location": {"type": "string"}}, + "required": ["location"], + }, + }, } ] } - + transformed = config.transform_request( model="bedrock/invoke/moonshot.kimi-k2-thinking", messages=messages, optional_params=optional_params, litellm_params={}, - headers={} + headers={}, ) - + # Verify model ID is stripped assert transformed["model"] == "moonshot.kimi-k2-thinking" - + # Verify tools are included assert "tools" in transformed assert len(transformed["tools"]) == 1 @@ -193,9 +440,9 @@ class TestBedrockMoonshotToolCalling: tool_response_message = { "role": "tool", "tool_call_id": "call_123", - "content": json.dumps({"temperature": 72, "condition": "sunny"}) + "content": json.dumps({"temperature": 72, "condition": "sunny"}), } - + # Verify the message structure assert tool_response_message["role"] == "tool" assert "tool_call_id" in tool_response_message @@ -208,8 +455,10 @@ class TestBedrockMoonshotParameterValidation: def test_stop_sequences_not_supported(self): """Test that stop sequences are correctly excluded from supported params.""" config = get_bedrock_chat_config("invoke/moonshot.kimi-k2-thinking") - supported_params = config.get_supported_openai_params("moonshot.kimi-k2-thinking") - + supported_params = config.get_supported_openai_params( + "moonshot.kimi-k2-thinking" + ) + # Bedrock Moonshot doesn't support stopSequences field assert "stop" not in supported_params @@ -218,10 +467,12 @@ class TestBedrockMoonshotParameterValidation: # Moonshot models support temperature 0-1 # This is handled by the parent MoonshotChatConfig class config = get_bedrock_chat_config("invoke/moonshot.kimi-k2-thinking") - + # Verify config exists and can handle temperature assert config is not None - supported_params = config.get_supported_openai_params("moonshot.kimi-k2-thinking") + supported_params = config.get_supported_openai_params( + "moonshot.kimi-k2-thinking" + ) assert "temperature" in supported_params @@ -233,34 +484,31 @@ class TestBedrockMoonshotTransformations: from litellm.llms.bedrock.chat.invoke_transformations.amazon_moonshot_transformation import ( AmazonMoonshotConfig, ) - + config = AmazonMoonshotConfig() - + messages = [ {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": "Hello!"} + {"role": "user", "content": "Hello!"}, ] - - optional_params = { - "temperature": 0.7, - "max_tokens": 100 - } - + + optional_params = {"temperature": 0.7, "max_tokens": 100} + transformed = config.transform_request( model="bedrock/invoke/moonshot.kimi-k2-thinking", messages=messages, optional_params=optional_params, litellm_params={}, - headers={} + headers={}, ) - + # Verify model ID is stripped assert transformed["model"] == "moonshot.kimi-k2-thinking" - + # Verify messages are included assert "messages" in transformed assert len(transformed["messages"]) >= 1 - + # Verify optional params are included assert transformed["temperature"] == 0.7 assert transformed["max_tokens"] == 100 @@ -270,21 +518,21 @@ class TestBedrockMoonshotTransformations: from litellm.llms.bedrock.chat.invoke_transformations.amazon_moonshot_transformation import ( AmazonMoonshotConfig, ) - + config = AmazonMoonshotConfig() - + messages = [ {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": "Hello!"} + {"role": "user", "content": "Hello!"}, ] - + transformed = config.transform_request( model="moonshot.kimi-k2-thinking", messages=messages, optional_params={}, litellm_params={}, - headers={} + headers={}, ) - + # System messages should be supported assert "messages" in transformed diff --git a/tests/llm_translation/test_together_ai.py b/tests/llm_translation/test_together_ai.py index 5225ab78f61..4ad0c90230d 100644 --- a/tests/llm_translation/test_together_ai.py +++ b/tests/llm_translation/test_together_ai.py @@ -20,7 +20,7 @@ import pytest class TestTogetherAI(BaseLLMChatTest): def get_base_completion_call_args(self) -> dict: litellm.set_verbose = True - return {"model": "together_ai/Qwen/Qwen3.5-9B"} + return {"model": "together_ai/Qwen/Qwen2.5-7B-Instruct-Turbo"} def test_tool_call_no_arguments(self, tool_call_no_arguments): """Test that tool calls with no arguments is translated correctly. Relevant issue: https://github.com/BerriAI/litellm/issues/6833""" diff --git a/tests/local_testing/test_completion.py b/tests/local_testing/test_completion.py index f18a2b4afbb..457385a3b0b 100644 --- a/tests/local_testing/test_completion.py +++ b/tests/local_testing/test_completion.py @@ -65,7 +65,7 @@ def test_completion_custom_provider_model_name(): try: litellm.cache = None response = completion( - model="together_ai/Qwen/Qwen3.5-9B", + model="together_ai/Qwen/Qwen2.5-7B-Instruct-Turbo", messages=messages, logger_fn=logger_fn, ) @@ -2815,7 +2815,7 @@ def test_customprompt_together_ai(): print(litellm.success_callback) print(litellm._async_success_callback) response = completion( - model="together_ai/Qwen/Qwen3.5-9B", + model="together_ai/Qwen/Qwen2.5-7B-Instruct-Turbo", messages=messages, roles={ "system": { @@ -3682,7 +3682,7 @@ def test_completion_together_ai_stream(): messages = [{"content": user_message, "role": "user"}] try: response = completion( - model="together_ai/Qwen/Qwen3.5-9B", + model="together_ai/Qwen/Qwen2.5-7B-Instruct-Turbo", messages=messages, stream=True, max_tokens=5, diff --git a/tests/local_testing/test_multiple_deployments.py b/tests/local_testing/test_multiple_deployments.py index 61baa73da04..f7276d4f14e 100644 --- a/tests/local_testing/test_multiple_deployments.py +++ b/tests/local_testing/test_multiple_deployments.py @@ -25,7 +25,7 @@ model_list = [ { "model_name": "mistral-7b-instruct", "litellm_params": { # params for litellm completion/embedding call - "model": "together_ai/Qwen/Qwen3.5-9B", + "model": "together_ai/Qwen/Qwen2.5-7B-Instruct-Turbo", "api_key": os.getenv("TOGETHERAI_API_KEY"), }, }, diff --git a/tests/local_testing/test_text_completion.py b/tests/local_testing/test_text_completion.py index dde5f67ea1c..ace5fed1100 100644 --- a/tests/local_testing/test_text_completion.py +++ b/tests/local_testing/test_text_completion.py @@ -4034,7 +4034,7 @@ def test_async_text_completion_together_ai(): async def test_get_response(): try: response = await litellm.atext_completion( - model="together_ai/Qwen/Qwen3.5-9B", + model="together_ai/Qwen/Qwen2.5-7B-Instruct-Turbo", prompt="good morning", max_tokens=10, ) diff --git a/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py b/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py index a76c4118214..5c5cae61a9d 100644 --- a/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py +++ b/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py @@ -316,7 +316,10 @@ def test_normalize_tool_input_schema_types_for_bedrock_invoke(): "type": "custom", "additionalProperties": False, "properties": { - "nested": {"type": "custom", "properties": {"x": {"type": "string"}}} + "nested": { + "type": "custom", + "properties": {"x": {"type": "string"}}, + } }, "required": ["nested"], }, @@ -385,34 +388,6 @@ def test_bedrock_invoke_messages_transform_adds_name_when_tool_missing_name(): assert result["tools"][0]["name"] == "litellm_unnamed_tool_0" -def test_bedrock_invoke_messages_injects_thinking_for_clear_thinking_context_management(): - """ - Bedrock requires extended thinking when ``clear_thinking_20251015`` appears in - ``context_management`` (Claude Code sends CM without ``thinking``). - """ - from litellm.types.router import GenericLiteLLMParams - - cfg = AmazonAnthropicClaudeMessagesConfig() - optional_params = { - "max_tokens": 32000, - "stream": False, - "context_management": { - "edits": [{"type": "clear_thinking_20251015", "keep": "all"}] - }, - } - result = cfg.transform_anthropic_messages_request( - model="global.anthropic.claude-sonnet-4-6-v1:0", - messages=[{"role": "user", "content": "hi"}], - anthropic_messages_optional_request_params=copy.deepcopy(optional_params), - litellm_params=GenericLiteLLMParams(), - headers={}, - ) - assert result["thinking"]["type"] == "enabled" - assert result["thinking"]["budget_tokens"] == BEDROCK_MIN_THINKING_BUDGET_TOKENS - betas = result.get("anthropic_beta") or [] - assert "interleaved-thinking-2025-05-14" in betas - - def test_bedrock_invoke_messages_skips_thinking_injection_when_already_enabled(): from litellm.types.router import GenericLiteLLMParams diff --git a/tests/test_litellm/llms/github_copilot/test_github_copilot_authenticator.py b/tests/test_litellm/llms/github_copilot/test_github_copilot_authenticator.py index c6ae2b9c4e1..5b48ed323a4 100644 --- a/tests/test_litellm/llms/github_copilot/test_github_copilot_authenticator.py +++ b/tests/test_litellm/llms/github_copilot/test_github_copilot_authenticator.py @@ -189,3 +189,65 @@ class TestGitHubCopilotAuthenticator: with patch("builtins.open", mock_open(read_data=mock_api_key_data)): api_base = authenticator.get_api_base() assert api_base == "https://api.enterprise.githubcopilot.com" + + def test_get_device_code_with_custom_url(self, authenticator, mock_http_client): + """GITHUB_COPILOT_DEVICE_CODE_URL env var must be used by _get_device_code at call time.""" + mock_client, mock_response = mock_http_client + custom_url = "https://custom.example.com/device" + mock_response.json.return_value = { + "device_code": "dc", + "user_code": "UC", + "verification_uri": "https://example.com", + } + with patch.dict(os.environ, {"GITHUB_COPILOT_DEVICE_CODE_URL": custom_url}), \ + patch("litellm.llms.github_copilot.authenticator._get_httpx_client", return_value=mock_client): + authenticator._get_device_code() + assert mock_client.post.call_args[0][0] == custom_url + + def test_get_device_code_with_custom_client_id(self, authenticator, mock_http_client): + """GITHUB_COPILOT_CLIENT_ID env var must appear as client_id in the device-code request body.""" + mock_client, mock_response = mock_http_client + custom_id = "custom_client_id" + mock_response.json.return_value = { + "device_code": "dc", + "user_code": "UC", + "verification_uri": "https://example.com", + } + with patch.dict(os.environ, {"GITHUB_COPILOT_CLIENT_ID": custom_id}), \ + patch("litellm.llms.github_copilot.authenticator._get_httpx_client", return_value=mock_client): + authenticator._get_device_code() + assert mock_client.post.call_args[1]["json"]["client_id"] == custom_id + + def test_poll_for_access_token_with_custom_url(self, authenticator, mock_http_client): + """GITHUB_COPILOT_ACCESS_TOKEN_URL env var must be used by _poll_for_access_token at call time.""" + mock_client, mock_response = mock_http_client + custom_url = "https://custom.example.com/token" + mock_response.json.return_value = {"access_token": "tok"} + with patch.dict(os.environ, {"GITHUB_COPILOT_ACCESS_TOKEN_URL": custom_url}), \ + patch("litellm.llms.github_copilot.authenticator._get_httpx_client", return_value=mock_client), \ + patch("time.sleep"): + authenticator._poll_for_access_token("dc") + assert mock_client.post.call_args[0][0] == custom_url + + def test_poll_for_access_token_with_custom_client_id(self, authenticator, mock_http_client): + """GITHUB_COPILOT_CLIENT_ID env var must appear as client_id in the polling request body.""" + mock_client, mock_response = mock_http_client + custom_id = "custom_client_id" + mock_response.json.return_value = {"access_token": "tok"} + with patch.dict(os.environ, {"GITHUB_COPILOT_CLIENT_ID": custom_id}), \ + patch("litellm.llms.github_copilot.authenticator._get_httpx_client", return_value=mock_client), \ + patch("time.sleep"): + authenticator._poll_for_access_token("dc") + assert mock_client.post.call_args[1]["json"]["client_id"] == custom_id + + def test_refresh_api_key_with_custom_url(self, authenticator, mock_http_client): + """GITHUB_COPILOT_API_KEY_URL env var must be used by _refresh_api_key at call time.""" + mock_client, mock_response = mock_http_client + custom_url = "https://custom.example.com/api-key" + mock_response.json.return_value = {"token": "api-tok", "expires_at": 9999999999} + with patch.dict(os.environ, {"GITHUB_COPILOT_API_KEY_URL": custom_url}), \ + patch("litellm.llms.github_copilot.authenticator._get_httpx_client", return_value=mock_client), \ + patch.object(authenticator, "get_access_token", return_value="access-tok"): + authenticator._refresh_api_key() + assert mock_client.get.call_args[0][0] == custom_url + diff --git a/tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py b/tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py index ca823d6fb55..97461561a05 100644 --- a/tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py +++ b/tests/test_litellm/llms/mistral/ocr/test_mistral_ocr_transformation.py @@ -4,6 +4,7 @@ Unit tests for MistralOCRConfig transformation. Tests the supported OCR parameters and their mapping behaviour. No real API calls are made — all tests are fully mocked/local. """ + import pytest from litellm.llms.mistral.ocr.transformation import MistralOCRConfig @@ -39,7 +40,9 @@ class TestGetSupportedOcrParams: "bbox_annotation_format", "document_annotation_format", ]: - assert param in supported, f"Previously supported param '{param}' is missing" + assert ( + param in supported + ), f"Previously supported param '{param}' is missing" class TestMapOcrParams: @@ -79,3 +82,97 @@ class TestMapOcrParams: ) assert "extract_header" in result assert "unsupported_param" not in result + + +class TestNewSupportedParams: + """Verify the newly added params are in the supported list.""" + + @pytest.mark.parametrize( + "param_name", + [ + "table_format", + "confidence_scores_granularity", + "document_annotation_prompt", + "id", + ], + ) + def test_new_param_in_supported_list( + self, config: MistralOCRConfig, param_name: str + ) -> None: + supported = config.get_supported_ocr_params(model=MODEL) + assert param_name in supported + + +class TestNewParamsMapOcr: + """Verify the newly added params survive map_ocr_params.""" + + @pytest.mark.parametrize( + "param_name,param_value", + [ + ("table_format", "html"), + ("table_format", "markdown"), + ("confidence_scores_granularity", "word"), + ("confidence_scores_granularity", "page"), + ("document_annotation_prompt", "Extract all invoice line items"), + ("id", "req-123"), + ], + ) + def test_new_param_passed_through( + self, config: MistralOCRConfig, param_name: str, param_value: str + ) -> None: + result = config.map_ocr_params( + non_default_params={param_name: param_value}, + optional_params={}, + model=MODEL, + ) + assert result == {param_name: param_value} + + +class TestTransformOcrRequest: + """Verify params end up in the final request body via transform_ocr_request.""" + + SAMPLE_DOCUMENT = { + "type": "document_url", + "document_url": "https://example.com/doc.pdf", + } + + @pytest.mark.parametrize( + "param_name,param_value", + [ + ("table_format", "html"), + ("confidence_scores_granularity", "word"), + ("document_annotation_prompt", "Extract all invoice line items"), + ("id", "req-123"), + ("extract_header", True), + ("pages", [0, 1]), + ], + ) + def test_param_included_in_request_body( + self, config: MistralOCRConfig, param_name: str, param_value + ) -> None: + result = config.transform_ocr_request( + model=MODEL, + document=self.SAMPLE_DOCUMENT, + optional_params={param_name: param_value}, + headers={}, + ) + assert result.data[param_name] == param_value + assert result.data["model"] == MODEL + assert result.data["document"] == self.SAMPLE_DOCUMENT + assert result.files is None + + def test_multiple_new_params_together(self, config: MistralOCRConfig) -> None: + """Multiple new params can be passed together in a single request.""" + optional_params = { + "table_format": "html", + "confidence_scores_granularity": "page", + "extract_header": True, + } + result = config.transform_ocr_request( + model=MODEL, + document=self.SAMPLE_DOCUMENT, + optional_params=optional_params, + headers={}, + ) + for key, value in optional_params.items(): + assert result.data[key] == value diff --git a/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx b/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx index 24881e669f9..9157fedbe21 100644 --- a/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx +++ b/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx @@ -11,6 +11,7 @@ import { serverRootPath, } from "@/components/networking"; import { extractErrorMessage } from "@/utils/errorUtils"; +import { generateCodeChallenge, generateCodeVerifier } from "@/utils/pkce"; import { getSecureItem, setSecureItem } from "@/utils/secureStorage"; export type McpOAuthStatus = "idle" | "authorizing" | "exchanging" | "success" | "error"; @@ -34,25 +35,6 @@ interface UseMcpOAuthFlowResult { tokenResponse: Record | null; } -const base64UrlEncode = (buffer: ArrayBuffer) => { - const bytes = new Uint8Array(buffer); - let binary = ""; - bytes.forEach((b) => (binary += String.fromCharCode(b))); - return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -}; - -const generateCodeVerifier = () => { - const array = new Uint8Array(32); - window.crypto.getRandomValues(array); - return base64UrlEncode(array.buffer); -}; - -const generateCodeChallenge = async (verifier: string) => { - const data = new TextEncoder().encode(verifier); - const digest = await window.crypto.subtle.digest("SHA-256", data); - return base64UrlEncode(digest); -}; - export const useMcpOAuthFlow = ({ accessToken, getCredentials, diff --git a/ui/litellm-dashboard/src/hooks/useUserMcpOAuthFlow.tsx b/ui/litellm-dashboard/src/hooks/useUserMcpOAuthFlow.tsx index e032c503dc7..aa7ce84de5e 100644 --- a/ui/litellm-dashboard/src/hooks/useUserMcpOAuthFlow.tsx +++ b/ui/litellm-dashboard/src/hooks/useUserMcpOAuthFlow.tsx @@ -23,6 +23,7 @@ import { } from "@/components/networking"; import NotificationsManager from "@/components/molecules/notifications_manager"; import { extractErrorMessage } from "@/utils/errorUtils"; +import { generateCodeChallenge, generateCodeVerifier } from "@/utils/pkce"; import { getSecureItem, setSecureItem } from "@/utils/secureStorage"; export type UserMcpOAuthStatus = "idle" | "authorizing" | "exchanging" | "success" | "error"; @@ -60,25 +61,6 @@ type StoredFlowState = { scopes?: string[]; }; -const b64url = (buf: ArrayBuffer) => { - const bytes = new Uint8Array(buf); - let s = ""; - bytes.forEach((b) => (s += String.fromCharCode(b))); - return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -}; - -const genVerifier = () => { - const arr = new Uint8Array(32); - window.crypto.getRandomValues(arr); - return b64url(arr.buffer); -}; - -const genChallenge = async (verifier: string) => { - const data = new TextEncoder().encode(verifier); - const digest = await window.crypto.subtle.digest("SHA-256", data); - return b64url(digest); -}; - const setStorage = (key: string, value: string) => { setSecureItem(key, value); }; @@ -144,8 +126,8 @@ export const useUserMcpOAuthFlow = ({ } } - const verifier = genVerifier(); - const challenge = await genChallenge(verifier); + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); const state = crypto.randomUUID(); const redirectUri = buildCallbackUrl(); const scopeString = scopes?.filter((s) => s.trim()).join(" "); diff --git a/ui/litellm-dashboard/src/utils/pkce.ts b/ui/litellm-dashboard/src/utils/pkce.ts new file mode 100644 index 00000000000..2b960c34f33 --- /dev/null +++ b/ui/litellm-dashboard/src/utils/pkce.ts @@ -0,0 +1,18 @@ +const base64UrlEncode = (buffer: ArrayBuffer) => { + const bytes = new Uint8Array(buffer); + let binary = ""; + bytes.forEach((b) => (binary += String.fromCharCode(b))); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +}; + +export const generateCodeVerifier = () => { + const array = new Uint8Array(32); + window.crypto.getRandomValues(array); + return base64UrlEncode(array.buffer); +}; + +export const generateCodeChallenge = async (verifier: string) => { + const data = new TextEncoder().encode(verifier); + const digest = await window.crypto.subtle.digest("SHA-256", data); + return base64UrlEncode(digest); +};