mirror of
https://github.com/danielmiessler/fabric.git
synced 2026-09-10 07:36:44 -04:00
Merge pull request #1908 from rodaddy/feature/vertexai-provider
feat(ai): add VertexAI provider for Claude models
This commit is contained in:
commit
baf5a2fecb
7
cmd/generate_changelog/incoming/1908.txt
Normal file
7
cmd/generate_changelog/incoming/1908.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
### PR [#1908](https://github.com/danielmiessler/Fabric/pull/1908) by [rodaddy](https://github.com/rodaddy): feat(ai): add VertexAI provider for Claude models
|
||||
|
||||
- Added support for Google Cloud Vertex AI as a provider to access Claude models using Application Default Credentials (ADC)
|
||||
- Enabled routing of Fabric requests through Google Cloud Platform instead of directly to Anthropic for GCP billing
|
||||
- Implemented support for Claude models (Sonnet 4.5, Opus 4.5, Haiku 4.5, etc.) via Vertex AI
|
||||
- Added Google ADC authentication support eliminating the need for API keys
|
||||
- Configured project ID and region settings with 'global' as default for cost optimization
|
||||
2
go.mod
2
go.mod
|
|
@ -58,9 +58,11 @@ require (
|
|||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.57.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
|
||||
go.uber.org/mock v0.6.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.40.0 // indirect
|
||||
)
|
||||
|
||||
|
|
|
|||
11
go.sum
11
go.sum
|
|
@ -81,6 +81,8 @@ github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ
|
|||
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y+bSQAYZnetRJ70VMVKm5CKI0=
|
||||
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4=
|
||||
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
|
||||
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
|
|
@ -94,6 +96,11 @@ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o
|
|||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.35.0 h1:ixjkELDE+ru6idPxcHLj8LBVc2bFP7iBytj353BoHUo=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
|
|
@ -248,6 +255,8 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmd
|
|||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
|
|
@ -312,6 +321,8 @@ github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI
|
|||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import (
|
|||
"github.com/danielmiessler/fabric/internal/plugins/ai/openai"
|
||||
"github.com/danielmiessler/fabric/internal/plugins/ai/openai_compatible"
|
||||
"github.com/danielmiessler/fabric/internal/plugins/ai/perplexity"
|
||||
"github.com/danielmiessler/fabric/internal/plugins/ai/vertexai"
|
||||
"github.com/danielmiessler/fabric/internal/plugins/strategy"
|
||||
|
||||
"github.com/samber/lo"
|
||||
|
|
@ -101,6 +102,7 @@ func NewPluginRegistry(db *fsdb.Db) (ret *PluginRegistry, err error) {
|
|||
azure.NewClient(),
|
||||
gemini.NewClient(),
|
||||
anthropic.NewClient(),
|
||||
vertexai.NewClient(),
|
||||
lmstudio.NewClient(),
|
||||
exolab.NewClient(),
|
||||
perplexity.NewClient(), // Added Perplexity client
|
||||
|
|
|
|||
210
internal/plugins/ai/vertexai/vertexai.go
Normal file
210
internal/plugins/ai/vertexai/vertexai.go
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
package vertexai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/anthropics/anthropic-sdk-go"
|
||||
"github.com/anthropics/anthropic-sdk-go/vertex"
|
||||
"github.com/danielmiessler/fabric/internal/chat"
|
||||
"github.com/danielmiessler/fabric/internal/domain"
|
||||
"github.com/danielmiessler/fabric/internal/plugins"
|
||||
)
|
||||
|
||||
const (
|
||||
cloudPlatformScope = "https://www.googleapis.com/auth/cloud-platform"
|
||||
defaultRegion = "global"
|
||||
maxTokens = 4096
|
||||
)
|
||||
|
||||
// NewClient creates a new Vertex AI client for accessing Claude models via Google Cloud
|
||||
func NewClient() (ret *Client) {
|
||||
vendorName := "VertexAI"
|
||||
ret = &Client{}
|
||||
|
||||
ret.PluginBase = &plugins.PluginBase{
|
||||
Name: vendorName,
|
||||
EnvNamePrefix: plugins.BuildEnvVariablePrefix(vendorName),
|
||||
ConfigureCustom: ret.configure,
|
||||
}
|
||||
|
||||
ret.ProjectID = ret.AddSetupQuestion("Project ID", true)
|
||||
ret.Region = ret.AddSetupQuestion("Region", false)
|
||||
ret.Region.Value = defaultRegion
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Client implements the ai.Vendor interface for Google Cloud Vertex AI with Anthropic models
|
||||
type Client struct {
|
||||
*plugins.PluginBase
|
||||
ProjectID *plugins.SetupQuestion
|
||||
Region *plugins.SetupQuestion
|
||||
|
||||
client *anthropic.Client
|
||||
}
|
||||
|
||||
func (c *Client) configure() error {
|
||||
ctx := context.Background()
|
||||
projectID := c.ProjectID.Value
|
||||
region := c.Region.Value
|
||||
|
||||
// Initialize Anthropic client for Claude models via Vertex AI using Google ADC
|
||||
vertexOpt := vertex.WithGoogleAuth(ctx, region, projectID, cloudPlatformScope)
|
||||
client := anthropic.NewClient(vertexOpt)
|
||||
c.client = &client
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) ListModels() ([]string, error) {
|
||||
// Return Claude models available on Vertex AI
|
||||
return []string{
|
||||
string(anthropic.ModelClaudeSonnet4_5),
|
||||
string(anthropic.ModelClaudeOpus4_5),
|
||||
string(anthropic.ModelClaudeHaiku4_5),
|
||||
string(anthropic.ModelClaude3_7SonnetLatest),
|
||||
string(anthropic.ModelClaude3_5HaikuLatest),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Send(ctx context.Context, msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions) (string, error) {
|
||||
if c.client == nil {
|
||||
return "", fmt.Errorf("VertexAI client not initialized")
|
||||
}
|
||||
|
||||
// Convert chat messages to Anthropic format
|
||||
anthropicMessages := c.toMessages(msgs)
|
||||
if len(anthropicMessages) == 0 {
|
||||
return "", fmt.Errorf("no valid messages to send")
|
||||
}
|
||||
|
||||
// Create the request
|
||||
response, err := c.client.Messages.New(ctx, anthropic.MessageNewParams{
|
||||
Model: anthropic.Model(opts.Model),
|
||||
MaxTokens: int64(maxTokens),
|
||||
Messages: anthropicMessages,
|
||||
Temperature: anthropic.Opt(opts.Temperature),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Extract text from response
|
||||
var textParts []string
|
||||
for _, block := range response.Content {
|
||||
if block.Type == "text" && block.Text != "" {
|
||||
textParts = append(textParts, block.Text)
|
||||
}
|
||||
}
|
||||
|
||||
if len(textParts) == 0 {
|
||||
return "", fmt.Errorf("no content in response")
|
||||
}
|
||||
|
||||
return strings.Join(textParts, ""), nil
|
||||
}
|
||||
|
||||
func (c *Client) SendStream(msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions, channel chan string) error {
|
||||
if c.client == nil {
|
||||
close(channel)
|
||||
return fmt.Errorf("VertexAI client not initialized")
|
||||
}
|
||||
|
||||
defer close(channel)
|
||||
ctx := context.Background()
|
||||
|
||||
// Convert chat messages to Anthropic format
|
||||
anthropicMessages := c.toMessages(msgs)
|
||||
if len(anthropicMessages) == 0 {
|
||||
return fmt.Errorf("no valid messages to send")
|
||||
}
|
||||
|
||||
// Create streaming request
|
||||
stream := c.client.Messages.NewStreaming(ctx, anthropic.MessageNewParams{
|
||||
Model: anthropic.Model(opts.Model),
|
||||
MaxTokens: int64(maxTokens),
|
||||
Messages: anthropicMessages,
|
||||
Temperature: anthropic.Opt(opts.Temperature),
|
||||
})
|
||||
|
||||
// Process stream
|
||||
for stream.Next() {
|
||||
event := stream.Current()
|
||||
if event.Delta.Text != "" {
|
||||
channel <- event.Delta.Text
|
||||
}
|
||||
}
|
||||
|
||||
return stream.Err()
|
||||
}
|
||||
|
||||
func (c *Client) toMessages(msgs []*chat.ChatCompletionMessage) []anthropic.MessageParam {
|
||||
// Convert messages to Anthropic format with proper role handling
|
||||
// - System messages become part of the first user message
|
||||
// - Messages must alternate user/assistant
|
||||
// - Skip empty messages
|
||||
|
||||
var anthropicMessages []anthropic.MessageParam
|
||||
var systemContent string
|
||||
|
||||
isFirstUserMessage := true
|
||||
lastRoleWasUser := false
|
||||
|
||||
for _, msg := range msgs {
|
||||
if strings.TrimSpace(msg.Content) == "" {
|
||||
continue // Skip empty messages
|
||||
}
|
||||
|
||||
switch msg.Role {
|
||||
case chat.ChatMessageRoleSystem:
|
||||
// Accumulate system content to prepend to first user message
|
||||
if systemContent != "" {
|
||||
systemContent += "\\n" + msg.Content
|
||||
} else {
|
||||
systemContent = msg.Content
|
||||
}
|
||||
case chat.ChatMessageRoleUser:
|
||||
userContent := msg.Content
|
||||
if isFirstUserMessage && systemContent != "" {
|
||||
userContent = systemContent + "\\n\\n" + userContent
|
||||
isFirstUserMessage = false
|
||||
}
|
||||
if lastRoleWasUser {
|
||||
// Enforce alternation: add a minimal assistant message
|
||||
anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(anthropic.NewTextBlock("Okay.")))
|
||||
}
|
||||
anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(anthropic.NewTextBlock(userContent)))
|
||||
lastRoleWasUser = true
|
||||
case chat.ChatMessageRoleAssistant:
|
||||
// If first message is assistant and we have system content, prepend user message
|
||||
if isFirstUserMessage && systemContent != "" {
|
||||
anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(anthropic.NewTextBlock(systemContent)))
|
||||
lastRoleWasUser = true
|
||||
isFirstUserMessage = false
|
||||
} else if !lastRoleWasUser && len(anthropicMessages) > 0 {
|
||||
// Enforce alternation: add a minimal user message
|
||||
anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(anthropic.NewTextBlock("Hi")))
|
||||
lastRoleWasUser = true
|
||||
}
|
||||
anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(anthropic.NewTextBlock(msg.Content)))
|
||||
lastRoleWasUser = false
|
||||
default:
|
||||
// Other roles are ignored for Anthropic's message structure
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// If only system content was provided, create a user message with it
|
||||
if len(anthropicMessages) == 0 && systemContent != "" {
|
||||
anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(anthropic.NewTextBlock(systemContent)))
|
||||
}
|
||||
|
||||
return anthropicMessages
|
||||
}
|
||||
|
||||
func (c *Client) NeedsRawMode(modelName string) bool {
|
||||
return false
|
||||
}
|
||||
Loading…
Reference in a new issue