mirror of
https://github.com/mudler/LocalAI.git
synced 2026-04-20 06:47:24 -04:00
* Initial plan * Add tool/function calling schema support to Anthropic Messages API Co-authored-by: mudler <2420543+mudler@users.noreply.github.com> * Add E2E tests for Anthropic tool calling Co-authored-by: mudler <2420543+mudler@users.noreply.github.com> * Make tool calling tests require model to use tools - First test now expects hasToolUse to be true with clear error message - Third test now expects toolUseID to be non-empty (removed conditional) - Both tests will now fail if model doesn't call the expected tools Co-authored-by: mudler <2420543+mudler@users.noreply.github.com> * Add E2E test for tool calling with streaming responses - Tests that streaming events are properly emitted (content_block_start/delta/stop) - Verifies tool_use blocks are accumulated correctly in streaming mode - Ensures model calls tools and stop_reason is set to tool_use Co-authored-by: mudler <2420543+mudler@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
376 lines
12 KiB
Go
376 lines
12 KiB
Go
package e2e_test
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/anthropics/anthropic-sdk-go"
|
|
"github.com/anthropics/anthropic-sdk-go/option"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("Anthropic API E2E test", func() {
|
|
var client anthropic.Client
|
|
|
|
Context("API with Anthropic SDK", func() {
|
|
BeforeEach(func() {
|
|
// Create Anthropic client pointing to LocalAI
|
|
client = anthropic.NewClient(
|
|
option.WithBaseURL(localAIURL),
|
|
option.WithAPIKey("test-api-key"), // LocalAI doesn't require a real API key
|
|
)
|
|
|
|
// Wait for API to be ready by attempting a simple request
|
|
Eventually(func() error {
|
|
_, err := client.Messages.New(context.TODO(), anthropic.MessageNewParams{
|
|
Model: "gpt-4",
|
|
MaxTokens: 10,
|
|
Messages: []anthropic.MessageParam{
|
|
anthropic.NewUserMessage(anthropic.NewTextBlock("Hi")),
|
|
},
|
|
})
|
|
return err
|
|
}, "2m").ShouldNot(HaveOccurred())
|
|
})
|
|
|
|
Context("Non-streaming responses", func() {
|
|
It("generates a response for a simple message", func() {
|
|
message, err := client.Messages.New(context.TODO(), anthropic.MessageNewParams{
|
|
Model: "gpt-4",
|
|
MaxTokens: 1024,
|
|
Messages: []anthropic.MessageParam{
|
|
anthropic.NewUserMessage(anthropic.NewTextBlock("How much is 2+2? Reply with just the number.")),
|
|
},
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(message.Content).ToNot(BeEmpty())
|
|
// Role is a constant type that defaults to "assistant"
|
|
Expect(string(message.Role)).To(Equal("assistant"))
|
|
Expect(message.StopReason).To(Equal(anthropic.MessageStopReasonEndTurn))
|
|
Expect(string(message.Type)).To(Equal("message"))
|
|
|
|
// Check that content contains text block with expected answer
|
|
Expect(len(message.Content)).To(BeNumerically(">=", 1))
|
|
textBlock := message.Content[0]
|
|
Expect(string(textBlock.Type)).To(Equal("text"))
|
|
Expect(textBlock.Text).To(Or(ContainSubstring("4"), ContainSubstring("four")))
|
|
})
|
|
|
|
It("handles system prompts", func() {
|
|
message, err := client.Messages.New(context.TODO(), anthropic.MessageNewParams{
|
|
Model: "gpt-4",
|
|
MaxTokens: 1024,
|
|
System: []anthropic.TextBlockParam{
|
|
{Text: "You are a helpful assistant. Always respond in uppercase letters."},
|
|
},
|
|
Messages: []anthropic.MessageParam{
|
|
anthropic.NewUserMessage(anthropic.NewTextBlock("Say hello")),
|
|
},
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(message.Content).ToNot(BeEmpty())
|
|
Expect(len(message.Content)).To(BeNumerically(">=", 1))
|
|
})
|
|
|
|
It("returns usage information", func() {
|
|
message, err := client.Messages.New(context.TODO(), anthropic.MessageNewParams{
|
|
Model: "gpt-4",
|
|
MaxTokens: 100,
|
|
Messages: []anthropic.MessageParam{
|
|
anthropic.NewUserMessage(anthropic.NewTextBlock("Hello")),
|
|
},
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(message.Usage.InputTokens).To(BeNumerically(">", 0))
|
|
Expect(message.Usage.OutputTokens).To(BeNumerically(">", 0))
|
|
})
|
|
})
|
|
|
|
Context("Streaming responses", func() {
|
|
It("streams tokens for a simple message", func() {
|
|
stream := client.Messages.NewStreaming(context.TODO(), anthropic.MessageNewParams{
|
|
Model: "gpt-4",
|
|
MaxTokens: 1024,
|
|
Messages: []anthropic.MessageParam{
|
|
anthropic.NewUserMessage(anthropic.NewTextBlock("Count from 1 to 5")),
|
|
},
|
|
})
|
|
|
|
message := anthropic.Message{}
|
|
eventCount := 0
|
|
hasContentDelta := false
|
|
|
|
for stream.Next() {
|
|
event := stream.Current()
|
|
err := message.Accumulate(event)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
eventCount++
|
|
|
|
// Check for content block delta events
|
|
switch event.AsAny().(type) {
|
|
case anthropic.ContentBlockDeltaEvent:
|
|
hasContentDelta = true
|
|
}
|
|
}
|
|
|
|
Expect(stream.Err()).ToNot(HaveOccurred())
|
|
Expect(eventCount).To(BeNumerically(">", 0))
|
|
Expect(hasContentDelta).To(BeTrue())
|
|
|
|
// Check accumulated message
|
|
Expect(message.Content).ToNot(BeEmpty())
|
|
// Role is a constant type that defaults to "assistant"
|
|
Expect(string(message.Role)).To(Equal("assistant"))
|
|
})
|
|
|
|
It("streams with system prompt", func() {
|
|
stream := client.Messages.NewStreaming(context.TODO(), anthropic.MessageNewParams{
|
|
Model: "gpt-4",
|
|
MaxTokens: 1024,
|
|
System: []anthropic.TextBlockParam{
|
|
{Text: "You are a helpful assistant."},
|
|
},
|
|
Messages: []anthropic.MessageParam{
|
|
anthropic.NewUserMessage(anthropic.NewTextBlock("Say hello")),
|
|
},
|
|
})
|
|
|
|
message := anthropic.Message{}
|
|
for stream.Next() {
|
|
event := stream.Current()
|
|
err := message.Accumulate(event)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
}
|
|
|
|
Expect(stream.Err()).ToNot(HaveOccurred())
|
|
Expect(message.Content).ToNot(BeEmpty())
|
|
})
|
|
})
|
|
|
|
Context("Tool calling", func() {
|
|
It("handles tool calls in non-streaming mode", func() {
|
|
message, err := client.Messages.New(context.TODO(), anthropic.MessageNewParams{
|
|
Model: "gpt-4",
|
|
MaxTokens: 1024,
|
|
Messages: []anthropic.MessageParam{
|
|
anthropic.NewUserMessage(anthropic.NewTextBlock("What's the weather like in San Francisco?")),
|
|
},
|
|
Tools: []anthropic.ToolParam{
|
|
{
|
|
Name: "get_weather",
|
|
Description: anthropic.F("Get the current weather in a given location"),
|
|
InputSchema: anthropic.F(map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"location": map[string]interface{}{
|
|
"type": "string",
|
|
"description": "The city and state, e.g. San Francisco, CA",
|
|
},
|
|
},
|
|
"required": []string{"location"},
|
|
}),
|
|
},
|
|
},
|
|
})
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(message.Content).ToNot(BeEmpty())
|
|
|
|
// The model must use tools - find the tool use in the response
|
|
hasToolUse := false
|
|
for _, block := range message.Content {
|
|
if block.Type == anthropic.ContentBlockTypeToolUse {
|
|
hasToolUse = true
|
|
Expect(block.Name).To(Equal("get_weather"))
|
|
Expect(block.ID).ToNot(BeEmpty())
|
|
// Verify that input contains location
|
|
inputMap, ok := block.Input.(map[string]interface{})
|
|
Expect(ok).To(BeTrue())
|
|
_, hasLocation := inputMap["location"]
|
|
Expect(hasLocation).To(BeTrue())
|
|
}
|
|
}
|
|
|
|
// Model must have called the tool
|
|
Expect(hasToolUse).To(BeTrue(), "Model should have called the get_weather tool")
|
|
Expect(message.StopReason).To(Equal(anthropic.MessageStopReasonToolUse))
|
|
})
|
|
|
|
It("handles tool_choice parameter", func() {
|
|
message, err := client.Messages.New(context.TODO(), anthropic.MessageNewParams{
|
|
Model: "gpt-4",
|
|
MaxTokens: 1024,
|
|
Messages: []anthropic.MessageParam{
|
|
anthropic.NewUserMessage(anthropic.NewTextBlock("Tell me about the weather")),
|
|
},
|
|
Tools: []anthropic.ToolParam{
|
|
{
|
|
Name: "get_weather",
|
|
Description: anthropic.F("Get the current weather"),
|
|
InputSchema: anthropic.F(map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"location": map[string]interface{}{
|
|
"type": "string",
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
ToolChoice: anthropic.F[anthropic.ToolChoiceUnionParam](
|
|
anthropic.ToolChoiceAutoParam{
|
|
Type: anthropic.F(anthropic.ToolChoiceAutoTypeAuto),
|
|
},
|
|
),
|
|
})
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(message.Content).ToNot(BeEmpty())
|
|
})
|
|
|
|
It("handles tool results in messages", func() {
|
|
// First, make a request that should trigger a tool call
|
|
firstMessage, err := client.Messages.New(context.TODO(), anthropic.MessageNewParams{
|
|
Model: "gpt-4",
|
|
MaxTokens: 1024,
|
|
Messages: []anthropic.MessageParam{
|
|
anthropic.NewUserMessage(anthropic.NewTextBlock("What's the weather in SF?")),
|
|
},
|
|
Tools: []anthropic.ToolParam{
|
|
{
|
|
Name: "get_weather",
|
|
Description: anthropic.F("Get weather"),
|
|
InputSchema: anthropic.F(map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"location": map[string]interface{}{"type": "string"},
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
})
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Find the tool use block - model must call the tool
|
|
var toolUseID string
|
|
var toolUseName string
|
|
for _, block := range firstMessage.Content {
|
|
if block.Type == anthropic.ContentBlockTypeToolUse {
|
|
toolUseID = block.ID
|
|
toolUseName = block.Name
|
|
break
|
|
}
|
|
}
|
|
|
|
// Model must have called the tool
|
|
Expect(toolUseID).ToNot(BeEmpty(), "Model should have called the get_weather tool")
|
|
|
|
// Send back a tool result and verify it's handled correctly
|
|
secondMessage, err := client.Messages.New(context.TODO(), anthropic.MessageNewParams{
|
|
Model: "gpt-4",
|
|
MaxTokens: 1024,
|
|
Messages: []anthropic.MessageParam{
|
|
anthropic.NewUserMessage(anthropic.NewTextBlock("What's the weather in SF?")),
|
|
anthropic.NewAssistantMessage(firstMessage.Content...),
|
|
anthropic.NewUserMessage(
|
|
anthropic.NewToolResultBlock(toolUseID, "Sunny, 72°F", false),
|
|
),
|
|
},
|
|
Tools: []anthropic.ToolParam{
|
|
{
|
|
Name: toolUseName,
|
|
Description: anthropic.F("Get weather"),
|
|
InputSchema: anthropic.F(map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"location": map[string]interface{}{"type": "string"},
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
})
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(secondMessage.Content).ToNot(BeEmpty())
|
|
})
|
|
|
|
It("handles tool calls in streaming mode", func() {
|
|
stream := client.Messages.NewStreaming(context.TODO(), anthropic.MessageNewParams{
|
|
Model: "gpt-4",
|
|
MaxTokens: 1024,
|
|
Messages: []anthropic.MessageParam{
|
|
anthropic.NewUserMessage(anthropic.NewTextBlock("What's the weather like in San Francisco?")),
|
|
},
|
|
Tools: []anthropic.ToolParam{
|
|
{
|
|
Name: "get_weather",
|
|
Description: anthropic.F("Get the current weather in a given location"),
|
|
InputSchema: anthropic.F(map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"location": map[string]interface{}{
|
|
"type": "string",
|
|
"description": "The city and state, e.g. San Francisco, CA",
|
|
},
|
|
},
|
|
"required": []string{"location"},
|
|
}),
|
|
},
|
|
},
|
|
})
|
|
|
|
message := anthropic.Message{}
|
|
eventCount := 0
|
|
hasToolUseBlock := false
|
|
hasContentBlockStart := false
|
|
hasContentBlockDelta := false
|
|
hasContentBlockStop := false
|
|
|
|
for stream.Next() {
|
|
event := stream.Current()
|
|
err := message.Accumulate(event)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
eventCount++
|
|
|
|
// Check for different event types related to tool use
|
|
switch e := event.AsAny().(type) {
|
|
case anthropic.ContentBlockStartEvent:
|
|
hasContentBlockStart = true
|
|
if e.ContentBlock.Type == anthropic.ContentBlockTypeToolUse {
|
|
hasToolUseBlock = true
|
|
}
|
|
case anthropic.ContentBlockDeltaEvent:
|
|
hasContentBlockDelta = true
|
|
case anthropic.ContentBlockStopEvent:
|
|
hasContentBlockStop = true
|
|
}
|
|
}
|
|
|
|
Expect(stream.Err()).ToNot(HaveOccurred())
|
|
Expect(eventCount).To(BeNumerically(">", 0))
|
|
|
|
// Verify streaming events were emitted
|
|
Expect(hasContentBlockStart).To(BeTrue(), "Should have content_block_start event")
|
|
Expect(hasContentBlockDelta).To(BeTrue(), "Should have content_block_delta event")
|
|
Expect(hasContentBlockStop).To(BeTrue(), "Should have content_block_stop event")
|
|
|
|
// Check accumulated message has tool use
|
|
Expect(message.Content).ToNot(BeEmpty())
|
|
|
|
// Model must have called the tool
|
|
foundToolUse := false
|
|
for _, block := range message.Content {
|
|
if block.Type == anthropic.ContentBlockTypeToolUse {
|
|
foundToolUse = true
|
|
Expect(block.Name).To(Equal("get_weather"))
|
|
Expect(block.ID).ToNot(BeEmpty())
|
|
}
|
|
}
|
|
Expect(foundToolUse).To(BeTrue(), "Model should have called the get_weather tool in streaming mode")
|
|
Expect(message.StopReason).To(Equal(anthropic.MessageStopReasonToolUse))
|
|
})
|
|
})
|
|
})
|
|
})
|