first commit

This commit is contained in:
Luis Pater
2025-07-02 03:42:56 +08:00
commit 827bd6e356
15 changed files with 3004 additions and 0 deletions

452
internal/client/client.go Normal file
View File

@@ -0,0 +1,452 @@
package client
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"golang.org/x/oauth2"
"io"
"net/http"
"runtime"
"strings"
"sync"
"time"
)
// --- Constants ---
const (
codeAssistEndpoint = "https://cloudcode-pa.googleapis.com"
apiVersion = "v1internal"
pluginVersion = "1.0.0"
)
type GCPProject struct {
Projects []GCPProjectProjects `json:"projects"`
}
type GCPProjectLabels struct {
GenerativeLanguage string `json:"generative-language"`
}
type GCPProjectProjects struct {
ProjectNumber string `json:"projectNumber"`
ProjectID string `json:"projectId"`
LifecycleState string `json:"lifecycleState"`
Name string `json:"name"`
Labels GCPProjectLabels `json:"labels"`
CreateTime time.Time `json:"createTime"`
}
type Content struct {
Role string `json:"role"`
Parts []Part `json:"parts"`
}
// Part represents a single part of a message's content.
type Part struct {
Text string `json:"text,omitempty"`
InlineData *InlineData `json:"inlineData,omitempty"`
FunctionCall *FunctionCall `json:"functionCall,omitempty"`
FunctionResponse *FunctionResponse `json:"functionResponse,omitempty"`
}
type InlineData struct {
MimeType string `json:"mime_type,omitempty"`
Data string `json:"data,omitempty"`
}
// FunctionCall represents a tool call requested by the model.
type FunctionCall struct {
Name string `json:"name"`
Args map[string]interface{} `json:"args"`
}
// FunctionResponse represents the result of a tool execution.
type FunctionResponse struct {
Name string `json:"name"`
Response map[string]interface{} `json:"response"`
}
// GenerateContentRequest is the request payload for the streamGenerateContent endpoint.
type GenerateContentRequest struct {
Contents []Content `json:"contents"`
Tools []ToolDeclaration `json:"tools,omitempty"`
GenerationConfig `json:"generationConfig"`
}
// GenerationConfig defines model generation parameters.
type GenerationConfig struct {
ThinkingConfig GenerationConfigThinkingConfig `json:"thinkingConfig,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
TopP float64 `json:"topP,omitempty"`
TopK float64 `json:"topK,omitempty"`
// Temperature, TopP, TopK, etc. can be added here.
}
type GenerationConfigThinkingConfig struct {
IncludeThoughts bool `json:"include_thoughts,omitempty"`
}
// ToolDeclaration is the structure for declaring tools to the API.
// For now, we'll assume a simple structure. A more complete implementation
// would mirror the OpenAPI schema definition.
type ToolDeclaration struct {
FunctionDeclarations []interface{} `json:"functionDeclarations"`
}
// Client is the main client for interacting with the CLI API.
type Client struct {
httpClient *http.Client
projectID string
RequestMutex sync.Mutex
Email string
}
// NewClient creates a new CLI API client.
func NewClient(httpClient *http.Client) *Client {
return &Client{
httpClient: httpClient,
}
}
// SetupUser performs the initial user onboarding and setup.
func (c *Client) SetupUser(ctx context.Context, email, projectID string) error {
c.Email = email
log.Info("Performing user onboarding...")
// 1. LoadCodeAssist
loadAssistReqBody := map[string]interface{}{
"metadata": getClientMetadata(),
}
if projectID != "" {
loadAssistReqBody["cloudaicompanionProject"] = projectID
}
var loadAssistResp map[string]interface{}
err := c.makeAPIRequest(ctx, "loadCodeAssist", "POST", loadAssistReqBody, &loadAssistResp)
if err != nil {
return fmt.Errorf("failed to load code assist: %w", err)
}
// a, _ := json.Marshal(&loadAssistResp)
// log.Debug(string(a))
// 2. OnboardUser
var onboardTierID = "legacy-tier"
if tiers, ok := loadAssistResp["allowedTiers"].([]interface{}); ok {
for _, t := range tiers {
if tier, tierOk := t.(map[string]interface{}); tierOk {
if isDefault, isDefaultOk := tier["isDefault"].(bool); isDefaultOk && isDefault {
if id, idOk := tier["id"].(string); idOk {
onboardTierID = id
break
}
}
}
}
}
onboardProjectID := projectID
if p, ok := loadAssistResp["cloudaicompanionProject"].(string); ok && p != "" {
onboardProjectID = p
}
onboardReqBody := map[string]interface{}{
"tierId": onboardTierID,
"metadata": getClientMetadata(),
}
if onboardProjectID != "" {
onboardReqBody["cloudaicompanionProject"] = onboardProjectID
} else {
return fmt.Errorf("failed to start user onboarding, need define a project id")
}
var lroResp map[string]interface{}
err = c.makeAPIRequest(ctx, "onboardUser", "POST", onboardReqBody, &lroResp)
if err != nil {
return fmt.Errorf("failed to start user onboarding: %w", err)
}
// a, _ = json.Marshal(&lroResp)
// log.Debug(string(a))
// 3. Poll Long-Running Operation (LRO)
if done, doneOk := lroResp["done"].(bool); doneOk && done {
if project, projectOk := lroResp["response"].(map[string]interface{})["cloudaicompanionProject"].(map[string]interface{}); projectOk {
c.projectID = project["id"].(string)
log.Infof("Onboarding complete. Using Project ID: %s", c.projectID)
return nil
}
}
return fmt.Errorf("failed to get operation name from onboarding response: %v", lroResp)
}
// makeAPIRequest handles making requests to the CLI API endpoints.
func (c *Client) makeAPIRequest(ctx context.Context, endpoint, method string, body interface{}, result interface{}) error {
var reqBody io.Reader
if body != nil {
jsonBody, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("failed to marshal request body: %w", err)
}
reqBody = bytes.NewBuffer(jsonBody)
}
url := fmt.Sprintf("%s/%s:%s", codeAssistEndpoint, apiVersion, endpoint)
if strings.HasPrefix(endpoint, "operations/") {
url = fmt.Sprintf("%s/%s", codeAssistEndpoint, endpoint)
}
req, err := http.NewRequestWithContext(ctx, method, url, reqBody)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
token, err := c.httpClient.Transport.(*oauth2.Transport).Source.Token()
if err != nil {
return fmt.Errorf("failed to get token: %w", err)
}
// Set headers
metadataStr := getClientMetadataString()
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", getUserAgent())
req.Header.Set("Client-Metadata", metadataStr)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to execute request: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("api request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
if result != nil {
if err = json.NewDecoder(resp.Body).Decode(result); err != nil {
return fmt.Errorf("failed to decode response body: %w", err)
}
}
return nil
}
// StreamAPIRequest handles making streaming requests to the CLI API endpoints.
func (c *Client) StreamAPIRequest(ctx context.Context, endpoint string, body interface{}) (io.ReadCloser, error) {
var jsonBody []byte
var err error
if byteBody, ok := body.([]byte); ok {
jsonBody = byteBody
} else {
jsonBody, err = json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
}
// log.Debug(string(jsonBody))
reqBody := bytes.NewBuffer(jsonBody)
// Add alt=sse for streaming
url := fmt.Sprintf("%s/%s:%s?alt=sse", codeAssistEndpoint, apiVersion, endpoint)
req, err := http.NewRequestWithContext(ctx, "POST", url, reqBody)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
token, err := c.httpClient.Transport.(*oauth2.Transport).Source.Token()
if err != nil {
return nil, fmt.Errorf("failed to get token: %w", err)
}
// Set headers
metadataStr := getClientMetadataString()
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", getUserAgent())
req.Header.Set("Client-Metadata", metadataStr)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
defer func() {
_ = resp.Body.Close()
}()
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("api streaming request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
return resp.Body, nil
}
// SendMessageStream handles a single conversational turn, including tool calls.
func (c *Client) SendMessageStream(ctx context.Context, rawJson []byte, model string, contents []Content, tools []ToolDeclaration) (<-chan []byte, <-chan error) {
dataTag := []byte("data: ")
errChan := make(chan error)
dataChan := make(chan []byte)
go func() {
defer close(errChan)
defer close(dataChan)
request := GenerateContentRequest{
Contents: contents,
GenerationConfig: GenerationConfig{
ThinkingConfig: GenerationConfigThinkingConfig{
IncludeThoughts: true,
},
},
}
request.Tools = tools
requestBody := map[string]interface{}{
"project": c.projectID, // Assuming ProjectID is available
"request": request,
"model": model,
}
byteRequestBody, _ := json.Marshal(requestBody)
// log.Debug(string(rawJson))
reasoningEffortResult := gjson.GetBytes(rawJson, "reasoning_effort")
if reasoningEffortResult.String() == "none" {
byteRequestBody, _ = sjson.DeleteBytes(byteRequestBody, "request.generationConfig.thinkingConfig.include_thoughts")
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", 0)
} else if reasoningEffortResult.String() == "auto" {
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", -1)
} else if reasoningEffortResult.String() == "low" {
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", 1024)
} else if reasoningEffortResult.String() == "medium" {
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", 8192)
} else if reasoningEffortResult.String() == "high" {
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", 24576)
} else {
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", -1)
}
temperatureResult := gjson.GetBytes(rawJson, "temperature")
if temperatureResult.Exists() && temperatureResult.Type == gjson.Number {
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.temperature", temperatureResult.Num)
}
topPResult := gjson.GetBytes(rawJson, "top_p")
if topPResult.Exists() && topPResult.Type == gjson.Number {
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.topP", topPResult.Num)
}
topKResult := gjson.GetBytes(rawJson, "top_k")
if topKResult.Exists() && topKResult.Type == gjson.Number {
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.topK", topKResult.Num)
}
// log.Debug(string(byteRequestBody))
stream, err := c.StreamAPIRequest(ctx, "streamGenerateContent", byteRequestBody)
if err != nil {
// log.Println(err)
errChan <- err
return
}
scanner := bufio.NewScanner(stream)
for scanner.Scan() {
line := scanner.Bytes()
// log.Printf("Received stream chunk: %s", line)
if bytes.HasPrefix(line, dataTag) {
dataChan <- line[6:]
}
}
if err = scanner.Err(); err != nil {
// log.Println(err)
errChan <- err
_ = stream.Close()
return
}
_ = stream.Close()
}()
return dataChan, errChan
}
func (c *Client) GetProjectList(ctx context.Context) (*GCPProject, error) {
token, err := c.httpClient.Transport.(*oauth2.Transport).Source.Token()
req, err := http.NewRequestWithContext(ctx, "GET", "https://cloudresourcemanager.googleapis.com/v1/projects", nil)
if err != nil {
return nil, fmt.Errorf("could not get project list: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
bodyBytes, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("get user info request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
var project GCPProject
err = json.Unmarshal(bodyBytes, &project)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal project list: %w", err)
}
return &project, nil
}
// getClientMetadata returns metadata about the client environment.
func getClientMetadata() map[string]string {
return map[string]string{
"ideType": "IDE_UNSPECIFIED",
"platform": getPlatform(),
"pluginType": "GEMINI",
"pluginVersion": pluginVersion,
}
}
// getClientMetadataString returns the metadata as a comma-separated string.
func getClientMetadataString() string {
md := getClientMetadata()
parts := make([]string, 0, len(md))
for k, v := range md {
parts = append(parts, fmt.Sprintf("%s=%s", k, v))
}
return strings.Join(parts, ",")
}
func getUserAgent() string {
return fmt.Sprintf(fmt.Sprintf("GeminiCLI/%s (%s; %s)", pluginVersion, runtime.GOOS, runtime.GOARCH))
}
// getPlatform returns the OS and architecture in the format expected by the API.
func getPlatform() string {
os := runtime.GOOS
arch := runtime.GOARCH
switch os {
case "darwin":
return fmt.Sprintf("DARWIN_%s", strings.ToUpper(arch))
case "linux":
return fmt.Sprintf("LINUX_%s", strings.ToUpper(arch))
case "windows":
return fmt.Sprintf("WINDOWS_%s", strings.ToUpper(arch))
default:
return "PLATFORM_UNSPECIFIED"
}
}