diff --git a/CHANGELOG.md b/CHANGELOG.md index 7065f20d..dd70f391 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index f6306fb2..cb17ab16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/cmd/gateway_providers.go b/cmd/gateway_providers.go index b174ef44..ba19ff98 100644 --- a/cmd/gateway_providers.go +++ b/cmd/gateway_providers.go @@ -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 diff --git a/go.mod b/go.mod index 254e0415..5de91629 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index faea3b88..d9e0a6ed 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index a93fcdd2..9d93564e 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -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. diff --git a/internal/config/config_load.go b/internal/config/config_load.go index a844e1ae..d12fdece 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -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) diff --git a/internal/config/config_secrets.go b/internal/config/config_secrets.go index 0add593b..99e6a8a0 100644 --- a/internal/config/config_secrets.go +++ b/internal/config/config_secrets.go @@ -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) diff --git a/internal/http/providers.go b/internal/http/providers.go index ff8fa177..5eb07696 100644 --- a/internal/http/providers.go +++ b/internal/http/providers.go @@ -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 } diff --git a/internal/providers/openai_config.go b/internal/providers/openai_config.go index 36849661..c8e10c58 100644 --- a/internal/providers/openai_config.go +++ b/internal/providers/openai_config.go @@ -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 } diff --git a/internal/providers/openai_http.go b/internal/providers/openai_http.go index 80a069a9..896021e0 100644 --- a/internal/providers/openai_http.go +++ b/internal/providers/openai_http.go @@ -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 " diff --git a/internal/providers/openai_request.go b/internal/providers/openai_request.go index 84cc264a..24e7f2c8 100644 --- a/internal/providers/openai_request.go +++ b/internal/providers/openai_request.go @@ -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) diff --git a/internal/providers/vertex.go b/internal/providers/vertex.go new file mode 100644 index 00000000..f39f9b21 --- /dev/null +++ b/internal/providers/vertex.go @@ -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) +} diff --git a/internal/providers/vertex_test.go b/internal/providers/vertex_test.go new file mode 100644 index 00000000..0004e8b5 --- /dev/null +++ b/internal/providers/vertex_test.go @@ -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) + } +} diff --git a/internal/store/provider_store.go b/internal/store/provider_store.go index c61da5dc..8b81d563 100644 --- a/internal/store/provider_store.go +++ b/internal/store/provider_store.go @@ -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. diff --git a/ui/desktop/frontend/src/constants/providers.ts b/ui/desktop/frontend/src/constants/providers.ts index e74cb04c..8cc17084 100644 --- a/ui/desktop/frontend/src/constants/providers.ts +++ b/ui/desktop/frontend/src/constants/providers.ts @@ -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 }, diff --git a/ui/web/src/constants/providers.ts b/ui/web/src/constants/providers.ts index 34be5309..637e19f1 100644 --- a/ui/web/src/constants/providers.ts +++ b/ui/web/src/constants/providers.ts @@ -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: "" },