Merge pull request #1458 from trheyi/main

Implement support for Anthropic connectors in the LLM and sandbox com…
This commit is contained in:
Max 2026-02-11 12:36:25 +08:00 committed by GitHub
commit 8dff448bd0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1424 additions and 18 deletions

View file

@ -9,6 +9,7 @@ import (
"sync"
"time"
"github.com/yaoapp/gou/connector"
gouMCP "github.com/yaoapp/gou/mcp"
mcpProcess "github.com/yaoapp/gou/mcp/process"
"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)
}
// 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()
if host, ok := setting["host"].(string); ok {
execOpts.ConnectorHost = host

View file

@ -2,6 +2,7 @@ package llm
import (
"github.com/yaoapp/gou/connector"
"github.com/yaoapp/gou/connector/anthropic"
"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 {
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
}
// 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,
}
}

File diff suppressed because it is too large Load diff

View 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
}

View file

@ -4,7 +4,9 @@ import (
"fmt"
"github.com/yaoapp/gou/connector"
gouAnthropicConn "github.com/yaoapp/gou/connector/anthropic"
"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/output/message"
)
@ -40,10 +42,15 @@ func SelectProvider(conn connector.Connector, options *context.CompletionOptions
// - Reasoning (o1, GPT-4o thinking, etc.)
return openai.New(conn, options.Capabilities), nil
case "claude":
// TODO: Implement Claude provider
// For now, use OpenAI provider (may have compatibility issues)
return openai.New(conn, options.Capabilities), nil
case "anthropic":
// Anthropic Messages API (Claude, Kimi Code, etc.)
// Check if connector has native Anthropic capabilities
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 to OpenAI-compatible provider
@ -53,21 +60,24 @@ func SelectProvider(conn connector.Connector, options *context.CompletionOptions
// DetectAPIFormat detects the API format from connector
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) {
return "openai"
}
// Check connector settings for host URL
// Check connector settings for host URL patterns as fallback
settings := conn.Setting()
if settings != nil {
if host, ok := settings["host"].(string); ok {
// Detect by host URL patterns
if contains(host, "anthropic.com") || contains(host, "claude") {
return "claude"
if contains(host, "anthropic.com") || contains(host, "api.kimi.com/coding") {
return "anthropic"
}
if contains(host, "deepseek.com") {
return "openai" // DeepSeek uses OpenAI-compatible API
return "openai"
}
}
}

View file

@ -338,9 +338,27 @@ func buildEnvironment(opts *Options, systemPrompt string) map[string]string {
// Explicitly set XAUTHORITY to the correct path.
env["XAUTHORITY"] = "/home/sandbox/.Xauthority"
// claude-proxy runs on localhost:3456, Claude CLI connects to it
env["ANTHROPIC_BASE_URL"] = "http://127.0.0.1:3456"
env["ANTHROPIC_API_KEY"] = "dummy" // Proxy doesn't verify this
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_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
// These are configured in package.yao sandbox.secrets (e.g., LLM_API_KEY, GITHUB_TOKEN)

View file

@ -38,6 +38,7 @@ type Options struct {
ConnectorHost string
ConnectorKey string
Model string
ConnectorType string // Connector API type: "openai" or "anthropic"
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)
}
@ -330,6 +331,12 @@ func (e *Executor) startClaudeProxy(ctx context.Context) error {
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
configJSON, err := BuildProxyConfig(e.opts)
if err != nil {

View file

@ -41,6 +41,7 @@ func New(manager *infraSandbox.Manager, opts *Options) (Executor, error) {
ConnectorHost: opts.ConnectorHost,
ConnectorKey: opts.ConnectorKey,
Model: opts.Model,
ConnectorType: opts.ConnectorType, // "openai" or "anthropic"
ConnectorOptions: opts.ConnectorOptions, // Extra options like thinking, max_tokens
Secrets: opts.Secrets, // Secrets for container env vars
}

View file

@ -94,6 +94,10 @@ type Options struct {
ConnectorKey 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)
// These are backend-specific parameters passed to the proxy
ConnectorOptions map[string]interface{} `json:"-"`

View file

@ -59,13 +59,13 @@ func listProviders(c *gin.Context) {
// Get user-defined model capabilities once at the start of request
modelCapabilities := getModelCapabilities()
// Get all OpenAI-compatible LLM connectors from AIConnectors
// Note: All openai type connectors are automatically added to AIConnectors during loading
// Get all LLM connectors from AIConnectors
// Note: All AI type connectors (openai, anthropic, fastembed) are automatically added to AIConnectors during loading
// See gou/connector/connector.go LoadSource() for details
for _, opt := range connector.AIConnectors {
connType := getConnectorType(opt.Value)
// Only include OpenAI-compatible LLM connectors
if connType == "openai" {
// Include OpenAI-compatible and Anthropic LLM connectors
if connType == "openai" || connType == "anthropic" {
conn, ok := connector.Connectors[opt.Value]
if !ok {
continue
@ -99,11 +99,14 @@ func getConnectorType(id string) string {
return "unknown"
}
// Only return openai type (OpenAI-compatible format)
if conn.Is(connector.OPENAI) {
return "openai"
}
if conn.Is(connector.ANTHROPIC) {
return "anthropic"
}
return "unknown"
}