feat(providers): add Google Cloud Vertex AI provider (#5)

* feat(providers): add Google Cloud Vertex AI provider (#576)

Add `vertex` built-in provider type that routes Gemini calls through Google
Cloud Vertex AI's OpenAI-compatible endpoint. Enterprises on GCP can now use
regional endpoints for data residency, consolidate AI spend under existing GCP
billing, enforce IAM/VPC-SC controls, and use committed-use discounts instead
of standalone Google AI Studio API keys.

Implementation reuses OpenAIProvider via the OpenAI-compat path; the only
provider-specific logic is OAuth2 auth wiring:

- New factory NewVertexProvider in internal/providers/vertex.go builds an
  *http.Client with oauth2.Transport, which auto-refreshes GCP access tokens
  (1-hour lifetime) transparently. Credentials precedence:
  inline SA JSON > credentials_file path > Application Default Credentials
  (works on GKE/Cloud Run/Compute Engine via metadata server).
- OpenAIProvider gets WithHTTPClient() + WithoutAuthHeader() options so the
  oauth2 transport injects Authorization rather than doRequest() setting a
  static Bearer header.
- Endpoint URL computed at registration time from project_id + region:
  https://{region}-aiplatform.googleapis.com/v1/projects/{p}/locations/{r}/endpoints/openapi
- Store: api_key column holds AES-256-GCM-encrypted SA JSON (same as other
  providers); settings JSONB holds {project_id, region, model}.
- Env vars: GOCLAW_VERTEX_{API_KEY,CREDENTIALS_FILE,PROJECT_ID,REGION,MODEL}.

Registration wired through all three paths: config-driven startup, DB-driven
startup, and HTTP CRUD in-memory registration. Vertex handled before the
generic "api_key empty" guard so ADC deployments register correctly.

Code-review fixes applied:

- H1 (correctness): Gemini thought_signature detection in openai.go now
  recognizes providerType="vertex" and apiBase suffix "aiplatform". Previously
  only worked because the default model string coincidentally contained
  "gemini"; custom model IDs or fine-tuned endpoint numeric IDs would drop the
  signature on passback and trigger HTTP 400 mid-tool-loop. Regression test
  added (TestVertexProviderForwardsThoughtSignatureOnToolCalls).
- M1 (hardening): region and project_id are regex-validated before URL
  concatenation to prevent hostname injection (e.g. region="evil.com/a?").
- M2 (hardening): APIBaseOverride must be https + *.googleapis.com host to
  prevent data exfiltration via crafted DB rows.
- M3 (documentation): CredentialsFile marked operator-only in the struct
  comment — never expose via admin UI or DB settings without path allow-list.

Tests: 17 Vertex-related unit tests. go build ./... + go build -tags sqliteonly
./... + go vet ./... all clean. Pre-existing TestSignMediaPath failure on
Windows (file_token.go uses path/filepath) is unrelated to this change.

* chore: trigger CI on digitopvn/goclaw fork

* ci: ping

* ci: retrigger workflows
This commit is contained in:
Duy /zuey/
2026-05-11 12:54:05 +07:00
committed by GitHub
parent a97e5028af
commit 2da52cfaee
17 changed files with 688 additions and 8 deletions
+1
View File
@@ -82,6 +82,7 @@ All notable changes to GoClaw are documented here. For full documentation, see [
- **Hooks system** — Event-driven hooks with command evaluators (shell exit code) and agent evaluators (delegate to reviewer). Blocking gates with auto-retry and recursion-safe evaluation.
- **Media tools** — `create_image` (DashScope, MiniMax), `create_audio` (OpenAI, ElevenLabs, MiniMax, Suno), `create_video` (MiniMax, Veo), `read_document` (Gemini File API), `read_image`, `read_audio`, `read_video`. Persistent media storage with lazy-loaded MediaRef.
- **Additional provider modes** — Claude CLI (Anthropic via stdio + MCP bridge), Codex (OpenAI gpt-5.3-codex via OAuth).
- **Google Cloud Vertex AI provider** — Enterprise GCP integration via Vertex OpenAI-compatible endpoint. OAuth2 service account auth (inline JSON or file path) with automatic token refresh, plus Application Default Credentials (ADC) for GKE/Cloud Run/Compute Engine. Regional endpoints for data residency (e.g. `asia-southeast1`, `us-central1`). Addresses [#576](https://github.com/nextlevelbuilder/goclaw/issues/576).
- **Knowledge graph** — LLM-powered entity extraction, graph traversal, force-directed visualization, and `knowledge_graph_search` agent tool.
- **Memory management** — Admin dashboard for memory documents (CRUD, semantic search, chunk/embedding details, bulk re-indexing).
- **Persistent pending messages** — Channel messages persisted to PostgreSQL with auto-compaction (LLM summarization) and monitoring dashboard.
+2 -2
View File
@@ -44,7 +44,7 @@ internal/
├── orchestration/ Orchestration primitives: BatchQueue[T] generic, ChildResult, media conversion (v3)
├── permissions/ RBAC (admin/operator/viewer)
├── pipeline/ 8-stage agent pipeline (context→history→prompt→think→act→observe→memory→summarize)
├── providers/ LLM providers: Anthropic (native HTTP+SSE), OpenAI-compat (HTTP+SSE), DashScope (Alibaba Qwen), Claude CLI (stdio+MCP bridge), ACP (Anthropic Console Proxy), Codex (OpenAI)
├── providers/ LLM providers: Anthropic (native HTTP+SSE), OpenAI-compat (HTTP+SSE), DashScope (Alibaba Qwen), Claude CLI (stdio+MCP bridge), ACP (Anthropic Console Proxy), Codex (OpenAI), Vertex AI (GCP OAuth2 + OpenAI-compat)
├── providerresolve/ Provider adapter + model registry with forward-compat resolver
├── sandbox/ Docker-based code execution sandbox
├── scheduler/ Lane-based concurrency (main/subagent/cron)
@@ -76,7 +76,7 @@ ui/desktop/ Wails v2 desktop app (React frontend + embedded ga
- **Agent types:** `open` (per-user context, 7 files) vs `predefined` (shared context + USER.md per-user)
- **Agent identity:** Dual-identity pattern (agent_key vs UUID) applies to agents, teams, tenants. Rule: UUID for DB/FK/events, agent_key for logs/paths/UI. See `docs/agent-identity-conventions.md`
- **Context files:** `agent_context_files` (agent-level) + `user_context_files` (per-user), routed via `ContextFileInterceptor`
- **Providers:** Anthropic (native HTTP+SSE), OpenAI-compat (HTTP+SSE), DashScope (Alibaba Qwen), Claude CLI (stdio+MCP bridge), ACP (Anthropic Console Proxy), Codex (OpenAI). All use `RetryDo()` for retries. Loads from `llm_providers` table with encrypted API keys. ProviderAdapter enables pluggable implementations with ModelRegistry forward-compat resolver. Shared SSEScanner in `providers/sse_reader.go` for streaming providers
- **Providers:** Anthropic (native HTTP+SSE), OpenAI-compat (HTTP+SSE), DashScope (Alibaba Qwen), Claude CLI (stdio+MCP bridge), ACP (Anthropic Console Proxy), Codex (OpenAI), Vertex AI (GCP OAuth2 service account or ADC + OpenAI-compat endpoint, `internal/providers/vertex.go`). All use `RetryDo()` for retries. Loads from `llm_providers` table with encrypted API keys. ProviderAdapter enables pluggable implementations with ModelRegistry forward-compat resolver. Shared SSEScanner in `providers/sse_reader.go` for streaming providers
- **Pipeline:** 8-stage loop (context→history→prompt→think→act→observe→memory→summarize) with pluggable callbacks, always-on execution path
- **DomainEventBus:** Typed events with worker pool, dedup, retry. Used by consolidation pipeline and memory workers
- **3-tier memory:** Working (conversation) → Episodic (session summaries) → Semantic (KG). Progressive loading L0/L1/L2 with auto-inject for L0
+45
View File
@@ -174,6 +174,27 @@ func registerProviders(registry *providers.Registry, cfg *config.Config, modelRe
slog.Info("registered provider", "name", "byteplus-coding")
}
// Google Cloud Vertex AI — OAuth2 service account or Application Default Credentials.
// Registers when project_id + region are set. Credential sources (priority order):
// inline JSON (APIKey) → file path (CredentialsFile) → ADC.
if cfg.Providers.Vertex.ProjectID != "" && cfg.Providers.Vertex.Region != "" {
vcfg := providers.VertexConfig{
Name: "vertex",
CredentialsJSON: cfg.Providers.Vertex.APIKey,
CredentialsFile: cfg.Providers.Vertex.CredentialsFile,
ProjectID: cfg.Providers.Vertex.ProjectID,
Region: cfg.Providers.Vertex.Region,
DefaultModel: cfg.Providers.Vertex.Model,
}
prov, err := providers.NewVertexProviderWithTimeout(vcfg)
if err != nil {
slog.Warn("vertex: initialization failed", "error", err)
} else {
registry.Register(prov)
slog.Info("registered provider", "name", "vertex", "region", cfg.Providers.Vertex.Region, "project", cfg.Providers.Vertex.ProjectID)
}
}
// Claude CLI provider (subscription-based, no API key needed)
if cfg.Providers.ClaudeCLI.CLIPath != "" {
cliPath := cfg.Providers.ClaudeCLI.CLIPath
@@ -323,6 +344,30 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi
slog.Info("registered provider from DB", "name", p.Name)
continue
}
// Vertex supports ADC (empty api_key) — handle before the generic key guard.
if p.ProviderType == store.ProviderVertex {
vsettings := store.ParseVertexProviderSettings(p.Settings)
if vsettings == nil {
slog.Warn("vertex: missing project_id/region in settings, skipping", "name", p.Name)
continue
}
vcfg := providers.VertexConfig{
Name: p.Name,
CredentialsJSON: p.APIKey,
ProjectID: vsettings.ProjectID,
Region: vsettings.Region,
DefaultModel: vsettings.Model,
APIBaseOverride: p.APIBase,
}
prov, err := providers.NewVertexProviderWithTimeout(vcfg)
if err != nil {
slog.Warn("vertex: init from DB failed", "name", p.Name, "error", err)
continue
}
registry.RegisterForTenant(p.TenantID, prov)
slog.Info("registered provider from DB", "name", p.Name, "type", "vertex", "region", vsettings.Region)
continue
}
if p.APIKey == "" {
continue
+2 -1
View File
@@ -42,6 +42,7 @@ require (
go.opentelemetry.io/otel/sdk v1.40.0
go.opentelemetry.io/otel/trace v1.40.0
golang.org/x/image v0.27.0
golang.org/x/oauth2 v0.34.0
golang.org/x/time v0.14.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.47.0
@@ -50,6 +51,7 @@ require (
require (
cel.dev/expr v0.25.1 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/akutz/memconn v0.1.0 // indirect
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect
@@ -154,7 +156,6 @@ require (
go.uber.org/atomic v1.11.0 // indirect
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/term v0.40.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
+2
View File
@@ -2,6 +2,8 @@
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM=
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc=
+17 -1
View File
@@ -218,6 +218,18 @@ type ProvidersConfig struct {
Novita ProviderConfig `json:"novita"` // Novita AI (OpenAI-compatible endpoint)
BytePlus ProviderConfig `json:"byteplus"` // BytePlus ModelArk (Seed 2.0)
BytePlusCoding ProviderConfig `json:"byteplus_coding"` // BytePlus ModelArk Coding Plan
Vertex VertexConfig `json:"vertex"` // Google Cloud Vertex AI (OAuth2 service account + ADC)
}
// VertexConfig configures Google Cloud Vertex AI.
// Credentials precedence: APIKey (inline JSON) > CredentialsFile (path) > ADC (both empty).
// ProjectID and Region are required; Model optional (defaults to google/gemini-2.0-flash-001).
type VertexConfig struct {
APIKey string `json:"api_key,omitempty"` // service account JSON inline (secret — never persist in config.json)
CredentialsFile string `json:"credentials_file,omitempty"` // path to service account JSON file
ProjectID string `json:"project_id,omitempty"`
Region string `json:"region,omitempty"`
Model string `json:"model,omitempty"`
}
// OllamaConfig configures a local (or self-hosted) Ollama instance.
@@ -292,6 +304,9 @@ func (p *ProvidersConfig) APIBaseForType(providerType string) string {
return p.BytePlus.APIBase
case "byteplus_coding":
return p.BytePlusCoding.APIBase
case "vertex":
// Computed from project+region at registration time; no config-level static base.
return ""
default:
return ""
}
@@ -321,7 +336,8 @@ func (c *Config) HasAnyProvider() bool {
p.ACP.Binary != "" ||
p.Novita.APIKey != "" ||
p.BytePlus.APIKey != "" ||
p.BytePlusCoding.APIKey != ""
p.BytePlusCoding.APIKey != "" ||
(p.Vertex.ProjectID != "" && p.Vertex.Region != "")
}
// QuotaWindow defines request limits per time window. Zero means unlimited.
+8
View File
@@ -109,6 +109,14 @@ func (c *Config) applyEnvOverrides() {
envStr("GOCLAW_OLLAMA_HOST", &c.Providers.Ollama.Host)
envStr("GOCLAW_OLLAMA_CLOUD_API_KEY", &c.Providers.OllamaCloud.APIKey)
envStr("GOCLAW_OLLAMA_CLOUD_API_BASE", &c.Providers.OllamaCloud.APIBase)
// Google Cloud Vertex AI (OAuth2 service account + ADC).
// APIKey may hold inline SA JSON; CredentialsFile is a path to SA JSON.
// If both empty, ADC (GOOGLE_APPLICATION_CREDENTIALS / gcloud / GCE metadata) is used.
envStr("GOCLAW_VERTEX_API_KEY", &c.Providers.Vertex.APIKey)
envStr("GOCLAW_VERTEX_CREDENTIALS_FILE", &c.Providers.Vertex.CredentialsFile)
envStr("GOCLAW_VERTEX_PROJECT_ID", &c.Providers.Vertex.ProjectID)
envStr("GOCLAW_VERTEX_REGION", &c.Providers.Vertex.Region)
envStr("GOCLAW_VERTEX_MODEL", &c.Providers.Vertex.Model)
envStr("GOCLAW_GATEWAY_TOKEN", &c.Gateway.Token)
envStr("GOCLAW_TELEGRAM_TOKEN", &c.Channels.Telegram.Token)
envStr("GOCLAW_DISCORD_TOKEN", &c.Channels.Discord.Token)
+3
View File
@@ -37,6 +37,7 @@ func (c *Config) MaskedCopy() *Config {
maskNonEmpty(&cp.Providers.Zai.APIKey)
maskNonEmpty(&cp.Providers.ZaiCoding.APIKey)
maskNonEmpty(&cp.Providers.OllamaCloud.APIKey)
maskNonEmpty(&cp.Providers.Vertex.APIKey)
// Mask gateway token
maskNonEmpty(&cp.Gateway.Token)
@@ -84,6 +85,7 @@ func (c *Config) StripSecrets() {
c.Providers.Zai.APIKey = ""
c.Providers.ZaiCoding.APIKey = ""
c.Providers.OllamaCloud.APIKey = ""
c.Providers.Vertex.APIKey = ""
// Gateway token
c.Gateway.Token = ""
@@ -136,6 +138,7 @@ func (c *Config) StripMaskedSecrets() {
stripIfMasked(&c.Providers.Zai.APIKey)
stripIfMasked(&c.Providers.ZaiCoding.APIKey)
stripIfMasked(&c.Providers.OllamaCloud.APIKey)
stripIfMasked(&c.Providers.Vertex.APIKey)
// Gateway token
stripIfMasked(&c.Gateway.Token)
+23
View File
@@ -204,6 +204,29 @@ func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) {
h.providerReg.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, "ollama", config.DockerLocalhost(host), "llama3.3"))
return
}
// Vertex supports ADC (empty api_key) — handle before the generic key guard.
if p.ProviderType == store.ProviderVertex {
vsettings := store.ParseVertexProviderSettings(p.Settings)
if vsettings == nil {
slog.Warn("vertex: missing project_id/region in settings, cannot register", "name", p.Name)
return
}
vcfg := providers.VertexConfig{
Name: p.Name,
CredentialsJSON: p.APIKey,
ProjectID: vsettings.ProjectID,
Region: vsettings.Region,
DefaultModel: vsettings.Model,
APIBaseOverride: p.APIBase,
}
prov, err := providers.NewVertexProviderWithTimeout(vcfg)
if err != nil {
slog.Warn("vertex: register in-memory failed", "name", p.Name, "error", err)
return
}
h.providerReg.RegisterForTenant(p.TenantID, prov)
return
}
if p.APIKey == "" {
return
}
+16
View File
@@ -21,6 +21,7 @@ type OpenAIProvider struct {
retryConfig RetryConfig
middlewares RequestMiddleware // composed middleware chain (nil = no-op)
registry ModelRegistry // model resolution registry (nil = skip)
noAuthHeader bool // when true, doRequest() skips setting Authorization (e.g. Vertex OAuth transport injects its own)
}
func NewOpenAIProvider(name, apiKey, apiBase, defaultModel string) *OpenAIProvider {
@@ -80,6 +81,21 @@ func (p *OpenAIProvider) WithProviderType(pt string) *OpenAIProvider {
return p
}
// WithHTTPClient overrides the default HTTP client. Used by Vertex to inject an oauth2.Transport.
func (p *OpenAIProvider) WithHTTPClient(c *http.Client) *OpenAIProvider {
if c != nil {
p.client = c
}
return p
}
// WithoutAuthHeader disables the Authorization header in doRequest(). Used by Vertex where
// the oauth2.Transport injects Authorization itself.
func (p *OpenAIProvider) WithoutAuthHeader() *OpenAIProvider {
p.noAuthHeader = true
return p
}
func (p *OpenAIProvider) Name() string { return p.name }
func (p *OpenAIProvider) DefaultModel() string { return p.defaultModel }
func (p *OpenAIProvider) SupportsThinking() bool { return true }
+5 -3
View File
@@ -26,10 +26,12 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, body any) (io.ReadCloser
}
httpReq.Header.Set("Content-Type", "application/json")
// Azure OpenAI/Foundry support for now atleast
if strings.Contains(strings.ToLower(p.apiBase), "azure.com") {
switch {
case p.noAuthHeader:
// Caller-supplied transport (e.g. Vertex oauth2.Transport) injects Authorization itself.
case strings.Contains(strings.ToLower(p.apiBase), "azure.com"):
httpReq.Header.Set("api-key", p.apiKey)
} else {
default:
prefix := p.authPrefix
if prefix == "" {
prefix = "Bearer "
+3 -1
View File
@@ -19,7 +19,9 @@ func (p *OpenAIProvider) buildRequestBody(model string, req ChatRequest, stream
supportsThoughtSignature := strings.Contains(strings.ToLower(p.providerType), "gemini") ||
strings.Contains(strings.ToLower(p.name), "gemini") ||
strings.Contains(strings.ToLower(p.apiBase), "generativelanguage") ||
strings.Contains(strings.ToLower(model), "gemini")
strings.Contains(strings.ToLower(model), "gemini") ||
strings.ToLower(p.providerType) == "vertex" ||
strings.Contains(strings.ToLower(p.apiBase), "aiplatform")
if supportsThoughtSignature {
inputMessages = collapseToolCallsWithoutSig(inputMessages)
+210
View File
@@ -0,0 +1,210 @@
package providers
import (
"context"
"fmt"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
// Vertex AI constants. Kept in the providers package (not store) to avoid an
// import cycle — store is imported by providers, so providers cannot import store.
const (
// VertexDefaultModel is the default Gemini model id (Vertex requires the "google/" prefix).
VertexDefaultModel = "google/gemini-2.0-flash-001"
// VertexDefaultScope is the OAuth2 scope for Vertex AI access.
VertexDefaultScope = "https://www.googleapis.com/auth/cloud-platform"
// ProviderTypeVertex mirrors store.ProviderVertex; duplicated here to keep the
// providers package free of a store import. Kept in sync by convention.
ProviderTypeVertex = "vertex"
)
// VertexDefaultAPIBase builds the Vertex AI OpenAI-compatible endpoint URL
// from a GCP project ID and region. Returns empty when either is missing.
// Matches: https://{region}-aiplatform.googleapis.com/v1/projects/{project}/locations/{region}/endpoints/openapi
func VertexDefaultAPIBase(projectID, region string) string {
if projectID == "" || region == "" {
return ""
}
return "https://" + region + "-aiplatform.googleapis.com/v1/projects/" +
projectID + "/locations/" + region + "/endpoints/openapi"
}
// VertexConfig is the input needed to build a Vertex AI provider instance.
// Credentials precedence: CredentialsJSON > CredentialsFile > ADC (Application Default Credentials).
// When all credential sources are empty, ADC is used — works on GCE/GKE/Cloud Run where
// the metadata server issues tokens automatically, or when GOOGLE_APPLICATION_CREDENTIALS is set.
type VertexConfig struct {
Name string // registry name (e.g. "vertex"); defaults to "vertex"
CredentialsJSON string // inline service account JSON (typically from DB or env)
CredentialsFile string // path to service account JSON file. OPERATOR-ONLY — never expose via admin UI
// or DB settings without path allow-list validation: this path is read directly from disk,
// which would let remote admins exfiltrate arbitrary readable files via crafted settings.
ProjectID string // required — GCP project ID (6-30 chars, lowercase letters/digits/hyphens, must start with a letter)
Region string // required — GCP region (e.g. "us-central1", "asia-southeast1")
DefaultModel string // e.g. "google/gemini-2.0-flash-001"; defaults to VertexDefaultModel
APIBaseOverride string // optional — explicit base URL; defaults to computed from project+region
}
// GCP region format: lowercase, hyphen-separated alphanum segments. e.g. "us-central1", "asia-southeast1", "global".
var vertexRegionRe = regexp.MustCompile(`^[a-z]+(-[a-z0-9]+)*$`)
// GCP project ID format per https://cloud.google.com/resource-manager/docs/creating-managing-projects:
// 6-30 chars, lowercase letters/digits/hyphens, must start with a letter.
var vertexProjectIDRe = regexp.MustCompile(`^[a-z][a-z0-9-]{4,28}[a-z0-9]$`)
// validateVertexProjectID rejects project IDs that don't match GCP's documented shape.
// Defense-in-depth: values come from admin-authenticated input (config, env, or Settings JSONB)
// and are interpolated into the endpoint URL — a malformed value could escape the intended host.
func validateVertexProjectID(id string) error {
if !vertexProjectIDRe.MatchString(id) {
return fmt.Errorf("vertex: invalid project_id %q (expected 6-30 lowercase letters/digits/hyphens starting with a letter)", id)
}
return nil
}
// validateVertexRegion rejects region strings that don't match GCP's documented shape.
func validateVertexRegion(region string) error {
if !vertexRegionRe.MatchString(region) {
return fmt.Errorf("vertex: invalid region %q (expected lowercase hyphen-separated alphanum, e.g. us-central1)", region)
}
return nil
}
// validateVertexAPIBaseOverride sanity-checks an explicit API base URL when provided.
// Belt-and-suspenders defense: `validateProviderURL` in internal/http runs at CRUD time,
// but a DB row inserted via migration or direct SQL can bypass that path.
// We require https + a Google-looking Vertex hostname to prevent data exfiltration
// (messages going to an attacker-controlled server while auth goes to Google).
func validateVertexAPIBaseOverride(base string) error {
u, err := url.Parse(base)
if err != nil {
return fmt.Errorf("vertex: invalid api_base_override %q: %w", base, err)
}
if u.Scheme != "https" {
return fmt.Errorf("vertex: api_base_override must use https scheme, got %q", u.Scheme)
}
host := strings.ToLower(u.Hostname())
if !strings.HasSuffix(host, "aiplatform.googleapis.com") && !strings.HasSuffix(host, ".googleapis.com") {
return fmt.Errorf("vertex: api_base_override host %q is not a googleapis.com endpoint", host)
}
return nil
}
// NewVertexProvider constructs an OpenAIProvider pre-configured for Google Cloud Vertex AI.
// Uses oauth2.Transport for automatic token refresh (1-hour access tokens) — no manual refresh needed.
// The returned provider speaks OpenAI ChatCompletions format against Vertex's OpenAI-compatible endpoint.
func NewVertexProvider(ctx context.Context, cfg VertexConfig) (*OpenAIProvider, error) {
if cfg.ProjectID == "" {
return nil, fmt.Errorf("vertex: project_id is required")
}
if cfg.Region == "" {
return nil, fmt.Errorf("vertex: region is required")
}
if err := validateVertexProjectID(cfg.ProjectID); err != nil {
return nil, err
}
if err := validateVertexRegion(cfg.Region); err != nil {
return nil, err
}
if override := strings.TrimSpace(cfg.APIBaseOverride); override != "" {
if err := validateVertexAPIBaseOverride(override); err != nil {
return nil, err
}
}
tokenSource, err := resolveVertexTokenSource(ctx, cfg)
if err != nil {
return nil, err
}
// ReuseTokenSource caches the current token in-memory until expiry (~1 hour),
// then transparently fetches a fresh one. No extra work for callers.
cached := oauth2.ReuseTokenSource(nil, tokenSource)
client := &http.Client{
Timeout: DefaultHTTPTimeout,
Transport: &oauth2.Transport{
Source: cached,
Base: http.DefaultTransport,
},
}
apiBase := strings.TrimSpace(cfg.APIBaseOverride)
if apiBase == "" {
apiBase = VertexDefaultAPIBase(cfg.ProjectID, cfg.Region)
}
defaultModel := cfg.DefaultModel
if defaultModel == "" {
defaultModel = VertexDefaultModel
}
name := cfg.Name
if name == "" {
name = "vertex"
}
// apiKey is intentionally empty — oauth2.Transport injects Authorization from the TokenSource.
// WithoutAuthHeader ensures doRequest() doesn't overwrite that with a "Bearer " header.
prov := NewOpenAIProvider(name, "", apiBase, defaultModel).
WithProviderType(ProviderTypeVertex).
WithHTTPClient(client).
WithoutAuthHeader()
return prov, nil
}
// resolveVertexTokenSource returns a GCP TokenSource using the first available credential source:
// inline JSON → file path → Application Default Credentials.
func resolveVertexTokenSource(ctx context.Context, cfg VertexConfig) (oauth2.TokenSource, error) {
scope := VertexDefaultScope
if data := strings.TrimSpace(cfg.CredentialsJSON); data != "" {
creds, err := google.CredentialsFromJSON(ctx, []byte(data), scope)
if err != nil {
return nil, fmt.Errorf("vertex: parse inline credentials: %w", err)
}
return creds.TokenSource, nil
}
if path := strings.TrimSpace(cfg.CredentialsFile); path != "" {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("vertex: read credentials file: %w", err)
}
creds, err := google.CredentialsFromJSON(ctx, data, scope)
if err != nil {
return nil, fmt.Errorf("vertex: parse credentials file %q: %w", path, err)
}
return creds.TokenSource, nil
}
// ADC: GOOGLE_APPLICATION_CREDENTIALS env, ~/.config/gcloud/..., or GCE metadata server.
creds, err := google.FindDefaultCredentials(ctx, scope)
if err != nil {
return nil, fmt.Errorf("vertex: application default credentials not found (set GOOGLE_APPLICATION_CREDENTIALS, provide credentials_file, or run on GCP): %w", err)
}
return creds.TokenSource, nil
}
// vertexInitTimeout caps credential discovery time so ADC on non-GCP machines
// doesn't stall gateway startup waiting for the metadata server.
const vertexInitTimeout = 10 * time.Second
// NewVertexProviderWithTimeout wraps NewVertexProvider with a bounded context.
// Recommended for startup-time registration where slow metadata lookups must not block boot.
func NewVertexProviderWithTimeout(cfg VertexConfig) (*OpenAIProvider, error) {
ctx, cancel := context.WithTimeout(context.Background(), vertexInitTimeout)
defer cancel()
return NewVertexProvider(ctx, cfg)
}
+318
View File
@@ -0,0 +1,318 @@
package providers
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestVertexDefaultAPIBase(t *testing.T) {
cases := []struct {
name, project, region, want string
}{
{"basic", "my-proj", "us-central1", "https://us-central1-aiplatform.googleapis.com/v1/projects/my-proj/locations/us-central1/endpoints/openapi"},
{"asia", "acme", "asia-southeast1", "https://asia-southeast1-aiplatform.googleapis.com/v1/projects/acme/locations/asia-southeast1/endpoints/openapi"},
{"empty_project", "", "us-central1", ""},
{"empty_region", "my-proj", "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := VertexDefaultAPIBase(tc.project, tc.region); got != tc.want {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}
func TestNewVertexProviderMissingFields(t *testing.T) {
cases := []struct {
name string
cfg VertexConfig
wantSub string
}{
{"no_project", VertexConfig{Region: "us-central1"}, "project_id"},
{"no_region", VertexConfig{ProjectID: "x"}, "region"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := NewVertexProvider(context.Background(), tc.cfg)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), tc.wantSub) {
t.Errorf("error %q missing %q", err, tc.wantSub)
}
})
}
}
func TestNewVertexProviderInvalidInlineJSON(t *testing.T) {
_, err := NewVertexProvider(context.Background(), VertexConfig{
CredentialsJSON: "not json",
ProjectID: "my-proj",
Region: "us-central1",
})
if err == nil {
t.Fatal("expected error parsing bad JSON")
}
if !strings.Contains(err.Error(), "credentials") {
t.Errorf("error %q does not mention credentials", err)
}
}
func TestNewVertexProviderCredentialsFileMissing(t *testing.T) {
_, err := NewVertexProvider(context.Background(), VertexConfig{
CredentialsFile: filepath.Join(t.TempDir(), "does-not-exist.json"),
ProjectID: "my-proj",
Region: "us-central1",
})
if err == nil {
t.Fatal("expected error for missing file")
}
if !strings.Contains(err.Error(), "read credentials file") {
t.Errorf("error %q missing expected prefix", err)
}
}
func TestNewVertexProviderCredentialsFileInvalid(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bad.json")
if err := os.WriteFile(path, []byte("{invalid"), 0o600); err != nil {
t.Fatal(err)
}
_, err := NewVertexProvider(context.Background(), VertexConfig{
CredentialsFile: path,
ProjectID: "my-proj",
Region: "us-central1",
})
if err == nil {
t.Fatal("expected parse error")
}
if !strings.Contains(err.Error(), "credentials file") {
t.Errorf("error %q missing expected phrase", err)
}
}
// TestOpenAIProviderWithoutAuthHeaderSkipsAuthorization verifies the skip-auth path
// added for Vertex — doRequest() must NOT set an Authorization header when skipAuthHeader is true.
// This is the sole non-trivial code change in openai.go needed for Vertex to work.
func TestOpenAIProviderWithoutAuthHeaderSkipsAuthorization(t *testing.T) {
var gotAuth string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
// Minimal successful openai response
_, _ = io.WriteString(w, `{"id":"1","choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`)
}))
defer server.Close()
prov := NewOpenAIProvider("test", "sk-should-not-appear", server.URL, "x").
WithoutAuthHeader()
resp, err := prov.Chat(context.Background(), ChatRequest{
Messages: []Message{{Role: "user", Content: "hi"}},
})
if err != nil {
t.Fatalf("chat: %v", err)
}
if resp.Content != "ok" {
t.Errorf("content=%q, want %q", resp.Content, "ok")
}
if gotAuth != "" {
t.Errorf("unexpected Authorization header %q — WithoutAuthHeader() should skip it", gotAuth)
}
}
// TestOpenAIProviderWithHTTPClientUsesCustomClient verifies WithHTTPClient() replaces the default.
// A transport that tags outgoing requests with a sentinel header lets us confirm the custom client
// is the one used for Vertex AI (so oauth2.Transport actually runs).
func TestOpenAIProviderWithHTTPClientUsesCustomClient(t *testing.T) {
var sawSentinel bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sawSentinel = r.Header.Get("X-Test-Transport") == "custom"
_, _ = io.WriteString(w, `{"id":"1","choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`)
}))
defer server.Close()
customClient := &http.Client{Transport: &taggingTransport{Base: http.DefaultTransport, Header: "X-Test-Transport", Value: "custom"}}
prov := NewOpenAIProvider("test", "ignored", server.URL, "x").
WithHTTPClient(customClient).
WithoutAuthHeader()
if _, err := prov.Chat(context.Background(), ChatRequest{Messages: []Message{{Role: "user", Content: "hi"}}}); err != nil {
t.Fatalf("chat: %v", err)
}
if !sawSentinel {
t.Error("custom transport did not run — WithHTTPClient() may not have replaced the client")
}
}
// taggingTransport is a test-only RoundTripper that sets a fixed header on every outbound request.
type taggingTransport struct {
Base http.RoundTripper
Header string
Value string
}
func (t *taggingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Set(t.Header, t.Value)
return t.Base.RoundTrip(req)
}
// Sanity check: ensure Vertex provider wires default model and endpoint correctly.
// We cannot exercise real token refresh without a real SA — skipAuthHeader + endpoint
// assertions cover the provider-specific wiring.
func TestNewVertexProviderWiresEndpointAndModel(t *testing.T) {
// Valid (but fake) SA JSON — CredentialsFromJSON parses structure without fetching tokens.
fakeSA := map[string]any{
"type": "service_account",
"project_id": "my-proj",
"private_key": fakePEM,
"client_email": "test@my-proj.iam.gserviceaccount.com",
"token_uri": "https://oauth2.googleapis.com/token",
}
data, _ := json.Marshal(fakeSA)
prov, err := NewVertexProvider(context.Background(), VertexConfig{
CredentialsJSON: string(data),
ProjectID: "my-proj",
Region: "us-central1",
})
if err != nil {
t.Fatalf("NewVertexProvider: %v", err)
}
wantBase := "https://us-central1-aiplatform.googleapis.com/v1/projects/my-proj/locations/us-central1/endpoints/openapi"
if prov.APIBase() != wantBase {
t.Errorf("APIBase=%q, want %q", prov.APIBase(), wantBase)
}
if prov.DefaultModel() != VertexDefaultModel {
t.Errorf("DefaultModel=%q, want %q", prov.DefaultModel(), VertexDefaultModel)
}
if prov.Name() != "vertex" {
t.Errorf("Name=%q, want %q", prov.Name(), "vertex")
}
if prov.ProviderType() != ProviderTypeVertex {
t.Errorf("ProviderType=%q, want %q", prov.ProviderType(), ProviderTypeVertex)
}
}
// Minimal valid-looking PKCS#8 PEM body — google.CredentialsFromJSON parses lazily
// so it does NOT attempt real key validation; test just needs structurally-valid JSON.
// The private_key field can be any non-empty string.
const fakePEM = "-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----\n"
// Regression test for H1 from code review: thought_signature detection must recognize
// providers whose providerType is "vertex" (or apiBase contains "aiplatform"),
// even when the model string does NOT contain "gemini". Without this fix, tool-call
// rounds against a fine-tuned Vertex endpoint ID would drop the signature on passback
// and trigger HTTP 400 from the Vertex API.
func TestVertexProviderForwardsThoughtSignatureOnToolCalls(t *testing.T) {
var bodies []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
bodies = append(bodies, string(b))
// Return a tool call with a thought_signature so the next round would echo it.
_, _ = io.WriteString(w, `{"id":"1","choices":[{"message":{"role":"assistant","tool_calls":[{"id":"t1","type":"function","function":{"name":"noop","arguments":"{}","thought_signature":"sig-xyz"}}]},"finish_reason":"tool_calls"}]}`)
}))
defer server.Close()
// Build a Vertex-style OpenAIProvider manually (avoids oauth2 in tests).
prov := NewOpenAIProvider("vertex", "", server.URL, "some-tuned-endpoint-id").
WithProviderType(ProviderTypeVertex).
WithoutAuthHeader()
// Round 1: assistant responds with tool_calls carrying thought_signature.
r1, err := prov.Chat(context.Background(), ChatRequest{
Messages: []Message{{Role: "user", Content: "go"}},
Tools: []ToolDefinition{{Type: "function", Function: &ToolFunctionSchema{Name: "noop", Parameters: map[string]any{"type": "object"}}}},
})
if err != nil {
t.Fatalf("round 1: %v", err)
}
if len(r1.ToolCalls) != 1 {
t.Fatalf("round 1 tool_calls = %d, want 1", len(r1.ToolCalls))
}
if r1.ToolCalls[0].Metadata["thought_signature"] != "sig-xyz" {
t.Fatalf("thought_signature metadata missing on round 1 tool call")
}
// Round 2: pass the assistant's tool call + a tool-result message. Expect the
// outbound request to INCLUDE thought_signature on the tool_calls entry.
toolCall := r1.ToolCalls[0]
toolCall.Arguments = map[string]any{}
_, err = prov.Chat(context.Background(), ChatRequest{
Messages: []Message{
{Role: "user", Content: "go"},
{Role: "assistant", Content: "", ToolCalls: []ToolCall{toolCall}},
{Role: "tool", Content: "ok", ToolCallID: "t1"},
{Role: "user", Content: "next"},
},
Tools: []ToolDefinition{{Type: "function", Function: &ToolFunctionSchema{Name: "noop", Parameters: map[string]any{"type": "object"}}}},
})
if err != nil {
t.Fatalf("round 2: %v", err)
}
if len(bodies) < 2 {
t.Fatalf("expected 2 round-trips, got %d", len(bodies))
}
if !strings.Contains(bodies[1], `"thought_signature":"sig-xyz"`) {
t.Errorf("round 2 body missing thought_signature (H1 regression): %s", bodies[1])
}
}
// Sanity check the validation helpers surface clear errors on bad input (M1 / M2).
func TestVertexValidationRejectsMalformedInput(t *testing.T) {
cases := []struct {
name, project, region, apiBase, wantSub string
}{
{"region_host_escape", "my-proj", "evil.com/a?", "", "invalid region"},
{"region_with_slash", "my-proj", "us/central1", "", "invalid region"},
{"project_uppercase", "MY-PROJ", "us-central1", "", "invalid project_id"},
{"project_starts_with_digit", "1badproj", "us-central1", "", "invalid project_id"},
{"project_too_short", "abc", "us-central1", "", "invalid project_id"},
{"override_http", "my-proj", "us-central1", "http://evil.com", "https scheme"},
{"override_non_google", "my-proj", "us-central1", "https://evil.com/vertex", "googleapis.com"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := NewVertexProvider(context.Background(), VertexConfig{
ProjectID: tc.project,
Region: tc.region,
APIBaseOverride: tc.apiBase,
})
if err == nil {
t.Fatalf("expected error, got nil")
}
if !strings.Contains(err.Error(), tc.wantSub) {
t.Errorf("error %q missing %q", err.Error(), tc.wantSub)
}
})
}
}
// Confirm well-formed projects+regions plus a valid override URL still work.
func TestVertexValidationAcceptsWellFormedInput(t *testing.T) {
fakeSA := map[string]any{
"type": "service_account",
"project_id": "my-proj",
"private_key": fakePEM,
"client_email": "test@my-proj.iam.gserviceaccount.com",
"token_uri": "https://oauth2.googleapis.com/token",
}
data, _ := json.Marshal(fakeSA)
_, err := NewVertexProvider(context.Background(), VertexConfig{
CredentialsJSON: string(data),
ProjectID: "my-proj",
Region: "asia-southeast1",
APIBaseOverride: "https://asia-southeast1-aiplatform.googleapis.com/v1/projects/my-proj/locations/asia-southeast1/endpoints/openapi",
})
if err != nil {
t.Fatalf("well-formed input rejected: %v", err)
}
}
+31
View File
@@ -33,6 +33,7 @@ const (
ProviderNovita = "novita" // Novita AI (OpenAI-compatible endpoint)
ProviderBytePlus = "byteplus" // BytePlus ModelArk (Seed 2.0 models)
ProviderBytePlusCoding = "byteplus_coding" // BytePlus ModelArk Coding Plan
ProviderVertex = "vertex" // Google Cloud Vertex AI (OAuth2 service account + ADC)
// Novita AI defaults.
NovitaDefaultAPIBase = "https://api.novita.ai/openai"
@@ -42,8 +43,13 @@ const (
BytePlusDefaultAPIBase = "https://ark.ap-southeast.bytepluses.com/api/v3"
BytePlusCodingDefaultAPIBase = "https://ark.ap-southeast.bytepluses.com/api/coding/v3"
BytePlusDefaultModel = "seed-2-0-lite-260228"
)
// Vertex AI constants live in internal/providers/vertex.go to avoid a store→providers import cycle
// (store is imported by providers). DB-layer concerns (ProviderVertex type + settings parsing)
// remain in this package.
// ValidProviderTypes lists all accepted provider_type values.
var ValidProviderTypes = map[string]bool{
ProviderAnthropicNative: true,
@@ -70,6 +76,30 @@ var ValidProviderTypes = map[string]bool{
ProviderNovita: true,
ProviderBytePlus: true,
ProviderBytePlusCoding: true,
ProviderVertex: true,
}
// VertexProviderSettings holds Vertex-specific config stored in llm_providers.settings JSONB.
type VertexProviderSettings struct {
ProjectID string `json:"project_id"`
Region string `json:"region"`
Model string `json:"model,omitempty"` // optional default model override (e.g. "google/gemini-2.5-pro-001")
}
// ParseVertexProviderSettings extracts Vertex config from settings JSONB.
// Returns nil if project_id or region is missing (both required).
func ParseVertexProviderSettings(settings json.RawMessage) *VertexProviderSettings {
if len(settings) == 0 {
return nil
}
var s VertexProviderSettings
if json.Unmarshal(settings, &s) != nil {
return nil
}
if s.ProjectID == "" || s.Region == "" {
return nil
}
return &s
}
// LLMProviderData represents an LLM provider configuration.
@@ -179,6 +209,7 @@ var NoEmbeddingTypes = map[string]bool{
ProviderACP: true,
ProviderClaudeCLI: true,
ProviderChatGPTOAuth: true,
ProviderVertex: true, // Vertex embeddings live on a different native endpoint, not on /endpoints/openapi
}
// ProviderStore manages LLM providers.
@@ -9,6 +9,7 @@ export const PROVIDER_TYPES: ProviderTypeInfo[] = [
{ value: 'anthropic_native', label: 'Anthropic (Native)', apiBase: '', needsKey: true },
{ value: 'openai_compat', label: 'OpenAI Compatible', apiBase: '', needsKey: true },
{ value: 'gemini_native', label: 'Google Gemini', apiBase: 'https://generativelanguage.googleapis.com/v1beta/openai', needsKey: true },
{ value: 'vertex', label: 'Google Vertex AI', apiBase: '', needsKey: false },
{ value: 'openrouter', label: 'OpenRouter', apiBase: 'https://openrouter.ai/api/v1', needsKey: true },
{ value: 'groq', label: 'Groq', apiBase: 'https://api.groq.com/openai/v1', needsKey: true },
{ value: 'deepseek', label: 'DeepSeek', apiBase: 'https://api.deepseek.com/v1', needsKey: true },
+1
View File
@@ -16,6 +16,7 @@ export const PROVIDER_TYPES: ProviderTypeInfo[] = [
{ value: "anthropic_native", label: "Anthropic (Native)", apiBase: "", placeholder: "https://api.anthropic.com" },
{ value: "openai_compat", label: "OpenAI Compatible", apiBase: "", placeholder: "https://api.openai.com/v1" },
{ value: "gemini_native", label: "Google Gemini", apiBase: "https://generativelanguage.googleapis.com/v1beta/openai", placeholder: "" },
{ value: "vertex", label: "Google Vertex AI", apiBase: "", placeholder: "Auto-computed from project_id + region (settings)" },
{ value: "openrouter", label: "OpenRouter", apiBase: "https://openrouter.ai/api/v1", placeholder: "" },
{ value: "groq", label: "Groq", apiBase: "https://api.groq.com/openai/v1", placeholder: "" },
{ value: "deepseek", label: "DeepSeek", apiBase: "https://api.deepseek.com/v1", placeholder: "" },