Merge pull request #1458 from trheyi/main
Implement support for Anthropic connectors in the LLM and sandbox com…
This commit is contained in:
commit
8dff448bd0
10 changed files with 1424 additions and 18 deletions
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/yaoapp/gou/connector"
|
||||||
gouMCP "github.com/yaoapp/gou/mcp"
|
gouMCP "github.com/yaoapp/gou/mcp"
|
||||||
mcpProcess "github.com/yaoapp/gou/mcp/process"
|
mcpProcess "github.com/yaoapp/gou/mcp/process"
|
||||||
"github.com/yaoapp/yao/agent/context"
|
"github.com/yaoapp/yao/agent/context"
|
||||||
|
|
@ -257,6 +258,14 @@ func (ast *Assistant) buildSandboxOptions(ctx *context.Context, opts *context.Op
|
||||||
return nil, fmt.Errorf("failed to get connector: %w", err)
|
return nil, fmt.Errorf("failed to get connector: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine connector type for sandbox proxy behavior
|
||||||
|
// Anthropic connectors bypass the proxy (Claude CLI connects directly)
|
||||||
|
if conn.Is(connector.ANTHROPIC) {
|
||||||
|
execOpts.ConnectorType = "anthropic"
|
||||||
|
} else {
|
||||||
|
execOpts.ConnectorType = "openai"
|
||||||
|
}
|
||||||
|
|
||||||
setting := conn.Setting()
|
setting := conn.Setting()
|
||||||
if host, ok := setting["host"].(string); ok {
|
if host, ok := setting["host"].(string); ok {
|
||||||
execOpts.ConnectorHost = host
|
execOpts.ConnectorHost = host
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package llm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/yaoapp/gou/connector"
|
"github.com/yaoapp/gou/connector"
|
||||||
|
"github.com/yaoapp/gou/connector/anthropic"
|
||||||
"github.com/yaoapp/gou/connector/openai"
|
"github.com/yaoapp/gou/connector/openai"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -68,6 +69,14 @@ func GetCapabilitiesFromConn(conn connector.Connector, modelCapabilities map[str
|
||||||
if capabilities, ok := caps.(openai.Capabilities); ok {
|
if capabilities, ok := caps.(openai.Capabilities); ok {
|
||||||
return &capabilities
|
return &capabilities
|
||||||
}
|
}
|
||||||
|
// Try to convert from *anthropic.Capabilities
|
||||||
|
if capabilities, ok := caps.(*anthropic.Capabilities); ok {
|
||||||
|
return convertAnthropicCaps(capabilities)
|
||||||
|
}
|
||||||
|
// Try to convert from anthropic.Capabilities (value type)
|
||||||
|
if capabilities, ok := caps.(anthropic.Capabilities); ok {
|
||||||
|
return convertAnthropicCaps(&capabilities)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,3 +134,21 @@ func ToMap(caps *openai.Capabilities) map[string]interface{} {
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// convertAnthropicCaps converts anthropic.Capabilities to openai.Capabilities
|
||||||
|
// This provides a unified capabilities interface across connector types
|
||||||
|
func convertAnthropicCaps(caps *anthropic.Capabilities) *openai.Capabilities {
|
||||||
|
if caps == nil {
|
||||||
|
return getDefaultCapabilities()
|
||||||
|
}
|
||||||
|
return &openai.Capabilities{
|
||||||
|
Vision: caps.Vision,
|
||||||
|
Audio: caps.Audio,
|
||||||
|
ToolCalls: caps.ToolCalls,
|
||||||
|
Reasoning: caps.Reasoning,
|
||||||
|
Streaming: caps.Streaming,
|
||||||
|
JSON: caps.JSON,
|
||||||
|
Multimodal: caps.Multimodal,
|
||||||
|
TemperatureAdjustable: caps.TemperatureAdjustable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
1173
agent/llm/providers/anthropic/anthropic.go
Normal file
1173
agent/llm/providers/anthropic/anthropic.go
Normal file
File diff suppressed because it is too large
Load diff
154
agent/llm/providers/anthropic/types.go
Normal file
154
agent/llm/providers/anthropic/types.go
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
package anthropic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/yaoapp/yao/agent/output/message"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Anthropic Messages API types
|
||||||
|
// Reference: https://docs.anthropic.com/en/api/messages
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// StreamEvent represents an SSE event from Anthropic streaming API
|
||||||
|
type StreamEvent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageStartEvent represents the message_start SSE event
|
||||||
|
type MessageStartEvent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message MessageStart `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageStart represents the message object in message_start event
|
||||||
|
type MessageStart struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content []ContentBlock `json:"content"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
StopReason *string `json:"stop_reason"`
|
||||||
|
StopSequence *string `json:"stop_sequence"`
|
||||||
|
Usage *UsageInfo `json:"usage,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContentBlockStartEvent represents the content_block_start SSE event
|
||||||
|
type ContentBlockStartEvent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Index int `json:"index"`
|
||||||
|
ContentBlock ContentBlock `json:"content_block"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContentBlockDeltaEvent represents the content_block_delta SSE event
|
||||||
|
type ContentBlockDeltaEvent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Index int `json:"index"`
|
||||||
|
Delta DeltaBlock `json:"delta"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContentBlockStopEvent represents the content_block_stop SSE event
|
||||||
|
type ContentBlockStopEvent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Index int `json:"index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageDeltaEvent represents the message_delta SSE event
|
||||||
|
type MessageDeltaEvent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Delta MessageDelta `json:"delta"`
|
||||||
|
Usage *DeltaUsage `json:"usage,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageDelta represents the delta in message_delta event
|
||||||
|
type MessageDelta struct {
|
||||||
|
StopReason string `json:"stop_reason,omitempty"`
|
||||||
|
StopSequence *string `json:"stop_sequence,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeltaUsage represents usage in message_delta event
|
||||||
|
type DeltaUsage struct {
|
||||||
|
OutputTokens int `json:"output_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContentBlock represents a content block in the response
|
||||||
|
type ContentBlock struct {
|
||||||
|
Type string `json:"type"` // "text", "thinking", "tool_use"
|
||||||
|
Text string `json:"text,omitempty"` // for type "text"
|
||||||
|
Thinking string `json:"thinking,omitempty"` // for type "thinking"
|
||||||
|
Signature string `json:"signature,omitempty"` // for type "thinking"
|
||||||
|
ID string `json:"id,omitempty"` // for type "tool_use"
|
||||||
|
Name string `json:"name,omitempty"` // for type "tool_use"
|
||||||
|
Input interface{} `json:"input,omitempty"` // for type "tool_use"
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeltaBlock represents a delta block in streaming
|
||||||
|
type DeltaBlock struct {
|
||||||
|
Type string `json:"type"` // "text_delta", "thinking_delta", "input_json_delta"
|
||||||
|
Text string `json:"text,omitempty"` // for type "text_delta"
|
||||||
|
Thinking string `json:"thinking,omitempty"` // for type "thinking_delta"
|
||||||
|
PartialJSON string `json:"partial_json,omitempty"` // for type "input_json_delta"
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsageInfo represents token usage information
|
||||||
|
type UsageInfo struct {
|
||||||
|
InputTokens int `json:"input_tokens"`
|
||||||
|
OutputTokens int `json:"output_tokens"`
|
||||||
|
CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"`
|
||||||
|
CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NonStreamResponse represents the full non-streaming response from Anthropic API
|
||||||
|
type NonStreamResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content []ContentBlock `json:"content"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
StopReason string `json:"stop_reason"`
|
||||||
|
StopSequence *string `json:"stop_sequence"`
|
||||||
|
Usage *UsageInfo `json:"usage,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIError represents an error response from Anthropic API
|
||||||
|
type APIError struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Error struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// streamAccumulator accumulates streaming response data
|
||||||
|
type streamAccumulator struct {
|
||||||
|
id string
|
||||||
|
model string
|
||||||
|
role string
|
||||||
|
content string
|
||||||
|
thinkingContent string
|
||||||
|
thinkingSignature string
|
||||||
|
toolCalls map[int]*accumulatedToolCall
|
||||||
|
stopReason string
|
||||||
|
usage *message.UsageInfo
|
||||||
|
|
||||||
|
// Current content block tracking
|
||||||
|
currentBlockIndex int
|
||||||
|
currentBlockType string
|
||||||
|
}
|
||||||
|
|
||||||
|
// accumulatedToolCall accumulates a single tool call from streaming
|
||||||
|
type accumulatedToolCall struct {
|
||||||
|
id string
|
||||||
|
name string
|
||||||
|
inputJSON string
|
||||||
|
}
|
||||||
|
|
||||||
|
// messageTracker tracks message lifecycle for stream events
|
||||||
|
type messageTracker struct {
|
||||||
|
active bool
|
||||||
|
messageID string
|
||||||
|
messageType message.StreamChunkType
|
||||||
|
startTime int64
|
||||||
|
chunkCount int
|
||||||
|
toolCallInfo *message.EventToolCallInfo
|
||||||
|
idGenerator *message.IDGenerator
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,9 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/yaoapp/gou/connector"
|
"github.com/yaoapp/gou/connector"
|
||||||
|
gouAnthropicConn "github.com/yaoapp/gou/connector/anthropic"
|
||||||
"github.com/yaoapp/yao/agent/context"
|
"github.com/yaoapp/yao/agent/context"
|
||||||
|
"github.com/yaoapp/yao/agent/llm/providers/anthropic"
|
||||||
"github.com/yaoapp/yao/agent/llm/providers/openai"
|
"github.com/yaoapp/yao/agent/llm/providers/openai"
|
||||||
"github.com/yaoapp/yao/agent/output/message"
|
"github.com/yaoapp/yao/agent/output/message"
|
||||||
)
|
)
|
||||||
|
|
@ -40,10 +42,15 @@ func SelectProvider(conn connector.Connector, options *context.CompletionOptions
|
||||||
// - Reasoning (o1, GPT-4o thinking, etc.)
|
// - Reasoning (o1, GPT-4o thinking, etc.)
|
||||||
return openai.New(conn, options.Capabilities), nil
|
return openai.New(conn, options.Capabilities), nil
|
||||||
|
|
||||||
case "claude":
|
case "anthropic":
|
||||||
// TODO: Implement Claude provider
|
// Anthropic Messages API (Claude, Kimi Code, etc.)
|
||||||
// For now, use OpenAI provider (may have compatibility issues)
|
// Check if connector has native Anthropic capabilities
|
||||||
return openai.New(conn, options.Capabilities), nil
|
settings := conn.Setting()
|
||||||
|
if caps, ok := settings["capabilities"].(*gouAnthropicConn.Capabilities); ok {
|
||||||
|
return anthropic.NewFromAnthropicCaps(conn, caps), nil
|
||||||
|
}
|
||||||
|
// Fallback: use OpenAI capabilities (converted from connector settings)
|
||||||
|
return anthropic.New(conn, options.Capabilities), nil
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Default to OpenAI-compatible provider
|
// Default to OpenAI-compatible provider
|
||||||
|
|
@ -53,21 +60,24 @@ func SelectProvider(conn connector.Connector, options *context.CompletionOptions
|
||||||
|
|
||||||
// DetectAPIFormat detects the API format from connector
|
// DetectAPIFormat detects the API format from connector
|
||||||
func DetectAPIFormat(conn connector.Connector) string {
|
func DetectAPIFormat(conn connector.Connector) string {
|
||||||
// Check connector type
|
// Check connector type directly
|
||||||
|
if conn.Is(connector.ANTHROPIC) {
|
||||||
|
return "anthropic"
|
||||||
|
}
|
||||||
|
|
||||||
if conn.Is(connector.OPENAI) {
|
if conn.Is(connector.OPENAI) {
|
||||||
return "openai"
|
return "openai"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check connector settings for host URL
|
// Check connector settings for host URL patterns as fallback
|
||||||
settings := conn.Setting()
|
settings := conn.Setting()
|
||||||
if settings != nil {
|
if settings != nil {
|
||||||
if host, ok := settings["host"].(string); ok {
|
if host, ok := settings["host"].(string); ok {
|
||||||
// Detect by host URL patterns
|
if contains(host, "anthropic.com") || contains(host, "api.kimi.com/coding") {
|
||||||
if contains(host, "anthropic.com") || contains(host, "claude") {
|
return "anthropic"
|
||||||
return "claude"
|
|
||||||
}
|
}
|
||||||
if contains(host, "deepseek.com") {
|
if contains(host, "deepseek.com") {
|
||||||
return "openai" // DeepSeek uses OpenAI-compatible API
|
return "openai"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -338,9 +338,27 @@ func buildEnvironment(opts *Options, systemPrompt string) map[string]string {
|
||||||
// Explicitly set XAUTHORITY to the correct path.
|
// Explicitly set XAUTHORITY to the correct path.
|
||||||
env["XAUTHORITY"] = "/home/sandbox/.Xauthority"
|
env["XAUTHORITY"] = "/home/sandbox/.Xauthority"
|
||||||
|
|
||||||
// claude-proxy runs on localhost:3456, Claude CLI connects to it
|
if opts.ConnectorType == "anthropic" {
|
||||||
|
// Anthropic mode: Claude CLI connects directly to the Anthropic-compatible backend
|
||||||
|
// No proxy needed — the backend already speaks Anthropic Messages API
|
||||||
|
env["ANTHROPIC_BASE_URL"] = opts.ConnectorHost
|
||||||
|
env["ANTHROPIC_API_KEY"] = opts.ConnectorKey
|
||||||
|
} else {
|
||||||
|
// OpenAI mode (default): Claude CLI connects to claude-proxy on localhost:3456
|
||||||
|
// The proxy translates Anthropic Messages API → OpenAI Chat Completions API
|
||||||
env["ANTHROPIC_BASE_URL"] = "http://127.0.0.1:3456"
|
env["ANTHROPIC_BASE_URL"] = "http://127.0.0.1:3456"
|
||||||
env["ANTHROPIC_API_KEY"] = "dummy" // Proxy doesn't verify this
|
env["ANTHROPIC_API_KEY"] = "dummy" // Proxy doesn't verify this
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set model environment variables from connector
|
||||||
|
// Claude CLI uses these to select the model for all roles
|
||||||
|
if opts.Model != "" {
|
||||||
|
env["ANTHROPIC_MODEL"] = opts.Model
|
||||||
|
env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = opts.Model
|
||||||
|
env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = opts.Model
|
||||||
|
env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = opts.Model
|
||||||
|
env["CLAUDE_CODE_SUBAGENT_MODEL"] = opts.Model
|
||||||
|
}
|
||||||
|
|
||||||
// Pass secrets as environment variables for Claude CLI to use
|
// Pass secrets as environment variables for Claude CLI to use
|
||||||
// These are configured in package.yao sandbox.secrets (e.g., LLM_API_KEY, GITHUB_TOKEN)
|
// These are configured in package.yao sandbox.secrets (e.g., LLM_API_KEY, GITHUB_TOKEN)
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ type Options struct {
|
||||||
ConnectorHost string
|
ConnectorHost string
|
||||||
ConnectorKey string
|
ConnectorKey string
|
||||||
Model string
|
Model string
|
||||||
|
ConnectorType string // Connector API type: "openai" or "anthropic"
|
||||||
ConnectorOptions map[string]interface{} // Extra connector options (e.g., thinking, max_tokens)
|
ConnectorOptions map[string]interface{} // Extra connector options (e.g., thinking, max_tokens)
|
||||||
Secrets map[string]string // Secrets to pass to container (e.g., GITHUB_TOKEN)
|
Secrets map[string]string // Secrets to pass to container (e.g., GITHUB_TOKEN)
|
||||||
}
|
}
|
||||||
|
|
@ -330,6 +331,12 @@ func (e *Executor) startClaudeProxy(ctx context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip proxy for Anthropic connectors — Claude CLI connects directly
|
||||||
|
// The backend already speaks Anthropic Messages API, no conversion needed
|
||||||
|
if e.opts.ConnectorType == "anthropic" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Build proxy config
|
// Build proxy config
|
||||||
configJSON, err := BuildProxyConfig(e.opts)
|
configJSON, err := BuildProxyConfig(e.opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ func New(manager *infraSandbox.Manager, opts *Options) (Executor, error) {
|
||||||
ConnectorHost: opts.ConnectorHost,
|
ConnectorHost: opts.ConnectorHost,
|
||||||
ConnectorKey: opts.ConnectorKey,
|
ConnectorKey: opts.ConnectorKey,
|
||||||
Model: opts.Model,
|
Model: opts.Model,
|
||||||
|
ConnectorType: opts.ConnectorType, // "openai" or "anthropic"
|
||||||
ConnectorOptions: opts.ConnectorOptions, // Extra options like thinking, max_tokens
|
ConnectorOptions: opts.ConnectorOptions, // Extra options like thinking, max_tokens
|
||||||
Secrets: opts.Secrets, // Secrets for container env vars
|
Secrets: opts.Secrets, // Secrets for container env vars
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,10 @@ type Options struct {
|
||||||
ConnectorKey string `json:"-"`
|
ConnectorKey string `json:"-"`
|
||||||
Model string `json:"-"`
|
Model string `json:"-"`
|
||||||
|
|
||||||
|
// ConnectorType - connector API type: "openai" or "anthropic"
|
||||||
|
// Determines whether to use claude-proxy (openai) or direct connection (anthropic)
|
||||||
|
ConnectorType string `json:"-"`
|
||||||
|
|
||||||
// ConnectorOptions - extra options from connector config (e.g., thinking, max_tokens, temperature)
|
// ConnectorOptions - extra options from connector config (e.g., thinking, max_tokens, temperature)
|
||||||
// These are backend-specific parameters passed to the proxy
|
// These are backend-specific parameters passed to the proxy
|
||||||
ConnectorOptions map[string]interface{} `json:"-"`
|
ConnectorOptions map[string]interface{} `json:"-"`
|
||||||
|
|
|
||||||
|
|
@ -59,13 +59,13 @@ func listProviders(c *gin.Context) {
|
||||||
// Get user-defined model capabilities once at the start of request
|
// Get user-defined model capabilities once at the start of request
|
||||||
modelCapabilities := getModelCapabilities()
|
modelCapabilities := getModelCapabilities()
|
||||||
|
|
||||||
// Get all OpenAI-compatible LLM connectors from AIConnectors
|
// Get all LLM connectors from AIConnectors
|
||||||
// Note: All openai type connectors are automatically added to AIConnectors during loading
|
// Note: All AI type connectors (openai, anthropic, fastembed) are automatically added to AIConnectors during loading
|
||||||
// See gou/connector/connector.go LoadSource() for details
|
// See gou/connector/connector.go LoadSource() for details
|
||||||
for _, opt := range connector.AIConnectors {
|
for _, opt := range connector.AIConnectors {
|
||||||
connType := getConnectorType(opt.Value)
|
connType := getConnectorType(opt.Value)
|
||||||
// Only include OpenAI-compatible LLM connectors
|
// Include OpenAI-compatible and Anthropic LLM connectors
|
||||||
if connType == "openai" {
|
if connType == "openai" || connType == "anthropic" {
|
||||||
conn, ok := connector.Connectors[opt.Value]
|
conn, ok := connector.Connectors[opt.Value]
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
|
|
@ -99,11 +99,14 @@ func getConnectorType(id string) string {
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only return openai type (OpenAI-compatible format)
|
|
||||||
if conn.Is(connector.OPENAI) {
|
if conn.Is(connector.OPENAI) {
|
||||||
return "openai"
|
return "openai"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if conn.Is(connector.ANTHROPIC) {
|
||||||
|
return "anthropic"
|
||||||
|
}
|
||||||
|
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue