Files
clawgo/pkg/providers/antigravity_provider.go

822 lines
26 KiB
Go

package providers
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
const (
antigravityDailyBaseURL = "https://daily-cloudcode-pa.googleapis.com"
antigravitySandboxBaseURL = "https://daily-cloudcode-pa.sandbox.googleapis.com"
antigravityProdBaseURL = "https://cloudcode-pa.googleapis.com"
)
type AntigravityProvider struct {
base *HTTPProvider
}
func NewAntigravityProvider(providerName, apiKey, apiBase, defaultModel string, supportsResponsesCompact bool, authMode string, timeout time.Duration, oauth *oauthManager) *AntigravityProvider {
normalizedBase := normalizeAPIBase(apiBase)
if normalizedBase == "" {
normalizedBase = antigravityDailyBaseURL
}
return &AntigravityProvider{
base: NewHTTPProvider(providerName, apiKey, normalizedBase, defaultModel, supportsResponsesCompact, authMode, timeout, oauth),
}
}
func (p *AntigravityProvider) GetDefaultModel() string {
if p == nil || p.base == nil {
return ""
}
return p.base.GetDefaultModel()
}
func (p *AntigravityProvider) CountTokens(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*UsageInfo, error) {
if p == nil || p.base == nil {
return nil, fmt.Errorf("provider not configured")
}
attempts, err := p.base.authAttempts(ctx)
if err != nil {
return nil, err
}
var lastBody []byte
var lastStatus int
var lastType string
for _, attempt := range attempts {
for _, baseURL := range p.baseURLs() {
requestBody := p.buildRequestBody(messages, tools, model, options, attempt.session, false)
delete(requestBody, "project")
delete(requestBody, "model")
request := mapFromAny(requestBody["request"])
delete(request, "safetySettings")
requestBody["request"] = request
body, status, ctype, reqErr := p.performCountTokensAttempt(ctx, p.countTokensEndpoint(baseURL), requestBody, attempt)
if reqErr != nil {
if strings.Contains(strings.ToLower(reqErr.Error()), "context canceled") || strings.Contains(strings.ToLower(reqErr.Error()), "deadline exceeded") {
return nil, reqErr
}
lastBody, lastStatus, lastType = nil, 0, ""
continue
}
lastBody, lastStatus, lastType = body, status, ctype
if status == http.StatusTooManyRequests {
continue
}
reason, retry := classifyOAuthFailure(status, body)
if retry {
if attempt.kind == "oauth" && attempt.session != nil && p.base.oauth != nil {
p.base.oauth.markExhausted(attempt.session, reason)
recordProviderOAuthError(p.base.providerName, attempt.session, reason)
}
if attempt.kind == "api_key" {
p.base.markAPIKeyFailure(reason)
}
break
}
if status != http.StatusOK {
return nil, fmt.Errorf("API error (status %d, content-type %q): %s", status, ctype, previewResponseBody(body))
}
var payload struct {
TotalTokens int `json:"totalTokens"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return nil, fmt.Errorf("invalid countTokens response: %w", err)
}
p.base.markAttemptSuccess(attempt)
return &UsageInfo{PromptTokens: payload.TotalTokens, TotalTokens: payload.TotalTokens}, nil
}
}
return nil, fmt.Errorf("API error (status %d, content-type %q): %s", lastStatus, lastType, previewResponseBody(lastBody))
}
func (p *AntigravityProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
body, status, ctype, err := p.doRequest(ctx, messages, tools, model, options, false, nil)
if err != nil {
return nil, err
}
if status != http.StatusOK {
return nil, fmt.Errorf("API error (status %d, content-type %q): %s", status, ctype, previewResponseBody(body))
}
if !json.Valid(body) {
return nil, fmt.Errorf("API error (status %d, content-type %q): non-JSON response: %s", status, ctype, previewResponseBody(body))
}
return parseAntigravityResponse(body)
}
func (p *AntigravityProvider) ChatStream(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}, onDelta func(string)) (*LLMResponse, error) {
body, status, ctype, err := p.doRequest(ctx, messages, tools, model, options, true, onDelta)
if err != nil {
return nil, err
}
if status != http.StatusOK {
return nil, fmt.Errorf("API error (status %d, content-type %q): %s", status, ctype, previewResponseBody(body))
}
if !json.Valid(body) {
return nil, fmt.Errorf("API error (status %d, content-type %q): non-JSON response: %s", status, ctype, previewResponseBody(body))
}
return parseAntigravityResponse(body)
}
func (p *AntigravityProvider) doRequest(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}, stream bool, onDelta func(string)) ([]byte, int, string, error) {
if p == nil || p.base == nil {
return nil, 0, "", fmt.Errorf("provider not configured")
}
attempts, err := p.base.authAttempts(ctx)
if err != nil {
return nil, 0, "", err
}
var lastBody []byte
var lastStatus int
var lastType string
for _, attempt := range attempts {
for _, baseURL := range p.baseURLs() {
requestBody := p.buildRequestBody(messages, tools, model, options, attempt.session, stream)
endpoint := p.endpoint(baseURL, stream)
for retryAttempt := 0; retryAttempt < 3; retryAttempt++ {
body, status, ctype, reqErr := p.performAttempt(ctx, endpoint, requestBody, attempt, stream, onDelta)
if reqErr != nil {
if strings.Contains(strings.ToLower(reqErr.Error()), "context canceled") || strings.Contains(strings.ToLower(reqErr.Error()), "deadline exceeded") {
return nil, 0, "", reqErr
}
lastBody, lastStatus, lastType = nil, 0, ""
break
}
lastBody, lastStatus, lastType = body, status, ctype
if antigravityShouldRetryNoCapacity(status, body) && retryAttempt < 2 {
if err := antigravityWait(ctx, antigravityNoCapacityRetryDelay(retryAttempt)); err != nil {
return nil, 0, "", err
}
continue
}
if status == http.StatusTooManyRequests || status == http.StatusServiceUnavailable || status == http.StatusBadGateway {
break
}
reason, retry := classifyOAuthFailure(status, body)
if retry {
if attempt.kind == "oauth" && attempt.session != nil && p.base.oauth != nil {
p.base.oauth.markExhausted(attempt.session, reason)
recordProviderOAuthError(p.base.providerName, attempt.session, reason)
}
if attempt.kind == "api_key" {
p.base.markAPIKeyFailure(reason)
}
break
}
p.base.markAttemptSuccess(attempt)
return body, status, ctype, nil
}
}
}
return lastBody, lastStatus, lastType, nil
}
func (p *AntigravityProvider) performAttempt(ctx context.Context, endpoint string, payload map[string]any, attempt authAttempt, stream bool, onDelta func(string)) ([]byte, int, string, error) {
jsonData, err := json.Marshal(payload)
if err != nil {
return nil, 0, "", fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(jsonData))
if err != nil {
return nil, 0, "", fmt.Errorf("failed to create request: %w", err)
}
req.Close = true
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", defaultAntigravityAPIUserAgent)
req.Header.Set("X-Goog-Api-Client", defaultAntigravityAPIClient)
req.Header.Set("Client-Metadata", defaultAntigravityClientMeta)
if stream {
req.Header.Set("Accept", "text/event-stream")
} else {
req.Header.Set("Accept", "application/json")
}
applyAttemptAuth(req, attempt)
client, err := p.base.httpClientForAttempt(attempt)
if err != nil {
return nil, 0, "", err
}
resp, err := client.Do(req)
if err != nil {
return nil, 0, "", fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
ctype := strings.TrimSpace(resp.Header.Get("Content-Type"))
if stream && strings.Contains(strings.ToLower(ctype), "text/event-stream") {
return consumeAntigravityStream(resp, onDelta)
}
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return nil, resp.StatusCode, ctype, fmt.Errorf("failed to read response: %w", readErr)
}
return body, resp.StatusCode, ctype, nil
}
func (p *AntigravityProvider) endpoint(baseURL string, stream bool) string {
base := normalizeAPIBase(baseURL)
if base == "" {
base = antigravityDailyBaseURL
}
path := "/" + defaultAntigravityAPIVersion + ":generateContent"
if stream {
path = "/" + defaultAntigravityAPIVersion + ":streamGenerateContent?alt=sse"
}
return base + path
}
func (p *AntigravityProvider) countTokensEndpoint(baseURL string) string {
base := normalizeAPIBase(baseURL)
if base == "" {
base = antigravityDailyBaseURL
}
return base + "/" + defaultAntigravityAPIVersion + ":countTokens"
}
func (p *AntigravityProvider) baseURLs() []string {
if p == nil || p.base == nil {
return []string{antigravityDailyBaseURL}
}
if custom := normalizeAPIBase(p.base.apiBase); custom != "" &&
!strings.Contains(strings.ToLower(custom), "api.openai.com") &&
custom != antigravityDailyBaseURL {
return []string{custom}
}
return []string{antigravityDailyBaseURL, antigravitySandboxBaseURL, antigravityProdBaseURL, defaultAntigravityAPIEndpoint}
}
func (p *AntigravityProvider) buildRequestBody(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}, session *oauthSession, stream bool) map[string]any {
request := map[string]any{}
baseModel := strings.TrimSpace(qwenBaseModel(model))
systemParts := make([]map[string]any, 0)
contents := make([]map[string]any, 0, len(messages))
callNames := map[string]string{}
for _, msg := range messages {
role := strings.ToLower(strings.TrimSpace(msg.Role))
switch role {
case "system", "developer":
if text := antigravityMessageText(msg); text != "" {
systemParts = append(systemParts, map[string]any{"text": text})
}
case "user":
if parts := antigravityTextParts(msg); len(parts) > 0 {
contents = append(contents, map[string]any{"role": "user", "parts": parts})
}
case "assistant":
parts := antigravityAssistantParts(msg)
for _, tc := range msg.ToolCalls {
name := strings.TrimSpace(tc.Name)
if tc.Function != nil && strings.TrimSpace(tc.Function.Name) != "" {
name = strings.TrimSpace(tc.Function.Name)
}
if name != "" && strings.TrimSpace(tc.ID) != "" {
callNames[strings.TrimSpace(tc.ID)] = name
}
}
if len(parts) > 0 {
contents = append(contents, map[string]any{"role": "model", "parts": parts})
}
case "tool":
if part := antigravityToolResponsePart(msg, callNames); part != nil {
contents = append(contents, map[string]any{"role": "function", "parts": []map[string]any{part}})
}
default:
if text := antigravityMessageText(msg); text != "" {
contents = append(contents, map[string]any{"role": "user", "parts": []map[string]any{{"text": text}}})
}
}
}
if len(systemParts) > 0 {
request["systemInstruction"] = map[string]any{"parts": systemParts}
}
if len(contents) > 0 {
request["contents"] = contents
}
if gen := antigravityGenerationConfig(options); len(gen) > 0 {
request["generationConfig"] = gen
}
if extra, ok := mapOption(options, "gemini_generation_config"); ok && len(extra) > 0 {
gen := mapFromAny(request["generationConfig"])
if gen == nil {
gen = map[string]any{}
}
for k, v := range extra {
gen[k] = v
}
request["generationConfig"] = gen
}
if toolDecls := antigravityToolDeclarations(tools); len(toolDecls) > 0 {
request["tools"] = []map[string]any{{"function_declarations": toolDecls}}
request["toolConfig"] = map[string]any{
"functionCallingConfig": map[string]any{"mode": "AUTO"},
}
}
projectID := ""
if session != nil {
projectID = firstNonEmpty(strings.TrimSpace(session.ProjectID), asString(session.Token["project_id"]), asString(session.Token["project-id"]), asString(session.Token["projectId"]), asString(session.Token["project"]))
}
if projectID == "" {
projectID = "default-project"
}
applyAntigravityThinkingSuffix(request, model)
requestType := "agent"
if strings.Contains(strings.ToLower(baseModel), "image") {
requestType = "image_gen"
}
return map[string]any{
"project": projectID,
"model": baseModel,
"userAgent": "antigravity",
"requestType": requestType,
"requestId": "agent-" + randomSessionID(),
"request": request,
}
}
func antigravityMessageText(msg Message) string {
parts := antigravityTextParts(msg)
if len(parts) == 0 {
return strings.TrimSpace(msg.Content)
}
lines := make([]string, 0, len(parts))
for _, part := range parts {
text := strings.TrimSpace(asString(part["text"]))
if text != "" {
lines = append(lines, text)
}
}
return strings.TrimSpace(strings.Join(lines, "\n"))
}
func antigravityTextParts(msg Message) []map[string]any {
if len(msg.ContentParts) == 0 {
if text := strings.TrimSpace(msg.Content); text != "" {
return []map[string]any{{"text": text}}
}
return nil
}
parts := make([]map[string]any, 0, len(msg.ContentParts))
for _, part := range msg.ContentParts {
switch strings.ToLower(strings.TrimSpace(part.Type)) {
case "", "text", "input_text":
if text := strings.TrimSpace(part.Text); text != "" {
parts = append(parts, map[string]any{"text": text})
}
}
}
if len(parts) == 0 && strings.TrimSpace(msg.Content) != "" {
return []map[string]any{{"text": strings.TrimSpace(msg.Content)}}
}
return parts
}
func antigravityAssistantParts(msg Message) []map[string]any {
parts := antigravityTextParts(msg)
for _, tc := range msg.ToolCalls {
name := strings.TrimSpace(tc.Name)
args := map[string]any{}
if tc.Function != nil {
if strings.TrimSpace(tc.Function.Name) != "" {
name = strings.TrimSpace(tc.Function.Name)
}
if strings.TrimSpace(tc.Function.Arguments) != "" {
_ = json.Unmarshal([]byte(tc.Function.Arguments), &args)
}
}
if len(args) == 0 && len(tc.Arguments) > 0 {
args = tc.Arguments
}
if name == "" {
continue
}
part := map[string]any{
"functionCall": map[string]any{
"name": name,
"args": args,
},
}
if strings.TrimSpace(tc.ID) != "" {
part["functionCall"].(map[string]any)["id"] = strings.TrimSpace(tc.ID)
}
parts = append(parts, part)
}
return parts
}
func antigravityToolResponsePart(msg Message, callNames map[string]string) map[string]any {
callID := strings.TrimSpace(msg.ToolCallID)
if callID == "" {
return nil
}
name := strings.TrimSpace(callNames[callID])
if name == "" {
name = "tool_result"
}
return map[string]any{
"functionResponse": map[string]any{
"name": name,
"id": callID,
"response": map[string]any{
"result": strings.TrimSpace(msg.Content),
},
},
}
}
func antigravityToolDeclarations(tools []ToolDefinition) []map[string]any {
out := make([]map[string]any, 0, len(tools))
for _, tool := range tools {
name := strings.TrimSpace(tool.Function.Name)
if name == "" {
name = strings.TrimSpace(tool.Name)
}
if name == "" {
continue
}
params := tool.Function.Parameters
if len(params) == 0 {
params = tool.Parameters
}
entry := map[string]any{
"name": name,
"description": strings.TrimSpace(firstNonEmpty(tool.Function.Description, tool.Description)),
"parametersJsonSchema": params,
}
if len(params) == 0 {
entry["parametersJsonSchema"] = map[string]any{"type": "object", "properties": map[string]any{}}
}
out = append(out, entry)
}
return out
}
func antigravityGenerationConfig(options map[string]any) map[string]any {
cfg := map[string]any{}
if maxTokens, ok := int64FromOption(options, "max_tokens"); ok {
cfg["maxOutputTokens"] = maxTokens
}
if temperature, ok := float64FromOption(options, "temperature"); ok {
cfg["temperature"] = temperature
}
return cfg
}
func applyAntigravityThinkingSuffix(request map[string]any, model string) {
suffix := qwenModelSuffix(model)
if suffix == "" {
return
}
baseModel := strings.TrimSpace(qwenBaseModel(model))
gen := mapFromAny(request["generationConfig"])
if gen == nil {
gen = map[string]any{}
}
thinkingConfig := mapFromAny(gen["thinkingConfig"])
if thinkingConfig == nil {
thinkingConfig = map[string]any{}
}
includeThoughts, userSetIncludeThoughts := geminiExistingIncludeThoughts(thinkingConfig)
delete(thinkingConfig, "thinkingBudget")
delete(thinkingConfig, "thinking_budget")
delete(thinkingConfig, "thinkingLevel")
delete(thinkingConfig, "thinking_level")
delete(thinkingConfig, "include_thoughts")
setIncludeThoughts := func(defaultValue bool, force bool) {
if force || !userSetIncludeThoughts {
includeThoughts = defaultValue
}
thinkingConfig["includeThoughts"] = includeThoughts
}
lower := strings.ToLower(strings.TrimSpace(suffix))
switch {
case lower == "auto" || lower == "-1":
thinkingConfig["thinkingBudget"] = -1
setIncludeThoughts(true, false)
case lower == "none" || lower == "0":
if geminiUsesThinkingLevels(baseModel) {
thinkingConfig["thinkingLevel"] = "low"
} else {
thinkingConfig["thinkingBudget"] = 128
}
setIncludeThoughts(false, true)
case isGeminiThinkingLevel(lower):
if geminiUsesThinkingLevels(baseModel) {
thinkingConfig["thinkingLevel"] = normalizeGeminiThinkingLevel(lower)
} else {
thinkingConfig["thinkingBudget"] = geminiThinkingBudgetForLevel(lower)
}
setIncludeThoughts(true, false)
default:
if budget, err := strconv.Atoi(lower); err == nil {
switch {
case budget < 0:
thinkingConfig["thinkingBudget"] = -1
setIncludeThoughts(true, false)
case budget == 0:
if geminiUsesThinkingLevels(baseModel) {
thinkingConfig["thinkingLevel"] = "low"
} else {
thinkingConfig["thinkingBudget"] = 128
}
setIncludeThoughts(false, true)
default:
thinkingConfig["thinkingBudget"] = budget
setIncludeThoughts(true, false)
}
}
}
if len(thinkingConfig) == 0 {
return
}
gen["thinkingConfig"] = thinkingConfig
request["generationConfig"] = gen
}
func consumeAntigravityStream(resp *http.Response, onDelta func(string)) ([]byte, int, string, error) {
if onDelta == nil {
onDelta = func(string) {}
}
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024)
var dataLines []string
state := &antigravityStreamState{}
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) == "" {
if len(dataLines) > 0 {
payload := strings.Join(dataLines, "\n")
dataLines = dataLines[:0]
if strings.TrimSpace(payload) != "" && strings.TrimSpace(payload) != "[DONE]" {
if delta := state.consume([]byte(payload)); delta != "" {
onDelta(delta)
}
}
}
continue
}
if strings.HasPrefix(line, "data:") {
dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:")))
}
}
if err := scanner.Err(); err != nil {
return nil, resp.StatusCode, strings.TrimSpace(resp.Header.Get("Content-Type")), fmt.Errorf("failed to read stream: %w", err)
}
return state.finalBody(), resp.StatusCode, strings.TrimSpace(resp.Header.Get("Content-Type")), nil
}
func (p *AntigravityProvider) performCountTokensAttempt(ctx context.Context, endpoint string, payload map[string]any, attempt authAttempt) ([]byte, int, string, error) {
jsonData, err := json.Marshal(payload)
if err != nil {
return nil, 0, "", fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(jsonData))
if err != nil {
return nil, 0, "", fmt.Errorf("failed to create request: %w", err)
}
req.Close = true
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", defaultAntigravityAPIUserAgent)
req.Header.Set("X-Goog-Api-Client", defaultAntigravityAPIClient)
req.Header.Set("Client-Metadata", defaultAntigravityClientMeta)
applyAttemptAuth(req, attempt)
client, err := p.base.httpClientForAttempt(attempt)
if err != nil {
return nil, 0, "", err
}
resp, err := client.Do(req)
if err != nil {
return nil, 0, "", fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return nil, resp.StatusCode, strings.TrimSpace(resp.Header.Get("Content-Type")), fmt.Errorf("failed to read response: %w", readErr)
}
return body, resp.StatusCode, strings.TrimSpace(resp.Header.Get("Content-Type")), nil
}
func antigravityShouldRetryNoCapacity(statusCode int, body []byte) bool {
if statusCode != http.StatusServiceUnavailable {
return false
}
return strings.Contains(strings.ToLower(string(body)), "no capacity available")
}
func antigravityNoCapacityRetryDelay(attempt int) time.Duration {
if attempt < 0 {
attempt = 0
}
delay := time.Duration(attempt+1) * 250 * time.Millisecond
if delay > 2*time.Second {
delay = 2 * time.Second
}
return delay
}
func antigravityWait(ctx context.Context, wait time.Duration) error {
if wait <= 0 {
return nil
}
timer := time.NewTimer(wait)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
type antigravityStreamState struct {
Text string
ToolCalls []ToolCall
FinishReason string
Usage *UsageInfo
}
func (s *antigravityStreamState) consume(payload []byte) string {
resp, err := parseAntigravityResponse(payload)
if err != nil {
return ""
}
delta := antigravityDeltaText(s.Text, resp.Content)
if resp.Content != "" {
if delta == resp.Content && strings.TrimSpace(s.Text) != "" && !strings.HasPrefix(resp.Content, s.Text) {
s.Text += delta
} else if resp.Content != s.Text {
s.Text = resp.Content
}
}
if len(resp.ToolCalls) > 0 {
s.ToolCalls = resp.ToolCalls
}
if strings.TrimSpace(resp.FinishReason) != "" {
s.FinishReason = resp.FinishReason
}
if resp.Usage != nil {
s.Usage = resp.Usage
}
return delta
}
func (s *antigravityStreamState) finalBody() []byte {
parts := make([]map[string]any, 0, 1+len(s.ToolCalls))
if strings.TrimSpace(s.Text) != "" {
parts = append(parts, map[string]any{"text": s.Text})
}
for _, tc := range s.ToolCalls {
args := map[string]any{}
if tc.Function != nil && strings.TrimSpace(tc.Function.Arguments) != "" {
_ = json.Unmarshal([]byte(tc.Function.Arguments), &args)
}
if len(args) == 0 && len(tc.Arguments) > 0 {
args = tc.Arguments
}
part := map[string]any{
"functionCall": map[string]any{
"name": tc.Name,
"args": args,
},
}
if strings.TrimSpace(tc.ID) != "" {
part["functionCall"].(map[string]any)["id"] = tc.ID
}
parts = append(parts, part)
}
root := map[string]any{
"response": map[string]any{
"candidates": []map[string]any{{
"content": map[string]any{"parts": parts},
}},
},
}
if strings.TrimSpace(s.FinishReason) != "" {
root["response"].(map[string]any)["candidates"].([]map[string]any)[0]["finishReason"] = s.FinishReason
}
if s.Usage != nil {
root["response"].(map[string]any)["usageMetadata"] = map[string]any{
"promptTokenCount": s.Usage.PromptTokens,
"candidatesTokenCount": s.Usage.CompletionTokens,
"totalTokenCount": s.Usage.TotalTokens,
}
}
raw, _ := json.Marshal(root)
return raw
}
func antigravityDeltaText(previous, current string) string {
if current == "" {
return ""
}
if previous == "" {
return current
}
if strings.HasPrefix(current, previous) {
return current[len(previous):]
}
return current
}
func parseAntigravityResponse(body []byte) (*LLMResponse, error) {
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return nil, fmt.Errorf("failed to unmarshal antigravity response: %w", err)
}
root := payload
if responseMap := mapFromAny(payload["response"]); len(responseMap) > 0 {
root = responseMap
}
candidatesRaw, _ := root["candidates"].([]any)
if len(candidatesRaw) == 0 {
return &LLMResponse{}, nil
}
first := mapFromAny(candidatesRaw[0])
content := mapFromAny(first["content"])
partsRaw, _ := content["parts"].([]any)
texts := make([]string, 0, len(partsRaw))
toolCalls := make([]ToolCall, 0)
for _, item := range partsRaw {
part := mapFromAny(item)
if asString(part["text"]) != "" && !strings.EqualFold(asString(part["thought"]), "true") {
texts = append(texts, asString(part["text"]))
}
functionCall := mapFromAny(part["functionCall"])
if len(functionCall) == 0 {
continue
}
args := map[string]any{}
if rawArgs, ok := functionCall["args"]; ok {
switch typed := rawArgs.(type) {
case map[string]any:
args = typed
case string:
_ = json.Unmarshal([]byte(typed), &args)
}
}
id := strings.TrimSpace(firstNonEmpty(asString(functionCall["id"]), asString(functionCall["call_id"])))
name := strings.TrimSpace(asString(functionCall["name"]))
argJSON, _ := json.Marshal(args)
toolCalls = append(toolCalls, ToolCall{
ID: id,
Name: name,
Function: &FunctionCall{
Name: name,
Arguments: string(argJSON),
},
Arguments: args,
})
}
finishReason := strings.TrimSpace(asString(first["finishReason"]))
if finishReason == "" || strings.EqualFold(finishReason, "completed") {
finishReason = "stop"
}
usageMeta := mapFromAny(root["usageMetadata"])
var usage *UsageInfo
if len(usageMeta) > 0 {
usage = &UsageInfo{
PromptTokens: intValue(usageMeta["promptTokenCount"]),
CompletionTokens: intValue(usageMeta["candidatesTokenCount"]),
TotalTokens: intValue(usageMeta["totalTokenCount"]),
}
if usage.PromptTokens == 0 && usage.CompletionTokens == 0 && usage.TotalTokens == 0 {
usage = nil
}
}
return &LLMResponse{
Content: strings.TrimSpace(strings.Join(texts, "\n")),
ToolCalls: toolCalls,
FinishReason: finishReason,
Usage: usage,
}, nil
}
func intValue(value any) int {
switch typed := value.(type) {
case int:
return typed
case int64:
return int(typed)
case float64:
return int(typed)
case json.Number:
if v, err := typed.Int64(); err == nil {
return int(v)
}
case string:
var num int
if _, err := fmt.Sscanf(strings.TrimSpace(typed), "%d", &num); err == nil {
return num
}
}
return 0
}