mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-09-02 16:20:06 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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=
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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: "" },
|
||||
|
||||
Reference in New Issue
Block a user