mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-05 16:24:59 +00:00
Merge pull request #18031 from BerriAI/litellm_anthropic_claude_skills_int
Add support for agent skills in chat completion
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user