Merge PR #343: Add Google Antigravity provider and harden tool-call compatibility

This commit is contained in:
yinwm 2026-02-18 00:13:24 +08:00
commit 5d1669ecc4
13 changed files with 2423 additions and 71 deletions

View file

@ -10,6 +10,7 @@ import (
"bufio" "bufio"
"context" "context"
"embed" "embed"
"encoding/json"
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
@ -373,6 +374,7 @@ func migrateHelp() {
func agentCmd() { func agentCmd() {
message := "" message := ""
sessionKey := "cli:default" sessionKey := "cli:default"
modelOverride := ""
args := os.Args[2:] args := os.Args[2:]
for i := 0; i < len(args); i++ { for i := 0; i < len(args); i++ {
@ -390,6 +392,11 @@ func agentCmd() {
sessionKey = args[i+1] sessionKey = args[i+1]
i++ i++
} }
case "--model", "-model":
if i+1 < len(args) {
modelOverride = args[i+1]
i++
}
} }
} }
@ -399,6 +406,10 @@ func agentCmd() {
os.Exit(1) os.Exit(1)
} }
if modelOverride != "" {
cfg.Agents.Defaults.Model = modelOverride
}
provider, err := providers.CreateProvider(cfg) provider, err := providers.CreateProvider(cfg)
if err != nil { if err != nil {
fmt.Printf("Error creating provider: %v\n", err) fmt.Printf("Error creating provider: %v\n", err)
@ -793,6 +804,8 @@ func authCmd() {
authLogoutCmd() authLogoutCmd()
case "status": case "status":
authStatusCmd() authStatusCmd()
case "models":
authModelsCmd()
default: default:
fmt.Printf("Unknown auth command: %s\n", os.Args[2]) fmt.Printf("Unknown auth command: %s\n", os.Args[2])
authHelp() authHelp()
@ -804,15 +817,18 @@ func authHelp() {
fmt.Println(" login Login via OAuth or paste token") fmt.Println(" login Login via OAuth or paste token")
fmt.Println(" logout Remove stored credentials") fmt.Println(" logout Remove stored credentials")
fmt.Println(" status Show current auth status") fmt.Println(" status Show current auth status")
fmt.Println(" models List available Antigravity models")
fmt.Println() fmt.Println()
fmt.Println("Login options:") fmt.Println("Login options:")
fmt.Println(" --provider <name> Provider to login with (openai, anthropic)") fmt.Println(" --provider <name> Provider to login with (openai, anthropic, google-antigravity)")
fmt.Println(" --device-code Use device code flow (for headless environments)") fmt.Println(" --device-code Use device code flow (for headless environments)")
fmt.Println() fmt.Println()
fmt.Println("Examples:") fmt.Println("Examples:")
fmt.Println(" picoclaw auth login --provider openai") fmt.Println(" picoclaw auth login --provider openai")
fmt.Println(" picoclaw auth login --provider openai --device-code") fmt.Println(" picoclaw auth login --provider openai --device-code")
fmt.Println(" picoclaw auth login --provider anthropic") fmt.Println(" picoclaw auth login --provider anthropic")
fmt.Println(" picoclaw auth login --provider google-antigravity")
fmt.Println(" picoclaw auth models")
fmt.Println(" picoclaw auth logout --provider openai") fmt.Println(" picoclaw auth logout --provider openai")
fmt.Println(" picoclaw auth status") fmt.Println(" picoclaw auth status")
} }
@ -836,7 +852,7 @@ func authLoginCmd() {
if provider == "" { if provider == "" {
fmt.Println("Error: --provider is required") fmt.Println("Error: --provider is required")
fmt.Println("Supported providers: openai, anthropic") fmt.Println("Supported providers: openai, anthropic, google-antigravity")
return return
} }
@ -845,9 +861,11 @@ func authLoginCmd() {
authLoginOpenAI(useDeviceCode) authLoginOpenAI(useDeviceCode)
case "anthropic": case "anthropic":
authLoginPasteToken(provider) authLoginPasteToken(provider)
case "google-antigravity", "antigravity":
authLoginGoogleAntigravity()
default: default:
fmt.Printf("Unsupported provider: %s\n", provider) fmt.Printf("Unsupported provider: %s\n", provider)
fmt.Println("Supported providers: openai, anthropic") fmt.Println("Supported providers: openai, anthropic, google-antigravity")
} }
} }
@ -887,6 +905,88 @@ func authLoginOpenAI(useDeviceCode bool) {
} }
} }
func authLoginGoogleAntigravity() {
cfg := auth.GoogleAntigravityOAuthConfig()
cred, err := auth.LoginBrowser(cfg)
if err != nil {
fmt.Printf("Login failed: %v\n", err)
os.Exit(1)
}
cred.Provider = "google-antigravity"
// Fetch user email from Google userinfo
email, err := fetchGoogleUserEmail(cred.AccessToken)
if err != nil {
fmt.Printf("Warning: could not fetch email: %v\n", err)
} else {
cred.Email = email
fmt.Printf("Email: %s\n", email)
}
// Fetch Cloud Code Assist project ID
projectID, err := providers.FetchAntigravityProjectID(cred.AccessToken)
if err != nil {
fmt.Printf("Warning: could not fetch project ID: %v\n", err)
fmt.Println("You may need Google Cloud Code Assist enabled on your account.")
} else {
cred.ProjectID = projectID
fmt.Printf("Project: %s\n", projectID)
}
if err := auth.SetCredential("google-antigravity", cred); err != nil {
fmt.Printf("Failed to save credentials: %v\n", err)
os.Exit(1)
}
appCfg, err := loadConfig()
if err == nil {
appCfg.Providers.Antigravity.AuthMethod = "oauth"
if appCfg.Agents.Defaults.Provider == "" {
appCfg.Agents.Defaults.Provider = "antigravity"
}
if appCfg.Agents.Defaults.Provider == "antigravity" || appCfg.Agents.Defaults.Provider == "google-antigravity" {
appCfg.Agents.Defaults.Model = "gemini-3-flash"
}
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
fmt.Printf("Warning: could not update config: %v\n", err)
}
}
fmt.Println("\n✓ Google Antigravity login successful!")
fmt.Println("Config updated: provider=antigravity, model=gemini-3-flash")
fmt.Println("Try it: picoclaw agent -m \"Hello world\"")
}
func fetchGoogleUserEmail(accessToken string) (string, error) {
req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("userinfo request failed: %s", string(body))
}
var userInfo struct {
Email string `json:"email"`
}
if err := json.Unmarshal(body, &userInfo); err != nil {
return "", err
}
return userInfo.Email, nil
}
func authLoginPasteToken(provider string) { func authLoginPasteToken(provider string) {
cred, err := auth.LoginPasteToken(provider, os.Stdin) cred, err := auth.LoginPasteToken(provider, os.Stdin)
if err != nil { if err != nil {
@ -942,6 +1042,8 @@ func authLogoutCmd() {
appCfg.Providers.OpenAI.AuthMethod = "" appCfg.Providers.OpenAI.AuthMethod = ""
case "anthropic": case "anthropic":
appCfg.Providers.Anthropic.AuthMethod = "" appCfg.Providers.Anthropic.AuthMethod = ""
case "google-antigravity", "antigravity":
appCfg.Providers.Antigravity.AuthMethod = ""
} }
config.SaveConfig(getConfigPath(), appCfg) config.SaveConfig(getConfigPath(), appCfg)
} }
@ -957,6 +1059,7 @@ func authLogoutCmd() {
if err == nil { if err == nil {
appCfg.Providers.OpenAI.AuthMethod = "" appCfg.Providers.OpenAI.AuthMethod = ""
appCfg.Providers.Anthropic.AuthMethod = "" appCfg.Providers.Anthropic.AuthMethod = ""
appCfg.Providers.Antigravity.AuthMethod = ""
config.SaveConfig(getConfigPath(), appCfg) config.SaveConfig(getConfigPath(), appCfg)
} }
@ -993,12 +1096,70 @@ func authStatusCmd() {
if cred.AccountID != "" { if cred.AccountID != "" {
fmt.Printf(" Account: %s\n", cred.AccountID) fmt.Printf(" Account: %s\n", cred.AccountID)
} }
if cred.Email != "" {
fmt.Printf(" Email: %s\n", cred.Email)
}
if cred.ProjectID != "" {
fmt.Printf(" Project: %s\n", cred.ProjectID)
}
if !cred.ExpiresAt.IsZero() { if !cred.ExpiresAt.IsZero() {
fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04")) fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04"))
} }
} }
} }
func authModelsCmd() {
cred, err := auth.GetCredential("google-antigravity")
if err != nil || cred == nil {
fmt.Println("Not logged in to Google Antigravity.")
fmt.Println("Run: picoclaw auth login --provider google-antigravity")
return
}
// Refresh token if needed
if cred.NeedsRefresh() && cred.RefreshToken != "" {
oauthCfg := auth.GoogleAntigravityOAuthConfig()
refreshed, refreshErr := auth.RefreshAccessToken(cred, oauthCfg)
if refreshErr == nil {
cred = refreshed
_ = auth.SetCredential("google-antigravity", cred)
}
}
projectID := cred.ProjectID
if projectID == "" {
fmt.Println("No project ID stored. Try logging in again.")
return
}
fmt.Printf("Fetching models for project: %s\n\n", projectID)
models, err := providers.FetchAntigravityModels(cred.AccessToken, projectID)
if err != nil {
fmt.Printf("Error fetching models: %v\n", err)
return
}
if len(models) == 0 {
fmt.Println("No models available.")
return
}
fmt.Println("Available Antigravity Models:")
fmt.Println("-----------------------------")
for _, m := range models {
status := "✓"
if m.IsExhausted {
status = "✗ (quota exhausted)"
}
name := m.ID
if m.DisplayName != "" {
name = fmt.Sprintf("%s (%s)", m.ID, m.DisplayName)
}
fmt.Printf(" %s %s\n", status, name)
}
}
func getConfigPath() string { func getConfigPath() string {
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()
return filepath.Join(home, ".picoclaw", "config.json") return filepath.Join(home, ".picoclaw", "config.json")

1002
docs/ANTIGRAVITY_AUTH.md Normal file

File diff suppressed because it is too large Load diff

72
docs/ANTIGRAVITY_USAGE.md Normal file
View file

@ -0,0 +1,72 @@
# Using Antigravity Provider in PicoClaw
This guide explains how to set up and use the **Antigravity** (Google Cloud Code Assist) provider in PicoClaw.
## Prerequisites
1. A Google account.
2. Google Cloud Code Assist enabled (usually available via the "Gemini for Google Cloud" onboarding).
## 1. Authentication
To authenticate with Antigravity, run the following command:
```bash
picoclaw auth login --provider antigravity
```
### Manual Authentication (Headless/VPS)
If you are running on a server (Coolify/Docker) and cannot reach `localhost`, follow these steps:
1. Run the command above.
2. Copy the URL provided and open it in your local browser.
3. Complete the login.
4. Your browser will redirect to a `localhost:51121` URL (which will fail to load).
5. **Copy that final URL** from your browser's address bar.
6. **Paste it back into the terminal** where PicoClaw is waiting.
PicoClaw will extract the authorization code and complete the process automatically.
## 2. Managing Models
### List Available Models
To see which models your project has access to and check their quotas:
```bash
picoclaw auth models
```
### Switch Models
You can change the default model in `~/.picoclaw/config.json` or override it via the CLI:
```bash
# Override for a single command
picoclaw agent -m "Hello" --model claude-opus-4-6-thinking
```
## 3. Real-world Usage (Coolify/Docker)
If you are deploying via Coolify or Docker, follow these steps to test:
1. **Branch**: Use the `feat/antigravity-provider` branch.
2. **Environment Variables**:
* `PICOCLAW_AGENTS_DEFAULTS_PROVIDER=antigravity`
* `PICOCLAW_AGENTS_DEFAULTS_MODEL=gemini-3-flash`
3. **Authentication persistence**:
If you've logged in locally, you can copy your credentials to the server:
```bash
scp ~/.picoclaw/auth-profiles.json user@your-server:~/.picoclaw/
```
*Alternatively*, run the `auth login` command once on the server if you have terminal access.
## 4. Troubleshooting
* **Empty Response**: If a model returns an empty reply, it may be restricted for your project. Try `gemini-3-flash` or `claude-opus-4-6-thinking`.
* **429 Rate Limit**: Antigravity has strict quotas. PicoClaw will display the "reset time" in the error message if you hit a limit.
* **404 Not Found**: Ensure you are using a model ID from the `picoclaw auth models` list. Use the short ID (e.g., `gemini-3-flash`) not the full path.
## 5. Summary of Working Models
Based on testing, the following models are most reliable:
* `gemini-3-flash` (Fast, highly available)
* `gemini-2.5-flash-lite` (Lightweight)
* `claude-opus-4-6-thinking` (Powerful, includes reasoning)

View file

@ -189,16 +189,7 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str
systemPrompt += "\n\n## Summary of Previous Conversation\n\n" + summary systemPrompt += "\n\n## Summary of Previous Conversation\n\n" + summary
} }
//This fix prevents the session memory from LLM failure due to elimination of toolu_IDs required from LLM history = sanitizeHistoryForProvider(history)
// --- INICIO DEL FIX ---
//Diegox-17
for len(history) > 0 && (history[0].Role == "tool") {
logger.DebugCF("agent", "Removing orphaned tool message from history to prevent LLM error",
map[string]interface{}{"role": history[0].Role})
history = history[1:]
}
//Diegox-17
// --- FIN DEL FIX ---
messages = append(messages, providers.Message{ messages = append(messages, providers.Message{
Role: "system", Role: "system",
@ -207,14 +198,58 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str
messages = append(messages, history...) messages = append(messages, history...)
messages = append(messages, providers.Message{ if strings.TrimSpace(currentMessage) != "" {
Role: "user", messages = append(messages, providers.Message{
Content: currentMessage, Role: "user",
}) Content: currentMessage,
})
}
return messages return messages
} }
func sanitizeHistoryForProvider(history []providers.Message) []providers.Message {
if len(history) == 0 {
return history
}
sanitized := make([]providers.Message, 0, len(history))
for _, msg := range history {
switch msg.Role {
case "tool":
if len(sanitized) == 0 {
logger.DebugCF("agent", "Dropping orphaned leading tool message", map[string]interface{}{})
continue
}
last := sanitized[len(sanitized)-1]
if last.Role != "assistant" || len(last.ToolCalls) == 0 {
logger.DebugCF("agent", "Dropping orphaned tool message", map[string]interface{}{})
continue
}
sanitized = append(sanitized, msg)
case "assistant":
if len(msg.ToolCalls) > 0 {
if len(sanitized) == 0 {
logger.DebugCF("agent", "Dropping assistant tool-call turn at history start", map[string]interface{}{})
continue
}
prev := sanitized[len(sanitized)-1]
if prev.Role != "user" && prev.Role != "tool" {
logger.DebugCF("agent", "Dropping assistant tool-call turn with invalid predecessor", map[string]interface{}{"prev_role": prev.Role})
continue
}
}
sanitized = append(sanitized, msg)
default:
sanitized = append(sanitized, msg)
}
}
return sanitized
}
func (cb *ContextBuilder) AddToolResult(messages []providers.Message, toolCallID, toolName, result string) []providers.Message { func (cb *ContextBuilder) AddToolResult(messages []providers.Message, toolCallID, toolName, result string) []providers.Message {
messages = append(messages, providers.Message{ messages = append(messages, providers.Message{
Role: "tool", Role: "tool",

View file

@ -605,15 +605,20 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
break break
} }
// Log tool calls normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls))
toolNames := make([]string, 0, len(response.ToolCalls))
for _, tc := range response.ToolCalls { for _, tc := range response.ToolCalls {
normalizedToolCalls = append(normalizedToolCalls, normalizeProviderToolCall(tc))
}
// Log tool calls
toolNames := make([]string, 0, len(normalizedToolCalls))
for _, tc := range normalizedToolCalls {
toolNames = append(toolNames, tc.Name) toolNames = append(toolNames, tc.Name)
} }
logger.InfoCF("agent", "LLM requested tool calls", logger.InfoCF("agent", "LLM requested tool calls",
map[string]interface{}{ map[string]interface{}{
"tools": toolNames, "tools": toolNames,
"count": len(response.ToolCalls), "count": len(normalizedToolCalls),
"iteration": iteration, "iteration": iteration,
}) })
@ -622,14 +627,22 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
Role: "assistant", Role: "assistant",
Content: response.Content, Content: response.Content,
} }
for _, tc := range response.ToolCalls { for _, tc := range normalizedToolCalls {
argumentsJSON, _ := json.Marshal(tc.Arguments) argumentsJSON, _ := json.Marshal(tc.Arguments)
thoughtSignature := ""
if tc.Function != nil {
thoughtSignature = tc.Function.ThoughtSignature
}
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{ assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{
ID: tc.ID, ID: tc.ID,
Type: "function", Type: "function",
Name: tc.Name,
Arguments: tc.Arguments,
Function: &providers.FunctionCall{ Function: &providers.FunctionCall{
Name: tc.Name, Name: tc.Name,
Arguments: string(argumentsJSON), Arguments: string(argumentsJSON),
ThoughtSignature: thoughtSignature,
}, },
}) })
} }
@ -639,7 +652,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
al.sessions.AddFullMessage(opts.SessionKey, assistantMsg) al.sessions.AddFullMessage(opts.SessionKey, assistantMsg)
// Execute tool calls // Execute tool calls
for _, tc := range response.ToolCalls { for _, tc := range normalizedToolCalls {
// Log tool call with arguments preview // Log tool call with arguments preview
argsJSON, _ := json.Marshal(tc.Arguments) argsJSON, _ := json.Marshal(tc.Arguments)
argsPreview := utils.Truncate(string(argsJSON), 200) argsPreview := utils.Truncate(string(argsJSON), 200)
@ -702,6 +715,45 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
return finalContent, iteration, nil return finalContent, iteration, nil
} }
func normalizeProviderToolCall(tc providers.ToolCall) providers.ToolCall {
normalized := tc
if normalized.Name == "" && normalized.Function != nil {
normalized.Name = normalized.Function.Name
}
if normalized.Arguments == nil {
normalized.Arguments = map[string]interface{}{}
}
if len(normalized.Arguments) == 0 && normalized.Function != nil && normalized.Function.Arguments != "" {
var parsed map[string]interface{}
if err := json.Unmarshal([]byte(normalized.Function.Arguments), &parsed); err == nil && parsed != nil {
normalized.Arguments = parsed
}
}
argsJSON, _ := json.Marshal(normalized.Arguments)
if normalized.Function == nil {
normalized.Function = &providers.FunctionCall{
Name: normalized.Name,
Arguments: string(argsJSON),
}
} else {
if normalized.Function.Name == "" {
normalized.Function.Name = normalized.Name
}
if normalized.Name == "" {
normalized.Name = normalized.Function.Name
}
if normalized.Function.Arguments == "" {
normalized.Function.Arguments = string(argsJSON)
}
}
return normalized
}
// updateToolContexts updates the context for tools that need channel/chatID info. // updateToolContexts updates the context for tools that need channel/chatID info.
func (al *AgentLoop) updateToolContexts(channel, chatID string) { func (al *AgentLoop) updateToolContexts(channel, chatID string) {
// Use ContextualTool interface instead of type assertions // Use ContextualTool interface instead of type assertions

View file

@ -1,6 +1,7 @@
package auth package auth
import ( import (
"bufio"
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
@ -11,6 +12,7 @@ import (
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"os"
"os/exec" "os/exec"
"runtime" "runtime"
"strconv" "strconv"
@ -19,11 +21,13 @@ import (
) )
type OAuthProviderConfig struct { type OAuthProviderConfig struct {
Issuer string Issuer string
ClientID string ClientID string
Scopes string ClientSecret string // Required for Google OAuth (confidential client)
Originator string TokenURL string // Override token endpoint (Google uses a different URL than issuer)
Port int Scopes string
Originator string
Port int
} }
func OpenAIOAuthConfig() OAuthProviderConfig { func OpenAIOAuthConfig() OAuthProviderConfig {
@ -36,6 +40,30 @@ func OpenAIOAuthConfig() OAuthProviderConfig {
} }
} }
// GoogleAntigravityOAuthConfig returns the OAuth configuration for Google Cloud Code Assist (Antigravity).
// Client credentials are the same ones used by OpenCode/pi-ai for Cloud Code Assist access.
func GoogleAntigravityOAuthConfig() OAuthProviderConfig {
// These are the same client credentials used by the OpenCode antigravity plugin.
clientID := decodeBase64("MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==")
clientSecret := decodeBase64("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=")
return OAuthProviderConfig{
Issuer: "https://accounts.google.com/o/oauth2/v2",
TokenURL: "https://oauth2.googleapis.com/token",
ClientID: clientID,
ClientSecret: clientSecret,
Scopes: "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/cclog https://www.googleapis.com/auth/experimentsandconfigs",
Port: 51121,
}
}
func decodeBase64(s string) string {
data, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return s
}
return string(data)
}
func generateState() (string, error) { func generateState() (string, error) {
buf := make([]byte, 32) buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil { if _, err := rand.Read(buf); err != nil {
@ -101,8 +129,17 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL) fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL)
} }
fmt.Println("If you're running in a headless environment, use: picoclaw auth login --provider openai --device-code") fmt.Printf("Wait! If you are in a headless environment (like Coolify/VPS) and cannot reach localhost:%d,\n", cfg.Port)
fmt.Println("Waiting for authentication in browser...") fmt.Println("please complete the login in your local browser and then PASTE the final redirect URL (or just the code) here.")
fmt.Println("Waiting for authentication (browser or manual paste)...")
// Start manual input in a goroutine
manualCh := make(chan string)
go func() {
reader := bufio.NewReader(os.Stdin)
input, _ := reader.ReadString('\n')
manualCh <- strings.TrimSpace(input)
}()
select { select {
case result := <-resultCh: case result := <-resultCh:
@ -110,6 +147,22 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
return nil, result.err return nil, result.err
} }
return exchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI) return exchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI)
case manualInput := <-manualCh:
if manualInput == "" {
return nil, fmt.Errorf("manual input cancelled")
}
// Extract code from URL if it's a full URL
code := manualInput
if strings.Contains(manualInput, "?") {
u, err := url.Parse(manualInput)
if err == nil {
code = u.Query().Get("code")
}
}
if code == "" {
return nil, fmt.Errorf("could not find authorization code in input")
}
return exchangeCodeForTokens(cfg, code, pkce.CodeVerifier, redirectURI)
case <-time.After(5 * time.Minute): case <-time.After(5 * time.Minute):
return nil, fmt.Errorf("authentication timed out after 5 minutes") return nil, fmt.Errorf("authentication timed out after 5 minutes")
} }
@ -269,8 +322,16 @@ func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCre
"refresh_token": {cred.RefreshToken}, "refresh_token": {cred.RefreshToken},
"scope": {"openid profile email"}, "scope": {"openid profile email"},
} }
if cfg.ClientSecret != "" {
data.Set("client_secret", cfg.ClientSecret)
}
resp, err := http.PostForm(cfg.Issuer+"/oauth/token", data) tokenURL := cfg.Issuer + "/oauth/token"
if cfg.TokenURL != "" {
tokenURL = cfg.TokenURL
}
resp, err := http.PostForm(tokenURL, data)
if err != nil { if err != nil {
return nil, fmt.Errorf("refreshing token: %w", err) return nil, fmt.Errorf("refreshing token: %w", err)
} }
@ -291,6 +352,12 @@ func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCre
if refreshed.AccountID == "" { if refreshed.AccountID == "" {
refreshed.AccountID = cred.AccountID refreshed.AccountID = cred.AccountID
} }
if cred.Email != "" && refreshed.Email == "" {
refreshed.Email = cred.Email
}
if cred.ProjectID != "" && refreshed.ProjectID == "" {
refreshed.ProjectID = cred.ProjectID
}
return refreshed, nil return refreshed, nil
} }
@ -300,21 +367,35 @@ func BuildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectU
func buildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectURI string) string { func buildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectURI string) string {
params := url.Values{ params := url.Values{
"response_type": {"code"}, "response_type": {"code"},
"client_id": {cfg.ClientID}, "client_id": {cfg.ClientID},
"redirect_uri": {redirectURI}, "redirect_uri": {redirectURI},
"scope": {cfg.Scopes}, "scope": {cfg.Scopes},
"code_challenge": {pkce.CodeChallenge}, "code_challenge": {pkce.CodeChallenge},
"code_challenge_method": {"S256"}, "code_challenge_method": {"S256"},
"id_token_add_organizations": {"true"}, "state": {state},
"codex_cli_simplified_flow": {"true"},
"state": {state},
} }
if strings.Contains(strings.ToLower(cfg.Issuer), "auth.openai.com") {
params.Set("originator", "picoclaw") isGoogle := strings.Contains(strings.ToLower(cfg.Issuer), "accounts.google.com")
if isGoogle {
// Google OAuth requires these for refresh token support
params.Set("access_type", "offline")
params.Set("prompt", "consent")
} else {
// OpenAI-specific parameters
params.Set("id_token_add_organizations", "true")
params.Set("codex_cli_simplified_flow", "true")
if strings.Contains(strings.ToLower(cfg.Issuer), "auth.openai.com") {
params.Set("originator", "picoclaw")
}
if cfg.Originator != "" {
params.Set("originator", cfg.Originator)
}
} }
if cfg.Originator != "" {
params.Set("originator", cfg.Originator) // Google uses /auth path, OpenAI uses /oauth/authorize
if isGoogle {
return cfg.Issuer + "/auth?" + params.Encode()
} }
return cfg.Issuer + "/oauth/authorize?" + params.Encode() return cfg.Issuer + "/oauth/authorize?" + params.Encode()
} }
@ -327,8 +408,22 @@ func exchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirect
"client_id": {cfg.ClientID}, "client_id": {cfg.ClientID},
"code_verifier": {codeVerifier}, "code_verifier": {codeVerifier},
} }
if cfg.ClientSecret != "" {
data.Set("client_secret", cfg.ClientSecret)
}
resp, err := http.PostForm(cfg.Issuer+"/oauth/token", data) tokenURL := cfg.Issuer + "/oauth/token"
if cfg.TokenURL != "" {
tokenURL = cfg.TokenURL
}
// Determine provider name from config
provider := "openai"
if cfg.TokenURL != "" && strings.Contains(cfg.TokenURL, "googleapis.com") {
provider = "google-antigravity"
}
resp, err := http.PostForm(tokenURL, data)
if err != nil { if err != nil {
return nil, fmt.Errorf("exchanging code for tokens: %w", err) return nil, fmt.Errorf("exchanging code for tokens: %w", err)
} }
@ -339,7 +434,7 @@ func exchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirect
return nil, fmt.Errorf("token exchange failed: %s", string(body)) return nil, fmt.Errorf("token exchange failed: %s", string(body))
} }
return parseTokenResponse(body, "openai") return parseTokenResponse(body, provider)
} }
func parseTokenResponse(body []byte, provider string) (*AuthCredential, error) { func parseTokenResponse(body []byte, provider string) (*AuthCredential, error) {

View file

@ -14,6 +14,8 @@ type AuthCredential struct {
ExpiresAt time.Time `json:"expires_at,omitempty"` ExpiresAt time.Time `json:"expires_at,omitempty"`
Provider string `json:"provider"` Provider string `json:"provider"`
AuthMethod string `json:"auth_method"` AuthMethod string `json:"auth_method"`
Email string `json:"email,omitempty"`
ProjectID string `json:"project_id,omitempty"`
} }
type AuthStore struct { type AuthStore struct {

View file

@ -182,6 +182,7 @@ type ProvidersConfig struct {
Cerebras ProviderConfig `json:"cerebras"` Cerebras ProviderConfig `json:"cerebras"`
VolcEngine ProviderConfig `json:"volcengine"` VolcEngine ProviderConfig `json:"volcengine"`
GitHubCopilot ProviderConfig `json:"github_copilot"` GitHubCopilot ProviderConfig `json:"github_copilot"`
Antigravity ProviderConfig `json:"antigravity"`
Qwen ProviderConfig `json:"qwen"` Qwen ProviderConfig `json:"qwen"`
} }

View file

@ -0,0 +1,827 @@
package providers
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/logger"
)
const (
antigravityBaseURL = "https://cloudcode-pa.googleapis.com"
antigravityDefaultModel = "gemini-3-flash"
antigravityUserAgent = "antigravity"
antigravityXGoogClient = "google-cloud-sdk vscode_cloudshelleditor/0.1"
antigravityVersion = "1.15.8"
)
// AntigravityProvider implements LLMProvider using Google's Cloud Code Assist (Antigravity) API.
// This provider authenticates via Google OAuth and provides access to models like Claude and Gemini
// through Google's infrastructure.
type AntigravityProvider struct {
tokenSource func() (string, string, error) // Returns (accessToken, projectID, error)
httpClient *http.Client
}
// NewAntigravityProvider creates a new Antigravity provider using stored auth credentials.
func NewAntigravityProvider() *AntigravityProvider {
return &AntigravityProvider{
tokenSource: createAntigravityTokenSource(),
httpClient: &http.Client{
Timeout: 120 * time.Second,
},
}
}
// Chat implements LLMProvider.Chat using the Cloud Code Assist v1internal API.
// The v1internal endpoint wraps the standard Gemini request in an envelope with
// project, model, request, requestType, userAgent, and requestId fields.
func (p *AntigravityProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
accessToken, projectID, err := p.tokenSource()
if err != nil {
return nil, fmt.Errorf("antigravity auth: %w", err)
}
if model == "" || model == "antigravity" || model == "google-antigravity" {
model = antigravityDefaultModel
}
// Strip provider prefixes if present
model = strings.TrimPrefix(model, "google-antigravity/")
model = strings.TrimPrefix(model, "antigravity/")
logger.DebugCF("provider.antigravity", "Starting chat", map[string]interface{}{
"model": model,
"project": projectID,
"requestId": fmt.Sprintf("agent-%d-%s", time.Now().UnixMilli(), randomString(9)),
})
// Build the inner Gemini-format request
innerRequest := p.buildRequest(messages, tools, model, options)
// Wrap in v1internal envelope (matches pi-ai SDK format)
envelope := map[string]interface{}{
"project": projectID,
"model": model,
"request": innerRequest,
"requestType": "agent",
"userAgent": antigravityUserAgent,
"requestId": fmt.Sprintf("agent-%d-%s", time.Now().UnixMilli(), randomString(9)),
}
bodyBytes, err := json.Marshal(envelope)
if err != nil {
return nil, fmt.Errorf("marshaling request: %w", err)
}
// Build API URL — uses Cloud Code Assist v1internal streaming endpoint
apiURL := fmt.Sprintf("%s/v1internal:streamGenerateContent?alt=sse", antigravityBaseURL)
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(bodyBytes))
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
// Headers matching the pi-ai SDK antigravity format
clientMetadata, _ := json.Marshal(map[string]string{
"ideType": "IDE_UNSPECIFIED",
"platform": "PLATFORM_UNSPECIFIED",
"pluginType": "GEMINI",
})
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("User-Agent", fmt.Sprintf("antigravity/%s linux/amd64", antigravityVersion))
req.Header.Set("X-Goog-Api-Client", antigravityXGoogClient)
req.Header.Set("Client-Metadata", string(clientMetadata))
resp, err := p.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("antigravity API call: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response: %w", err)
}
if resp.StatusCode != http.StatusOK {
logger.ErrorCF("provider.antigravity", "API call failed", map[string]interface{}{
"status_code": resp.StatusCode,
"response": string(respBody),
"model": model,
})
return nil, p.parseAntigravityError(resp.StatusCode, respBody)
}
// Response is always SSE from streamGenerateContent — each line is "data: {...}"
// with a "response" wrapper containing the standard Gemini response
llmResp, err := p.parseSSEResponse(string(respBody))
if err != nil {
return nil, err
}
// Check for empty response (some models might return valid success but empty text)
if llmResp.Content == "" && len(llmResp.ToolCalls) == 0 {
return nil, fmt.Errorf("antigravity: model returned an empty response (this model might be invalid or restricted)")
}
return llmResp, nil
}
// GetDefaultModel returns the default model identifier.
func (p *AntigravityProvider) GetDefaultModel() string {
return antigravityDefaultModel
}
// --- Request building ---
type antigravityRequest struct {
Contents []antigravityContent `json:"contents"`
Tools []antigravityTool `json:"tools,omitempty"`
SystemPrompt *antigravitySystemPrompt `json:"systemInstruction,omitempty"`
Config *antigravityGenConfig `json:"generationConfig,omitempty"`
}
type antigravityContent struct {
Role string `json:"role"`
Parts []antigravityPart `json:"parts"`
}
type antigravityPart struct {
Text string `json:"text,omitempty"`
ThoughtSignature string `json:"thoughtSignature,omitempty"`
ThoughtSignatureSnake string `json:"thought_signature,omitempty"`
FunctionCall *antigravityFunctionCall `json:"functionCall,omitempty"`
FunctionResponse *antigravityFunctionResponse `json:"functionResponse,omitempty"`
}
type antigravityFunctionCall struct {
Name string `json:"name"`
Args map[string]interface{} `json:"args"`
}
type antigravityFunctionResponse struct {
Name string `json:"name"`
Response map[string]interface{} `json:"response"`
}
type antigravityTool struct {
FunctionDeclarations []antigravityFuncDecl `json:"functionDeclarations"`
}
type antigravityFuncDecl struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Parameters interface{} `json:"parameters,omitempty"`
}
type antigravitySystemPrompt struct {
Parts []antigravityPart `json:"parts"`
}
type antigravityGenConfig struct {
MaxOutputTokens int `json:"maxOutputTokens,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
}
func (p *AntigravityProvider) buildRequest(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) antigravityRequest {
req := antigravityRequest{}
toolCallNames := make(map[string]string)
// Build contents from messages
for _, msg := range messages {
switch msg.Role {
case "system":
req.SystemPrompt = &antigravitySystemPrompt{
Parts: []antigravityPart{{Text: msg.Content}},
}
case "user":
if msg.ToolCallID != "" {
toolName := resolveToolResponseName(msg.ToolCallID, toolCallNames)
// Tool result
req.Contents = append(req.Contents, antigravityContent{
Role: "user",
Parts: []antigravityPart{{
FunctionResponse: &antigravityFunctionResponse{
Name: toolName,
Response: map[string]interface{}{
"result": msg.Content,
},
},
}},
})
} else {
req.Contents = append(req.Contents, antigravityContent{
Role: "user",
Parts: []antigravityPart{{Text: msg.Content}},
})
}
case "assistant":
content := antigravityContent{
Role: "model",
}
if msg.Content != "" {
content.Parts = append(content.Parts, antigravityPart{Text: msg.Content})
}
for _, tc := range msg.ToolCalls {
toolName, toolArgs, thoughtSignature := normalizeStoredToolCall(tc)
if toolName == "" {
logger.WarnCF("provider.antigravity", "Skipping tool call with empty name in history", map[string]interface{}{
"tool_call_id": tc.ID,
})
continue
}
if tc.ID != "" {
toolCallNames[tc.ID] = toolName
}
content.Parts = append(content.Parts, antigravityPart{
ThoughtSignature: thoughtSignature,
ThoughtSignatureSnake: thoughtSignature,
FunctionCall: &antigravityFunctionCall{
Name: toolName,
Args: toolArgs,
},
})
}
if len(content.Parts) > 0 {
req.Contents = append(req.Contents, content)
}
case "tool":
toolName := resolveToolResponseName(msg.ToolCallID, toolCallNames)
req.Contents = append(req.Contents, antigravityContent{
Role: "user",
Parts: []antigravityPart{{
FunctionResponse: &antigravityFunctionResponse{
Name: toolName,
Response: map[string]interface{}{
"result": msg.Content,
},
},
}},
})
}
}
// Build tools (sanitize schemas for Gemini compatibility)
if len(tools) > 0 {
var funcDecls []antigravityFuncDecl
for _, t := range tools {
if t.Type != "function" {
continue
}
params := sanitizeSchemaForGemini(t.Function.Parameters)
funcDecls = append(funcDecls, antigravityFuncDecl{
Name: t.Function.Name,
Description: t.Function.Description,
Parameters: params,
})
}
if len(funcDecls) > 0 {
req.Tools = []antigravityTool{{FunctionDeclarations: funcDecls}}
}
}
// Generation config
config := &antigravityGenConfig{}
if val, ok := options["max_tokens"]; ok {
if maxTokens, ok := val.(int); ok && maxTokens > 0 {
config.MaxOutputTokens = maxTokens
} else if maxTokens, ok := val.(float64); ok && maxTokens > 0 {
config.MaxOutputTokens = int(maxTokens)
}
}
if temp, ok := options["temperature"].(float64); ok {
config.Temperature = temp
}
if config.MaxOutputTokens > 0 || config.Temperature > 0 {
req.Config = config
}
return req
}
func normalizeStoredToolCall(tc ToolCall) (string, map[string]interface{}, string) {
name := tc.Name
args := tc.Arguments
thoughtSignature := ""
if name == "" && tc.Function != nil {
name = tc.Function.Name
thoughtSignature = tc.Function.ThoughtSignature
} else if tc.Function != nil {
thoughtSignature = tc.Function.ThoughtSignature
}
if args == nil {
args = map[string]interface{}{}
}
if len(args) == 0 && tc.Function != nil && tc.Function.Arguments != "" {
var parsed map[string]interface{}
if err := json.Unmarshal([]byte(tc.Function.Arguments), &parsed); err == nil && parsed != nil {
args = parsed
}
}
return name, args, thoughtSignature
}
func resolveToolResponseName(toolCallID string, toolCallNames map[string]string) string {
if toolCallID == "" {
return ""
}
if name, ok := toolCallNames[toolCallID]; ok && name != "" {
return name
}
return inferToolNameFromCallID(toolCallID)
}
func inferToolNameFromCallID(toolCallID string) string {
if !strings.HasPrefix(toolCallID, "call_") {
return toolCallID
}
rest := strings.TrimPrefix(toolCallID, "call_")
if idx := strings.LastIndex(rest, "_"); idx > 0 {
candidate := rest[:idx]
if candidate != "" {
return candidate
}
}
return toolCallID
}
// --- Response parsing ---
type antigravityJSONResponse struct {
Candidates []struct {
Content struct {
Parts []struct {
Text string `json:"text,omitempty"`
ThoughtSignature string `json:"thoughtSignature,omitempty"`
ThoughtSignatureSnake string `json:"thought_signature,omitempty"`
FunctionCall *antigravityFunctionCall `json:"functionCall,omitempty"`
} `json:"parts"`
Role string `json:"role"`
} `json:"content"`
FinishReason string `json:"finishReason"`
} `json:"candidates"`
UsageMetadata struct {
PromptTokenCount int `json:"promptTokenCount"`
CandidatesTokenCount int `json:"candidatesTokenCount"`
TotalTokenCount int `json:"totalTokenCount"`
} `json:"usageMetadata"`
}
func (p *AntigravityProvider) parseJSONResponse(body []byte) (*LLMResponse, error) {
var resp antigravityJSONResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("parsing antigravity response: %w", err)
}
if len(resp.Candidates) == 0 {
return nil, fmt.Errorf("antigravity: no candidates in response")
}
candidate := resp.Candidates[0]
var contentParts []string
var toolCalls []ToolCall
for _, part := range candidate.Content.Parts {
if part.Text != "" {
contentParts = append(contentParts, part.Text)
}
if part.FunctionCall != nil {
argumentsJSON, _ := json.Marshal(part.FunctionCall.Args)
toolCalls = append(toolCalls, 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(argumentsJSON),
ThoughtSignature: extractPartThoughtSignature(part.ThoughtSignature, part.ThoughtSignatureSnake),
},
})
}
}
finishReason := "stop"
if len(toolCalls) > 0 {
finishReason = "tool_calls"
}
if candidate.FinishReason == "MAX_TOKENS" {
finishReason = "length"
}
var usage *UsageInfo
if resp.UsageMetadata.TotalTokenCount > 0 {
usage = &UsageInfo{
PromptTokens: resp.UsageMetadata.PromptTokenCount,
CompletionTokens: resp.UsageMetadata.CandidatesTokenCount,
TotalTokens: resp.UsageMetadata.TotalTokenCount,
}
}
return &LLMResponse{
Content: strings.Join(contentParts, ""),
ToolCalls: toolCalls,
FinishReason: finishReason,
Usage: usage,
}, nil
}
func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error) {
var contentParts []string
var toolCalls []ToolCall
var usage *UsageInfo
var finishReason string
scanner := bufio.NewScanner(strings.NewReader(body))
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
break
}
// v1internal SSE wraps the Gemini response in a "response" field
var sseChunk struct {
Response antigravityJSONResponse `json:"response"`
}
if err := json.Unmarshal([]byte(data), &sseChunk); err != nil {
continue
}
resp := sseChunk.Response
for _, candidate := range resp.Candidates {
for _, part := range candidate.Content.Parts {
if part.Text != "" {
contentParts = append(contentParts, part.Text)
}
if part.FunctionCall != nil {
argumentsJSON, _ := json.Marshal(part.FunctionCall.Args)
toolCalls = append(toolCalls, 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(argumentsJSON),
ThoughtSignature: extractPartThoughtSignature(part.ThoughtSignature, part.ThoughtSignatureSnake),
},
})
}
}
if candidate.FinishReason != "" {
finishReason = candidate.FinishReason
}
}
if resp.UsageMetadata.TotalTokenCount > 0 {
usage = &UsageInfo{
PromptTokens: resp.UsageMetadata.PromptTokenCount,
CompletionTokens: resp.UsageMetadata.CandidatesTokenCount,
TotalTokens: resp.UsageMetadata.TotalTokenCount,
}
}
}
mappedFinish := "stop"
if len(toolCalls) > 0 {
mappedFinish = "tool_calls"
}
if finishReason == "MAX_TOKENS" {
mappedFinish = "length"
}
return &LLMResponse{
Content: strings.Join(contentParts, ""),
ToolCalls: toolCalls,
FinishReason: mappedFinish,
Usage: usage,
}, nil
}
func extractPartThoughtSignature(thoughtSignature string, thoughtSignatureSnake string) string {
if thoughtSignature != "" {
return thoughtSignature
}
if thoughtSignatureSnake != "" {
return thoughtSignatureSnake
}
return ""
}
// --- Schema sanitization ---
// Google/Gemini doesn't support many JSON Schema keywords that other providers accept.
var geminiUnsupportedKeywords = map[string]bool{
"patternProperties": true,
"additionalProperties": true,
"$schema": true,
"$id": true,
"$ref": true,
"$defs": true,
"definitions": true,
"examples": true,
"minLength": true,
"maxLength": true,
"minimum": true,
"maximum": true,
"multipleOf": true,
"pattern": true,
"format": true,
"minItems": true,
"maxItems": true,
"uniqueItems": true,
"minProperties": true,
"maxProperties": true,
}
func sanitizeSchemaForGemini(schema map[string]interface{}) map[string]interface{} {
if schema == nil {
return nil
}
result := make(map[string]interface{})
for k, v := range schema {
if geminiUnsupportedKeywords[k] {
continue
}
// Recursively sanitize nested objects
switch val := v.(type) {
case map[string]interface{}:
result[k] = sanitizeSchemaForGemini(val)
case []interface{}:
sanitized := make([]interface{}, len(val))
for i, item := range val {
if m, ok := item.(map[string]interface{}); ok {
sanitized[i] = sanitizeSchemaForGemini(m)
} else {
sanitized[i] = item
}
}
result[k] = sanitized
default:
result[k] = v
}
}
// Ensure top-level has type: "object" if properties are present
if _, hasProps := result["properties"]; hasProps {
if _, hasType := result["type"]; !hasType {
result["type"] = "object"
}
}
return result
}
// --- Token source ---
func createAntigravityTokenSource() func() (string, string, error) {
return func() (string, string, error) {
cred, err := auth.GetCredential("google-antigravity")
if err != nil {
return "", "", fmt.Errorf("loading auth credentials: %w", err)
}
if cred == nil {
return "", "", fmt.Errorf("no credentials for google-antigravity. Run: picoclaw auth login --provider google-antigravity")
}
// Refresh if needed
if cred.NeedsRefresh() && cred.RefreshToken != "" {
oauthCfg := auth.GoogleAntigravityOAuthConfig()
refreshed, err := auth.RefreshAccessToken(cred, oauthCfg)
if err != nil {
return "", "", fmt.Errorf("refreshing token: %w", err)
}
refreshed.Email = cred.Email
if refreshed.ProjectID == "" {
refreshed.ProjectID = cred.ProjectID
}
if err := auth.SetCredential("google-antigravity", refreshed); err != nil {
return "", "", fmt.Errorf("saving refreshed token: %w", err)
}
cred = refreshed
}
if cred.IsExpired() {
return "", "", fmt.Errorf("antigravity credentials expired. Run: picoclaw auth login --provider google-antigravity")
}
projectID := cred.ProjectID
if projectID == "" {
// Try to fetch project ID from API
fetchedID, err := FetchAntigravityProjectID(cred.AccessToken)
if err != nil {
logger.WarnCF("provider.antigravity", "Could not fetch project ID, using fallback", map[string]interface{}{
"error": err.Error(),
})
projectID = "rising-fact-p41fc" // Default fallback (same as OpenCode)
} else {
projectID = fetchedID
cred.ProjectID = projectID
_ = auth.SetCredential("google-antigravity", cred)
}
}
return cred.AccessToken, projectID, nil
}
}
// FetchAntigravityProjectID retrieves the Google Cloud project ID from the loadCodeAssist endpoint.
func FetchAntigravityProjectID(accessToken string) (string, error) {
reqBody, _ := json.Marshal(map[string]interface{}{
"metadata": map[string]interface{}{
"ideType": "IDE_UNSPECIFIED",
"platform": "PLATFORM_UNSPECIFIED",
"pluginType": "GEMINI",
},
})
req, err := http.NewRequest("POST", antigravityBaseURL+"/v1internal:loadCodeAssist", bytes.NewReader(reqBody))
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", antigravityUserAgent)
req.Header.Set("X-Goog-Api-Client", antigravityXGoogClient)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("loadCodeAssist failed: %s", string(body))
}
var result struct {
CloudAICompanionProject string `json:"cloudaicompanionProject"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", err
}
if result.CloudAICompanionProject == "" {
return "", fmt.Errorf("no project ID in loadCodeAssist response")
}
return result.CloudAICompanionProject, nil
}
// FetchAntigravityModels fetches available models from the Cloud Code Assist API.
func FetchAntigravityModels(accessToken, projectID string) ([]AntigravityModelInfo, error) {
reqBody, _ := json.Marshal(map[string]interface{}{
"project": projectID,
})
req, err := http.NewRequest("POST", antigravityBaseURL+"/v1internal:fetchAvailableModels", bytes.NewReader(reqBody))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", antigravityUserAgent)
req.Header.Set("X-Goog-Api-Client", antigravityXGoogClient)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetchAvailableModels failed (HTTP %d): %s", resp.StatusCode, truncateString(string(body), 200))
}
var result struct {
Models map[string]struct {
DisplayName string `json:"displayName"`
QuotaInfo struct {
RemainingFraction interface{} `json:"remainingFraction"`
ResetTime string `json:"resetTime"`
IsExhausted bool `json:"isExhausted"`
} `json:"quotaInfo"`
} `json:"models"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parsing models response: %w", err)
}
var models []AntigravityModelInfo
for id, info := range result.Models {
models = append(models, AntigravityModelInfo{
ID: id,
DisplayName: info.DisplayName,
IsExhausted: info.QuotaInfo.IsExhausted,
})
}
// Ensure gemini-3-flash-preview and gemini-3-flash are in the list if they aren't already
hasFlashPreview := false
hasFlash := false
for _, m := range models {
if m.ID == "gemini-3-flash-preview" {
hasFlashPreview = true
}
if m.ID == "gemini-3-flash" {
hasFlash = true
}
}
if !hasFlashPreview {
models = append(models, AntigravityModelInfo{
ID: "gemini-3-flash-preview",
DisplayName: "Gemini 3 Flash (Preview)",
})
}
if !hasFlash {
models = append(models, AntigravityModelInfo{
ID: "gemini-3-flash",
DisplayName: "Gemini 3 Flash",
})
}
return models, nil
}
type AntigravityModelInfo struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
IsExhausted bool `json:"is_exhausted"`
}
// --- Helpers ---
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}
func randomString(n int) string {
const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, n)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}
func (p *AntigravityProvider) parseAntigravityError(statusCode int, body []byte) error {
var errResp struct {
Error struct {
Code int `json:"code"`
Message string `json:"message"`
Status string `json:"status"`
Details []map[string]interface{} `json:"details"`
} `json:"error"`
}
if err := json.Unmarshal(body, &errResp); err != nil {
return fmt.Errorf("antigravity API error (HTTP %d): %s", statusCode, truncateString(string(body), 500))
}
msg := errResp.Error.Message
if statusCode == 429 {
// Try to extract quota reset info
for _, detail := range errResp.Error.Details {
if typeVal, ok := detail["@type"].(string); ok && strings.HasSuffix(typeVal, "ErrorInfo") {
if metadata, ok := detail["metadata"].(map[string]interface{}); ok {
if delay, ok := metadata["quotaResetDelay"].(string); ok {
return fmt.Errorf("antigravity rate limit exceeded: %s (reset in %s)", msg, delay)
}
}
}
}
return fmt.Errorf("antigravity rate limit exceeded: %s", msg)
}
return fmt.Errorf("antigravity API error (%s): %s", errResp.Error.Status, msg)
}

View file

@ -0,0 +1,56 @@
package providers
import "testing"
func TestBuildRequestUsesFunctionFieldsWhenToolCallNameMissing(t *testing.T) {
p := &AntigravityProvider{}
messages := []Message{
{
Role: "assistant",
ToolCalls: []ToolCall{{
ID: "call_read_file_123",
Function: &FunctionCall{
Name: "read_file",
Arguments: `{"path":"README.md"}`,
},
}},
},
{
Role: "tool",
ToolCallID: "call_read_file_123",
Content: "ok",
},
}
req := p.buildRequest(messages, nil, "", nil)
if len(req.Contents) != 2 {
t.Fatalf("expected 2 contents, got %d", len(req.Contents))
}
modelPart := req.Contents[0].Parts[0]
if modelPart.FunctionCall == nil {
t.Fatal("expected functionCall in assistant message")
}
if modelPart.FunctionCall.Name != "read_file" {
t.Fatalf("expected functionCall name read_file, got %q", modelPart.FunctionCall.Name)
}
if got := modelPart.FunctionCall.Args["path"]; got != "README.md" {
t.Fatalf("expected functionCall args[path] to be README.md, got %v", got)
}
toolPart := req.Contents[1].Parts[0]
if toolPart.FunctionResponse == nil {
t.Fatal("expected functionResponse in tool message")
}
if toolPart.FunctionResponse.Name != "read_file" {
t.Fatalf("expected functionResponse name read_file, got %q", toolPart.FunctionResponse.Name)
}
}
func TestResolveToolResponseNameInfersNameFromGeneratedCallID(t *testing.T) {
got := resolveToolResponseName("call_search_docs_999", map[string]string{})
if got != "search_docs" {
t.Fatalf("expected inferred tool name search_docs, got %q", got)
}
}

View file

@ -132,8 +132,9 @@ func (p *HTTPProvider) parseResponse(body []byte) (*LLMResponse, error) {
ID string `json:"id"` ID string `json:"id"`
Type string `json:"type"` Type string `json:"type"`
Function *struct { Function *struct {
Name string `json:"name"` Name string `json:"name"`
Arguments string `json:"arguments"` Arguments string `json:"arguments"`
ThoughtSignature string `json:"thought_signature"`
} `json:"function"` } `json:"function"`
} `json:"tool_calls"` } `json:"tool_calls"`
} `json:"message"` } `json:"message"`
@ -159,18 +160,11 @@ func (p *HTTPProvider) parseResponse(body []byte) (*LLMResponse, error) {
for _, tc := range choice.Message.ToolCalls { for _, tc := range choice.Message.ToolCalls {
arguments := make(map[string]interface{}) arguments := make(map[string]interface{})
name := "" name := ""
thoughtSignature := ""
// Handle OpenAI format with nested function object if tc.Function != nil {
if tc.Type == "function" && tc.Function != nil {
name = tc.Function.Name
if tc.Function.Arguments != "" {
if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil {
arguments["raw"] = tc.Function.Arguments
}
}
} else if tc.Function != nil {
// Legacy format without type field
name = tc.Function.Name name = tc.Function.Name
thoughtSignature = tc.Function.ThoughtSignature
if tc.Function.Arguments != "" { if tc.Function.Arguments != "" {
if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil { if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil {
arguments["raw"] = tc.Function.Arguments arguments["raw"] = tc.Function.Arguments
@ -179,7 +173,13 @@ func (p *HTTPProvider) parseResponse(body []byte) (*LLMResponse, error) {
} }
toolCalls = append(toolCalls, ToolCall{ toolCalls = append(toolCalls, ToolCall{
ID: tc.ID, ID: tc.ID,
Type: tc.Type,
Function: &FunctionCall{
Name: name,
Arguments: tc.Function.Arguments,
ThoughtSignature: thoughtSignature,
},
Name: name, Name: name,
Arguments: arguments, Arguments: arguments,
}) })
@ -347,6 +347,8 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
apiBase = "localhost:4321" apiBase = "localhost:4321"
} }
return NewGitHubCopilotProvider(apiBase, cfg.Providers.GitHubCopilot.ConnectMode, model) return NewGitHubCopilotProvider(apiBase, cfg.Providers.GitHubCopilot.ConnectMode, model)
case "antigravity", "google-antigravity":
return NewAntigravityProvider(), nil
case "volcengine", "doubao": case "volcengine", "doubao":
if cfg.Providers.VolcEngine.APIKey != "" { if cfg.Providers.VolcEngine.APIKey != "" {

View file

@ -11,8 +11,9 @@ type ToolCall struct {
} }
type FunctionCall struct { type FunctionCall struct {
Name string `json:"name"` Name string `json:"name"`
Arguments string `json:"arguments"` Arguments string `json:"arguments"`
ThoughtSignature string `json:"thought_signature,omitempty"`
} }
type LLMResponse struct { type LLMResponse struct {

View file

@ -83,15 +83,20 @@ func RunToolLoop(ctx context.Context, config ToolLoopConfig, messages []provider
break break
} }
// 5. Log tool calls normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls))
toolNames := make([]string, 0, len(response.ToolCalls))
for _, tc := range response.ToolCalls { for _, tc := range response.ToolCalls {
normalizedToolCalls = append(normalizedToolCalls, normalizeProviderToolCall(tc))
}
// 5. Log tool calls
toolNames := make([]string, 0, len(normalizedToolCalls))
for _, tc := range normalizedToolCalls {
toolNames = append(toolNames, tc.Name) toolNames = append(toolNames, tc.Name)
} }
logger.InfoCF("toolloop", "LLM requested tool calls", logger.InfoCF("toolloop", "LLM requested tool calls",
map[string]any{ map[string]any{
"tools": toolNames, "tools": toolNames,
"count": len(response.ToolCalls), "count": len(normalizedToolCalls),
"iteration": iteration, "iteration": iteration,
}) })
@ -100,11 +105,13 @@ func RunToolLoop(ctx context.Context, config ToolLoopConfig, messages []provider
Role: "assistant", Role: "assistant",
Content: response.Content, Content: response.Content,
} }
for _, tc := range response.ToolCalls { for _, tc := range normalizedToolCalls {
argumentsJSON, _ := json.Marshal(tc.Arguments) argumentsJSON, _ := json.Marshal(tc.Arguments)
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{ assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{
ID: tc.ID, ID: tc.ID,
Type: "function", Type: "function",
Name: tc.Name,
Arguments: tc.Arguments,
Function: &providers.FunctionCall{ Function: &providers.FunctionCall{
Name: tc.Name, Name: tc.Name,
Arguments: string(argumentsJSON), Arguments: string(argumentsJSON),
@ -114,7 +121,7 @@ func RunToolLoop(ctx context.Context, config ToolLoopConfig, messages []provider
messages = append(messages, assistantMsg) messages = append(messages, assistantMsg)
// 7. Execute tool calls // 7. Execute tool calls
for _, tc := range response.ToolCalls { for _, tc := range normalizedToolCalls {
argsJSON, _ := json.Marshal(tc.Arguments) argsJSON, _ := json.Marshal(tc.Arguments)
argsPreview := utils.Truncate(string(argsJSON), 200) argsPreview := utils.Truncate(string(argsJSON), 200)
logger.InfoCF("toolloop", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview), logger.InfoCF("toolloop", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview),
@ -152,3 +159,42 @@ func RunToolLoop(ctx context.Context, config ToolLoopConfig, messages []provider
Iterations: iteration, Iterations: iteration,
}, nil }, nil
} }
func normalizeProviderToolCall(tc providers.ToolCall) providers.ToolCall {
normalized := tc
if normalized.Name == "" && normalized.Function != nil {
normalized.Name = normalized.Function.Name
}
if normalized.Arguments == nil {
normalized.Arguments = map[string]interface{}{}
}
if len(normalized.Arguments) == 0 && normalized.Function != nil && normalized.Function.Arguments != "" {
var parsed map[string]interface{}
if err := json.Unmarshal([]byte(normalized.Function.Arguments), &parsed); err == nil && parsed != nil {
normalized.Arguments = parsed
}
}
argsJSON, _ := json.Marshal(normalized.Arguments)
if normalized.Function == nil {
normalized.Function = &providers.FunctionCall{
Name: normalized.Name,
Arguments: string(argsJSON),
}
} else {
if normalized.Function.Name == "" {
normalized.Function.Name = normalized.Name
}
if normalized.Name == "" {
normalized.Name = normalized.Function.Name
}
if normalized.Function.Arguments == "" {
normalized.Function.Arguments = string(argsJSON)
}
}
return normalized
}