Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_yj_apr16

This commit is contained in:
Yuneng Jiang 2026-04-17 12:11:51 -07:00
commit 246a5bd158
No known key found for this signature in database
25 changed files with 606 additions and 179 deletions

View file

@ -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.
:::
<Image style={{padding: '10px', margin: '0 0 2.5rem'}} img={require('../img/hero.png')} />
**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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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==",

View file

@ -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",
},

View file

@ -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:

View file

@ -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):

View file

@ -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,

View file

@ -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]:
"""

View file

@ -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(

View file

@ -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"
)

View file

@ -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):

View file

@ -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",
]

View file

@ -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 = "<reasoning>This is my thought process</reasoning>This is the answer"
reasoning, content = config._extract_reasoning_from_content(content_with_reasoning)
content_with_reasoning = (
"<reasoning>This is my thought process</reasoning>This 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 "<reasoning>" 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

View file

@ -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"""

View file

@ -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,

View file

@ -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"),
},
},

View file

@ -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,
)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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<string, any> | 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,

View file

@ -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(" ");

View file

@ -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);
};