Merge pull request #18031 from BerriAI/litellm_anthropic_claude_skills_int

Add support for agent skills in chat completion
This commit is contained in:
Sameer Kankute
2025-12-16 21:39:11 +05:30
committed by GitHub
5 changed files with 338 additions and 32 deletions
@@ -1936,3 +1936,87 @@ curl http://0.0.0.0:4000/v1/chat/completions \
</TabItem>
</Tabs>
## Usage - Agent Skills
LiteLLM supports using Agent Skills with the API
<Tabs>
<TabItem value="sdk" label="SDK">
```python
response = completion(
model="claude-sonnet-4-5-20250929",
messages=messages,
tools= [
{
"type": "code_execution_20250825",
"name": "code_execution"
}
],
container= {
"skills": [
{
"type": "anthropic",
"skill_id": "pptx",
"version": "latest"
}
]
}
)
```
</TabItem>
<TabItem value="proxy" label="PROXY">
1. Setup config.yaml
```yaml
model_list:
- model_name: claude-sonnet-4-5-20250929
litellm_params:
model: anthropic/claude-sonnet-4-5-20250929
api_key: os.environ/ANTHROPIC_API_KEY
```
2. Start Proxy
```
litellm --config /path/to/config.yaml
```
3. Test it!
```bash
curl --location 'http://localhost:4000/chat/completions' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer <YOUR-LITELLM-KEY>' \
--data '{
"model": "claude-sonnet-4-5-20250929",
"messages": [
{
"role": "user",
"content": "Hi"
}
],
"tools": [
{
"type": "code_execution_20250825",
"name": "code_execution"
}
],
"container": {
"skills": [
{
"type": "anthropic",
"skill_id": "pptx",
"version": "latest"
}
]
}
}'
```
</TabItem>
</Tabs>
The container and its "id" will be present in "provider_specific_fields" in streaming/non-streaming response
+10 -19
View File
@@ -690,14 +690,14 @@ class ModelResponseIterator:
self.current_content_block_type = content_block_start["content_block"]["type"]
if content_block_start["content_block"]["type"] == "text":
text = content_block_start["content_block"]["text"]
elif content_block_start["content_block"]["type"] == "tool_use":
elif content_block_start["content_block"]["type"] == "tool_use" or content_block_start["content_block"]["type"] == "server_tool_use":
self.tool_index += 1
tool_use = ChatCompletionToolCallChunk(
id=content_block_start["content_block"]["id"],
type="function",
function=ChatCompletionToolCallFunctionChunk(
name=content_block_start["content_block"]["name"],
arguments="",
arguments=str(content_block_start["content_block"]["input"]),
),
index=self.tool_index,
)
@@ -706,18 +706,6 @@ class ModelResponseIterator:
caller_data = content_block_start["content_block"]["caller"]
if caller_data:
tool_use["caller"] = cast(Dict[str, Any], caller_data) # type: ignore[typeddict-item]
elif content_block_start["content_block"]["type"] == "server_tool_use":
# Handle server tool use (for tool search)
self.tool_index += 1
tool_use = ChatCompletionToolCallChunk(
id=content_block_start["content_block"]["id"],
type="function",
function=ChatCompletionToolCallFunctionChunk(
name=content_block_start["content_block"]["name"],
arguments="",
),
index=self.tool_index,
)
elif (
content_block_start["content_block"]["type"] == "redacted_thinking"
):
@@ -765,7 +753,9 @@ class ModelResponseIterator:
# These are automatically handled by Anthropic API, we just pass them through
pass
elif type_chunk == "message_delta":
finish_reason, usage = self._handle_message_delta(chunk)
finish_reason, usage, container = self._handle_message_delta(chunk)
if container:
provider_specific_fields["container"] = container
elif type_chunk == "message_start":
"""
Anthropic
@@ -881,15 +871,15 @@ class ModelResponseIterator:
return text, tool_use
def _handle_message_delta(self, chunk: dict) -> Tuple[str, Optional[Usage]]:
def _handle_message_delta(self, chunk: dict) -> Tuple[str, Optional[Usage], Optional[Dict[str, Any]]]:
"""
Handle message_delta event for finish_reason and usage.
Handle message_delta event for finish_reason, usage, and container.
Args:
chunk: The message_delta chunk
Returns:
Tuple of (finish_reason, usage)
Tuple of (finish_reason, usage, container)
"""
message_delta = MessageBlockDelta(**chunk) # type: ignore
finish_reason = map_finish_reason(
@@ -900,7 +890,8 @@ class ModelResponseIterator:
if self.converted_response_format_tool:
finish_reason = "stop"
usage = self._handle_usage(anthropic_usage_chunk=message_delta["usage"])
return finish_reason, usage
container = message_delta["delta"].get("container")
return finish_reason, usage, container
def _handle_accumulated_json_chunk(
self, data_str: str
+6 -12
View File
@@ -1141,22 +1141,12 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
if content["type"] == "text":
text_content += content["text"]
## TOOL CALLING
elif content["type"] == "tool_use":
elif content["type"] == "tool_use" or content["type"] == "server_tool_use":
tool_call = AnthropicConfig.convert_tool_use_to_openai_format(
anthropic_tool_content=content,
index=idx,
)
tool_calls.append(tool_call)
## SERVER TOOL USE (for tool search)
elif content["type"] == "server_tool_use":
# Server tool use blocks are for tool search - treat as tool calls
# Note: using .get("input", {}) for server_tool_use as input may not be present
content_with_input = {**content, "input": content.get("input", {})}
tool_call = AnthropicConfig.convert_tool_use_to_openai_format(
anthropic_tool_content=content_with_input,
index=idx,
)
tool_calls.append(tool_call)
## TOOL SEARCH TOOL RESULT (skip - this is metadata about tool discovery)
elif content["type"] == "tool_search_tool_result":
# This block contains tool_references that were discovered
@@ -1352,6 +1342,8 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
"context_management"
)
container: Optional[Dict] = completion_response.get("container")
provider_specific_fields: Dict[str, Any] = {
"citations": citations,
"thinking_blocks": thinking_blocks,
@@ -1360,7 +1352,9 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
provider_specific_fields["context_management"] = context_management
if web_search_results is not None:
provider_specific_fields["web_search_results"] = web_search_results
if container is not None:
provider_specific_fields["container"] = container
_message = litellm.Message(
tool_calls=tool_calls,
content=text_content or None,
+45
View File
@@ -186,6 +186,37 @@ class AnthropicModelInfo(BaseLLMModelInfo):
return False
def is_code_execution_tool_used(self, tools: Optional[List]) -> bool:
"""
Check if code execution tool is being used.
Returns True if any tool has type "code_execution_20250825".
"""
if not tools:
return False
for tool in tools:
tool_type = tool.get("type", "")
if tool_type == "code_execution_20250825":
return True
return False
def is_container_with_skills_used(self, optional_params: Optional[dict]) -> bool:
"""
Check if container with skills is being used.
Returns True if optional_params contains container with skills.
"""
if not optional_params:
return False
container = optional_params.get("container")
if container and isinstance(container, dict):
skills = container.get("skills")
if skills and isinstance(skills, list) and len(skills) > 0:
return True
return False
def _get_user_anthropic_beta_headers(
self, anthropic_beta_header: Optional[str]
) -> Optional[List[str]]:
@@ -270,6 +301,8 @@ class AnthropicModelInfo(BaseLLMModelInfo):
effort_used: bool = False,
is_vertex_request: bool = False,
user_anthropic_beta_headers: Optional[List[str]] = None,
code_execution_tool_used: bool = False,
container_with_skills_used: bool = False,
) -> dict:
betas = set()
if prompt_caching_set:
@@ -293,6 +326,14 @@ class AnthropicModelInfo(BaseLLMModelInfo):
if effort_used:
from litellm.types.llms.anthropic import ANTHROPIC_EFFORT_BETA_HEADER
betas.add(ANTHROPIC_EFFORT_BETA_HEADER)
# Code execution tool uses a separate beta header
if code_execution_tool_used:
betas.add("code-execution-2025-08-25")
# Container with skills uses a separate beta header
if container_with_skills_used:
betas.add("skills-2025-10-02")
headers = {
"anthropic-version": anthropic_version or "2023-06-01",
@@ -345,6 +386,8 @@ class AnthropicModelInfo(BaseLLMModelInfo):
programmatic_tool_calling_used = self.is_programmatic_tool_calling_used(tools=tools)
input_examples_used = self.is_input_examples_used(tools=tools)
effort_used = self.is_effort_used(optional_params=optional_params, model=model)
code_execution_tool_used = self.is_code_execution_tool_used(tools=tools)
container_with_skills_used = self.is_container_with_skills_used(optional_params=optional_params)
user_anthropic_beta_headers = self._get_user_anthropic_beta_headers(
anthropic_beta_header=headers.get("anthropic-beta")
)
@@ -362,6 +405,8 @@ class AnthropicModelInfo(BaseLLMModelInfo):
programmatic_tool_calling_used=programmatic_tool_calling_used,
input_examples_used=input_examples_used,
effort_used=effort_used,
code_execution_tool_used=code_execution_tool_used,
container_with_skills_used=container_with_skills_used,
)
headers = {**headers, **anthropic_headers}
@@ -1,11 +1,11 @@
from unittest.mock import MagicMock
from litellm.constants import RESPONSE_FORMAT_TOOL_NAME
from litellm.llms.anthropic.chat.handler import ModelResponseIterator
from litellm.types.llms.openai import (
ChatCompletionToolCallChunk,
ChatCompletionToolCallFunctionChunk,
)
from litellm.constants import RESPONSE_FORMAT_TOOL_NAME
def test_redacted_thinking_content_block_delta():
@@ -779,3 +779,195 @@ def test_web_search_tool_result_captured_in_provider_specific_fields():
assert (
web_search_results[0]["content"][0]["title"] == "Fun Otter Facts"
), "First result title should match"
def test_container_in_provider_specific_fields_streaming():
"""
Test that container is captured in provider_specific_fields for streaming responses.
When container with skills is used, the container field should be present in
the provider_specific_fields of the message_delta chunk.
"""
iterator = ModelResponseIterator(
streaming_response=MagicMock(), sync_stream=True, json_mode=False
)
# Simulate streaming chunks
chunks = [
# 1. message_start
{
"type": "message_start",
"message": {
"id": "msg_123",
"type": "message",
"role": "assistant",
"content": [],
"usage": {"input_tokens": 98976, "output_tokens": 1},
},
},
# 2. content_block_start for text
{
"type": "content_block_start",
"index": 0,
"content_block": {
"type": "text",
"text": "",
},
},
# 3. content_block_delta with text
{
"type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": "Hello, this is a response"},
},
# 4. content_block_stop for text
{"type": "content_block_stop", "index": 0},
# 5. message_delta with container - THIS IS WHAT WE'RE TESTING
{
"type": "message_delta",
"delta": {
"stop_reason": "end_turn",
"stop_sequence": None,
"container": {
"id": "container_011CW9hA9zpZ8xD3bjjShy4p",
"expires_at": "2025-12-16T04:57:16.913181Z",
"skills": [
{
"type": "anthropic",
"skill_id": "pptx",
"version": "20251013",
}
],
},
},
"usage": {
"input_tokens": 98976,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"output_tokens": 931,
"server_tool_use": {"web_search_requests": 0},
},
},
]
container_field = None
for chunk in chunks:
parsed = iterator.chunk_parser(chunk)
if (
parsed.choices
and parsed.choices[0].delta.provider_specific_fields
and "container" in parsed.choices[0].delta.provider_specific_fields
):
container_field = parsed.choices[0].delta.provider_specific_fields[
"container"
]
# Verify container was captured
assert container_field is not None, "container should be captured in provider_specific_fields"
assert (
container_field["id"] == "container_011CW9hA9zpZ8xD3bjjShy4p"
), "container id should match"
assert (
container_field["expires_at"] == "2025-12-16T04:57:16.913181Z"
), "expires_at should match"
assert len(container_field["skills"]) == 1, "Should have 1 skill"
assert (
container_field["skills"][0]["skill_id"] == "pptx"
), "skill_id should be pptx"
assert (
container_field["skills"][0]["version"] == "20251013"
), "version should match"
def test_container_in_provider_specific_fields_non_streaming():
"""
Test that container is captured in provider_specific_fields for non-streaming responses.
When container with skills is used in non-streaming, the container field should be
present in the provider_specific_fields of the response.
"""
iterator = ModelResponseIterator(
streaming_response=MagicMock(), sync_stream=False, json_mode=False
)
# Simulate a message_delta chunk with container (as it would appear in non-streaming)
message_delta_chunk = {
"type": "message_delta",
"delta": {
"stop_reason": "end_turn",
"stop_sequence": None,
"container": {
"id": "container_abc123xyz",
"expires_at": "2025-12-20T10:30:00.000000Z",
"skills": [
{
"type": "anthropic",
"skill_id": "code_execution",
"version": "latest",
},
{
"type": "anthropic",
"skill_id": "pptx",
"version": "20251013",
},
],
},
},
"usage": {
"input_tokens": 1000,
"output_tokens": 200,
},
}
model_response = iterator.chunk_parser(message_delta_chunk)
# Verify container is in provider_specific_fields
assert model_response.choices[0].delta.provider_specific_fields is not None
assert "container" in model_response.choices[0].delta.provider_specific_fields
container_field = model_response.choices[0].delta.provider_specific_fields[
"container"
]
assert container_field["id"] == "container_abc123xyz", "container id should match"
assert (
container_field["expires_at"] == "2025-12-20T10:30:00.000000Z"
), "expires_at should match"
assert len(container_field["skills"]) == 2, "Should have 2 skills"
assert (
container_field["skills"][0]["skill_id"] == "code_execution"
), "First skill_id should be code_execution"
assert (
container_field["skills"][1]["skill_id"] == "pptx"
), "Second skill_id should be pptx"
def test_container_absent_when_not_provided():
"""
Test that container is not added to provider_specific_fields when not provided.
This ensures we don't add empty or None container fields.
"""
iterator = ModelResponseIterator(
streaming_response=MagicMock(), sync_stream=False, json_mode=False
)
# message_delta without container
message_delta_chunk = {
"type": "message_delta",
"delta": {
"stop_reason": "end_turn",
"stop_sequence": None,
},
"usage": {
"input_tokens": 1000,
"output_tokens": 200,
},
}
model_response = iterator.chunk_parser(message_delta_chunk)
# Verify container is NOT in provider_specific_fields when not provided
if model_response.choices[0].delta.provider_specific_fields:
assert (
"container" not in model_response.choices[0].delta.provider_specific_fields
), "container should not be present when not provided in delta"