mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 12:14:40 +00:00
* 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
319 lines
12 KiB
Go
319 lines
12 KiB
Go
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)
|
|
}
|
|
}
|