Merge pull request #5 from TanLuong/feat/vertex-custom-endpoints-16972728568296868668

feat: support custom proxy endpoints and chat stream for Vertex/Gemini
This commit is contained in:
Nhat Tan 2026-03-25 15:25:36 +07:00 committed by GitHub
commit 53706b2fb4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 332 additions and 17 deletions

View file

@ -383,6 +383,7 @@ PicoClaw supports 30+ LLM providers through the `model_list` configuration. Use
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Required | Enterprise Azure deployment | | [Azure OpenAI](https://portal.azure.com/) | `azure/` | Required | Enterprise Azure deployment |
| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Device code login | | [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Device code login |
| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI | | [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
| [Google Vertex AI](https://cloud.google.com/vertex-ai) | `vertex/` | Required / OAuth | GCP Vertex AI |
| [AWS Bedrock](https://console.aws.amazon.com/bedrock)* | `bedrock/` | AWS credentials | Claude, Llama, Mistral on AWS | | [AWS Bedrock](https://console.aws.amazon.com/bedrock)* | `bedrock/` | AWS credentials | Claude, Llama, Mistral on AWS |
> \* AWS Bedrock requires build tag: `go build -tags bedrock`. Set `api_base` to a region name (e.g., `us-east-1`) for automatic endpoint resolution across all AWS partitions (aws, aws-cn, aws-us-gov). When using a full endpoint URL instead, you must also configure `AWS_REGION` via environment variable or AWS config/profile. > \* AWS Bedrock requires build tag: `go build -tags bedrock`. Set `api_base` to a region name (e.g., `us-east-1`) for automatic endpoint resolution across all AWS partitions (aws, aws-cn, aws-us-gov). When using a full endpoint URL instead, you must also configure `AWS_REGION` via environment variable or AWS config/profile.
@ -453,6 +454,27 @@ Add the `extra_headers` field to your model configuration:
} }
``` ```
**Google AI Studio / Vertex AI (Custom Endpoints):**
You can use the `vertex` provider to query Google AI Studio endpoints or custom Vertex AI URLs. This supports using an `api_key` as a query parameter (`?key=...`).
```json
{
"model_list": [
{
"model_name": "gemini-pro-studio",
"model": "vertex/gemini-1.5-pro",
"api_base": "https://generativelanguage.googleapis.com/v1beta/models",
"api_key": "YOUR_GEMINI_API_KEY"
},
{
"model_name": "gemini-pro-vertex",
"model": "vertex/gemini-3-pro-preview",
"api_base": "https://us-central1-aiplatform.googleapis.com/v1/publishers/google/models",
"api_key": "YOUR_VERTEX_API_KEY"
}
]
}
```
For full provider configuration details, see [Providers & Models](docs/providers.md). For full provider configuration details, see [Providers & Models](docs/providers.md).
</details> </details>

View file

@ -6,6 +6,7 @@
package vertex package vertex
import ( import (
"bufio"
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
@ -73,19 +74,44 @@ func NewProvider(apiKey, apiBase, proxy, projectID, region string, opts ...Optio
} }
// buildURL constructs the Vertex AI REST endpoint URL. // buildURL constructs the Vertex AI REST endpoint URL.
func (p *Provider) buildURL(model string) string { func (p *Provider) buildURL(model string, action string) string {
if p.apiBase != "" { if action == "" {
if strings.Contains(p.apiBase, "generateContent") { action = "generateContent"
return p.apiBase
}
return fmt.Sprintf("%s/models/%s:generateContent", p.apiBase, model)
} }
var baseURL string
if p.apiBase != "" {
if strings.Contains(p.apiBase, "generateContent") {
baseURL = p.apiBase
} else {
baseURL = fmt.Sprintf("%s/%s:%s", p.apiBase, model, action)
}
} else {
region := p.region region := p.region
if region == "" { if region == "" {
region = "us-central1" region = "us-central1"
} }
return fmt.Sprintf("https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:generateContent", region, p.projectID, region, model) baseURL = fmt.Sprintf("https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s", region, p.projectID, region, model, action)
}
// Only append ?key= for custom apiBase endpoints
if p.apiBase != "" && p.apiKey != "" && !strings.Contains(baseURL, "key=") {
if strings.Contains(baseURL, "?") {
baseURL = fmt.Sprintf("%s&key=%s", baseURL, p.apiKey)
} else {
baseURL = fmt.Sprintf("%s?key=%s", baseURL, p.apiKey)
}
}
if action == "streamGenerateContent" && !strings.Contains(baseURL, "alt=sse") {
if strings.Contains(baseURL, "?") {
baseURL = fmt.Sprintf("%s&alt=sse", baseURL)
} else {
baseURL = fmt.Sprintf("%s?alt=sse", baseURL)
}
}
return baseURL
} }
@ -287,7 +313,7 @@ func (p *Provider) Chat(
return nil, fmt.Errorf("failed to marshal request: %w", err) return nil, fmt.Errorf("failed to marshal request: %w", err)
} }
requestURL := p.buildURL(model) requestURL := p.buildURL(model, "generateContent")
req, err := http.NewRequestWithContext(ctx, "POST", requestURL, bytes.NewReader(jsonData)) req, err := http.NewRequestWithContext(ctx, "POST", requestURL, bytes.NewReader(jsonData))
if err != nil { if err != nil {
@ -295,7 +321,7 @@ func (p *Provider) Chat(
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
if p.apiKey != "" { if p.apiKey != "" && !strings.Contains(requestURL, "key=") {
req.Header.Set("Authorization", "Bearer "+p.apiKey) req.Header.Set("Authorization", "Bearer "+p.apiKey)
} }
@ -317,6 +343,161 @@ func (p *Provider) Chat(
return p.parseResponse(bodyBytes) return p.parseResponse(bodyBytes)
} }
func (p *Provider) ChatStream(
ctx context.Context,
messages []Message,
tools []ToolDefinition,
model string,
options map[string]any,
onChunk func(accumulated string),
) (*LLMResponse, error) {
if p.apiBase == "" && p.projectID == "" {
return nil, fmt.Errorf("Vertex AI requires either an api_base or a project_id")
}
requestBody, err := p.buildRequestBody(messages, tools, options)
if err != nil {
return nil, fmt.Errorf("failed to build request body: %w", err)
}
jsonData, err := json.Marshal(requestBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
requestURL := p.buildURL(model, "streamGenerateContent")
req, err := http.NewRequestWithContext(ctx, "POST", requestURL, bytes.NewReader(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if p.apiKey != "" && !strings.Contains(requestURL, "key=") {
req.Header.Set("Authorization", "Bearer "+p.apiKey)
}
resp, err := p.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, common.HandleErrorResponse(resp, "vertex")
}
var accumulatedText string
var allToolCalls []ToolCall
var finalResponse *LLMResponse
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
if strings.HasPrefix(line, "data: ") {
line = strings.TrimPrefix(line, "data: ")
} else if line == "[" || line == "]" || line == "," {
continue
}
var chunk struct {
Candidates []struct {
Content struct {
Parts []struct {
Text string `json:"text"`
FunctionCall *struct {
Name string `json:"name"`
Args map[string]any `json:"args"`
} `json:"functionCall,omitempty"`
} `json:"parts"`
} `json:"content"`
FinishReason string `json:"finishReason"`
} `json:"candidates"`
UsageMetadata *struct {
PromptTokenCount int `json:"promptTokenCount"`
CandidatesTokenCount int `json:"candidatesTokenCount"`
TotalTokenCount int `json:"totalTokenCount"`
} `json:"usageMetadata,omitempty"`
}
if err := json.Unmarshal([]byte(line), &chunk); err != nil {
continue
}
if len(chunk.Candidates) > 0 {
candidate := chunk.Candidates[0]
for _, part := range candidate.Content.Parts {
if part.Text != "" {
accumulatedText += part.Text
if onChunk != nil {
onChunk(accumulatedText)
}
}
if part.FunctionCall != nil {
argsJSON, _ := json.Marshal(part.FunctionCall.Args)
toolCall := ToolCall{
ID: fmt.Sprintf("call_%s_%d", part.FunctionCall.Name, time.Now().UnixNano()),
Name: part.FunctionCall.Name,
Arguments: part.FunctionCall.Args,
Function: &FunctionCall{
Name: part.FunctionCall.Name,
Arguments: string(argsJSON),
},
}
allToolCalls = append(allToolCalls, toolCall)
}
}
if candidate.FinishReason != "" && finalResponse == nil {
finishReason := candidate.FinishReason
if finishReason == "STOP" {
finishReason = "stop"
} else if len(allToolCalls) > 0 {
finishReason = "tool_calls"
}
finalResponse = &LLMResponse{
Content: accumulatedText,
ToolCalls: allToolCalls,
FinishReason: finishReason,
}
}
}
if chunk.UsageMetadata != nil {
if finalResponse == nil {
finalResponse = &LLMResponse{
Content: accumulatedText,
ToolCalls: allToolCalls,
}
}
finalResponse.Usage = &protocoltypes.UsageInfo{
PromptTokens: chunk.UsageMetadata.PromptTokenCount,
CompletionTokens: chunk.UsageMetadata.CandidatesTokenCount,
TotalTokens: chunk.UsageMetadata.TotalTokenCount,
}
}
}
if finalResponse == nil {
finishReason := "stop"
if len(allToolCalls) > 0 {
finishReason = "tool_calls"
}
finalResponse = &LLMResponse{
Content: accumulatedText,
ToolCalls: allToolCalls,
FinishReason: finishReason,
}
}
return finalResponse, nil
}
func (p *Provider) parseResponse(body []byte) (*LLMResponse, error) { func (p *Provider) parseResponse(body []byte) (*LLMResponse, error) {
var vResp struct { var vResp struct {
Candidates []struct { Candidates []struct {

View file

@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"testing" "testing"
@ -37,22 +38,22 @@ func TestProvider_buildURL(t *testing.T) {
}, },
{ {
name: "Override with base URL without method", name: "Override with base URL without method",
apiBase: "http://localhost:8080/v1", apiBase: "http://localhost:8080/v1/models",
model: "gemini-1.0-pro", model: "gemini-1.0-pro",
expected: "http://localhost:8080/v1/models/gemini-1.0-pro:generateContent", expected: "http://localhost:8080/v1/models/gemini-1.0-pro:generateContent?key=key",
}, },
{ {
name: "Override with full endpoint URL", name: "Override with full endpoint URL",
apiBase: "https://my-custom-proxy.com/my-endpoint:generateContent", apiBase: "https://my-custom-proxy.com/my-endpoint:generateContent",
model: "gemini-1.5-pro", model: "gemini-1.5-pro",
expected: "https://my-custom-proxy.com/my-endpoint:generateContent", expected: "https://my-custom-proxy.com/my-endpoint:generateContent?key=key",
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
p := NewProvider("key", tt.apiBase, "", tt.projectID, tt.region) p := NewProvider("key", tt.apiBase, "", tt.projectID, tt.region)
actual := p.buildURL(tt.model) actual := p.buildURL(tt.model, "generateContent")
assert.Equal(t, tt.expected, actual) assert.Equal(t, tt.expected, actual)
}) })
} }
@ -132,7 +133,9 @@ func TestProvider_Chat(t *testing.T) {
// Create a mock server // Create a mock server
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method) assert.Equal(t, "POST", r.Method)
assert.Equal(t, "Bearer test-key", r.Header.Get("Authorization")) // Because we're using the query 'key=test-key' we no longer have Bearer authentication
// assert.Equal(t, "Bearer test-key", r.Header.Get("Authorization"))
assert.Contains(t, r.URL.String(), "key=test-key")
var reqBody map[string]any var reqBody map[string]any
err := json.NewDecoder(r.Body).Decode(&reqBody) err := json.NewDecoder(r.Body).Decode(&reqBody)
@ -176,3 +179,112 @@ func TestProvider_Chat(t *testing.T) {
assert.Equal(t, 5, resp.Usage.CompletionTokens) assert.Equal(t, 5, resp.Usage.CompletionTokens)
assert.Equal(t, 15, resp.Usage.TotalTokens) assert.Equal(t, 15, resp.Usage.TotalTokens)
} }
func TestProvider_ChatStream(t *testing.T) {
// Create a mock server
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Contains(t, r.URL.String(), "key=test-key")
assert.Contains(t, r.URL.String(), "alt=sse")
var reqBody map[string]any
err := json.NewDecoder(r.Body).Decode(&reqBody)
require.NoError(t, err)
w.Header().Set("Content-Type", "text/event-stream")
// Write mock chunks
w.Write([]byte(`data: {"candidates":[{"content":{"parts":[{"text":"Hello"}]}}]}` + "\n\n"))
w.Write([]byte(`data: {"candidates":[{"content":{"parts":[{"text":", world!"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"totalTokenCount":15}}` + "\n\n"))
}))
defer ts.Close()
p := NewProvider("test-key", ts.URL, "", "my-project", "us-central1")
opts := make(map[string]any)
var chunks []string
resp, err := p.ChatStream(
context.Background(),
[]protocoltypes.Message{{Role: "user", Content: "Say hello!"}},
nil,
"gemini-1.5-pro",
opts,
func(accumulated string) {
chunks = append(chunks, accumulated)
},
)
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, "Hello, world!", resp.Content)
assert.Equal(t, "stop", resp.FinishReason)
assert.NotNil(t, resp.Usage)
if resp.Usage != nil {
assert.Equal(t, 10, resp.Usage.PromptTokens)
assert.Equal(t, 5, resp.Usage.CompletionTokens)
assert.Equal(t, 15, resp.Usage.TotalTokens)
}
assert.Equal(t, []string{"Hello", "Hello, world!"}, chunks)
}
func TestProvider_Chat_Standard(t *testing.T) {
// Create a mock server
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
// Standard Vertex without apiBase should use Bearer authentication
assert.Equal(t, "Bearer test-key", r.Header.Get("Authorization"))
var reqBody map[string]any
err := json.NewDecoder(r.Body).Decode(&reqBody)
require.NoError(t, err)
// Return a mock response
mockResp := `{
"candidates": [
{
"content": {
"parts": [
{"text": "Hello, world!"}
]
},
"finishReason": "STOP"
}
]
}`
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(mockResp))
}))
defer ts.Close()
// Use an empty apiBase so it builds standard Vertex URLs
p := NewProvider("test-key", "", "", "my-project", "us-central1")
// Since buildURL will use aiplatform.googleapis.com, we override the httpClient Transport
// to redirect requests to our mock server for this test by swapping the base URL out in a custom RoundTripper.
p.httpClient.Transport = roundTripperFunc(func(req *http.Request) (*http.Response, error) {
req.URL, _ = url.Parse(ts.URL)
return http.DefaultTransport.RoundTrip(req)
})
opts := make(map[string]any)
resp, err := p.Chat(
context.Background(),
[]protocoltypes.Message{{Role: "user", Content: "Say hello!"}},
nil,
"gemini-1.5-pro",
opts,
)
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, "Hello, world!", resp.Content)
assert.Equal(t, "stop", resp.FinishReason)
}
type roundTripperFunc func(*http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}