Add claude code support

This commit is contained in:
Luis Pater
2025-08-19 01:16:52 +08:00
parent c5cc238308
commit d58cc55cb2
40 changed files with 4429 additions and 185 deletions

View File

@@ -0,0 +1,281 @@
// Package gemini provides request translation functionality for Gemini to Anthropic API.
// It handles parsing and transforming Gemini API requests into Anthropic API format,
// extracting model information, system instructions, message contents, and tool declarations.
// The package performs JSON data transformation to ensure compatibility
// between Gemini API format and Anthropic API's expected format.
package gemini
import (
"crypto/rand"
"fmt"
"math/big"
"strings"
"github.com/luispater/CLIProxyAPI/internal/util"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// ConvertGeminiRequestToAnthropic parses and transforms a Gemini API request into Anthropic API format.
// It extracts the model name, system instruction, message contents, and tool declarations
// from the raw JSON request and returns them in the format expected by the Anthropic API.
func ConvertGeminiRequestToAnthropic(rawJSON []byte) string {
// Base Anthropic API template
out := `{"model":"","max_tokens":32000,"messages":[]}`
root := gjson.ParseBytes(rawJSON)
// Helper for generating tool call IDs in the form: toolu_<alphanum>
genToolCallID := func() string {
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
var b strings.Builder
// 24 chars random suffix
for i := 0; i < 24; i++ {
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
b.WriteByte(letters[n.Int64()])
}
return "toolu_" + b.String()
}
// FIFO queue to store tool call IDs for matching with tool results
// Gemini uses sequential pairing across possibly multiple in-flight
// functionCalls, so we keep a FIFO queue of generated tool IDs and
// consume them in order when functionResponses arrive.
var pendingToolIDs []string
// Model mapping
if v := root.Get("model"); v.Exists() {
modelName := v.String()
out, _ = sjson.Set(out, "model", modelName)
}
// Generation config
if genConfig := root.Get("generationConfig"); genConfig.Exists() {
if maxTokens := genConfig.Get("maxOutputTokens"); maxTokens.Exists() {
out, _ = sjson.Set(out, "max_tokens", maxTokens.Int())
}
if temp := genConfig.Get("temperature"); temp.Exists() {
out, _ = sjson.Set(out, "temperature", temp.Float())
}
if topP := genConfig.Get("topP"); topP.Exists() {
out, _ = sjson.Set(out, "top_p", topP.Float())
}
if stopSeqs := genConfig.Get("stopSequences"); stopSeqs.Exists() && stopSeqs.IsArray() {
var stopSequences []string
stopSeqs.ForEach(func(_, value gjson.Result) bool {
stopSequences = append(stopSequences, value.String())
return true
})
if len(stopSequences) > 0 {
out, _ = sjson.Set(out, "stop_sequences", stopSequences)
}
}
}
// System instruction -> system field
if sysInstr := root.Get("system_instruction"); sysInstr.Exists() {
if parts := sysInstr.Get("parts"); parts.Exists() && parts.IsArray() {
var systemText strings.Builder
parts.ForEach(func(_, part gjson.Result) bool {
if text := part.Get("text"); text.Exists() {
if systemText.Len() > 0 {
systemText.WriteString("\n")
}
systemText.WriteString(text.String())
}
return true
})
if systemText.Len() > 0 {
systemMessage := `{"role":"user","content":[{"type":"text","text":""}]}`
systemMessage, _ = sjson.Set(systemMessage, "content.0.text", systemText.String())
out, _ = sjson.SetRaw(out, "messages.-1", systemMessage)
}
}
}
// Contents -> messages
if contents := root.Get("contents"); contents.Exists() && contents.IsArray() {
contents.ForEach(func(_, content gjson.Result) bool {
role := content.Get("role").String()
if role == "model" {
role = "assistant"
}
if role == "function" {
role = "user"
}
// Create message
msg := `{"role":"","content":[]}`
msg, _ = sjson.Set(msg, "role", role)
if parts := content.Get("parts"); parts.Exists() && parts.IsArray() {
parts.ForEach(func(_, part gjson.Result) bool {
// Text content
if text := part.Get("text"); text.Exists() {
textContent := `{"type":"text","text":""}`
textContent, _ = sjson.Set(textContent, "text", text.String())
msg, _ = sjson.SetRaw(msg, "content.-1", textContent)
return true
}
// Function call (from model/assistant)
if fc := part.Get("functionCall"); fc.Exists() && role == "assistant" {
toolUse := `{"type":"tool_use","id":"","name":"","input":{}}`
// Generate a unique tool ID and enqueue it for later matching
// with the corresponding functionResponse
toolID := genToolCallID()
pendingToolIDs = append(pendingToolIDs, toolID)
toolUse, _ = sjson.Set(toolUse, "id", toolID)
if name := fc.Get("name"); name.Exists() {
toolUse, _ = sjson.Set(toolUse, "name", name.String())
}
if args := fc.Get("args"); args.Exists() {
toolUse, _ = sjson.SetRaw(toolUse, "input", args.Raw)
}
msg, _ = sjson.SetRaw(msg, "content.-1", toolUse)
return true
}
// Function response (from user)
if fr := part.Get("functionResponse"); fr.Exists() {
toolResult := `{"type":"tool_result","tool_use_id":"","content":""}`
// Attach the oldest queued tool_id to pair the response
// with its call. If the queue is empty, generate a new id.
var toolID string
if len(pendingToolIDs) > 0 {
toolID = pendingToolIDs[0]
// Pop the first element from the queue
pendingToolIDs = pendingToolIDs[1:]
} else {
// Fallback: generate new ID if no pending tool_use found
toolID = genToolCallID()
}
toolResult, _ = sjson.Set(toolResult, "tool_use_id", toolID)
// Extract result content
if result := fr.Get("response.result"); result.Exists() {
toolResult, _ = sjson.Set(toolResult, "content", result.String())
} else if response := fr.Get("response"); response.Exists() {
toolResult, _ = sjson.Set(toolResult, "content", response.Raw)
}
msg, _ = sjson.SetRaw(msg, "content.-1", toolResult)
return true
}
// Image content (inline_data)
if inlineData := part.Get("inline_data"); inlineData.Exists() {
imageContent := `{"type":"image","source":{"type":"base64","media_type":"","data":""}}`
if mimeType := inlineData.Get("mime_type"); mimeType.Exists() {
imageContent, _ = sjson.Set(imageContent, "source.media_type", mimeType.String())
}
if data := inlineData.Get("data"); data.Exists() {
imageContent, _ = sjson.Set(imageContent, "source.data", data.String())
}
msg, _ = sjson.SetRaw(msg, "content.-1", imageContent)
return true
}
// File data
if fileData := part.Get("file_data"); fileData.Exists() {
// For file data, we'll convert to text content with file info
textContent := `{"type":"text","text":""}`
fileInfo := "File: " + fileData.Get("file_uri").String()
if mimeType := fileData.Get("mime_type"); mimeType.Exists() {
fileInfo += " (Type: " + mimeType.String() + ")"
}
textContent, _ = sjson.Set(textContent, "text", fileInfo)
msg, _ = sjson.SetRaw(msg, "content.-1", textContent)
return true
}
return true
})
}
// Only add message if it has content
if contentArray := gjson.Get(msg, "content"); contentArray.Exists() && len(contentArray.Array()) > 0 {
out, _ = sjson.SetRaw(out, "messages.-1", msg)
}
return true
})
}
// Tools mapping: Gemini functionDeclarations -> Anthropic tools
if tools := root.Get("tools"); tools.Exists() && tools.IsArray() {
var anthropicTools []interface{}
tools.ForEach(func(_, tool gjson.Result) bool {
if funcDecls := tool.Get("functionDeclarations"); funcDecls.Exists() && funcDecls.IsArray() {
funcDecls.ForEach(func(_, funcDecl gjson.Result) bool {
anthropicTool := `"name":"","description":"","input_schema":{}}`
if name := funcDecl.Get("name"); name.Exists() {
anthropicTool, _ = sjson.Set(anthropicTool, "name", name.String())
}
if desc := funcDecl.Get("description"); desc.Exists() {
anthropicTool, _ = sjson.Set(anthropicTool, "description", desc.String())
}
if params := funcDecl.Get("parameters"); params.Exists() {
// Clean up the parameters schema
cleaned := params.Raw
cleaned, _ = sjson.Set(cleaned, "additionalProperties", false)
cleaned, _ = sjson.Set(cleaned, "$schema", "http://json-schema.org/draft-07/schema#")
anthropicTool, _ = sjson.SetRaw(anthropicTool, "input_schema", cleaned)
} else if params = funcDecl.Get("parametersJsonSchema"); params.Exists() {
// Clean up the parameters schema
cleaned := params.Raw
cleaned, _ = sjson.Set(cleaned, "additionalProperties", false)
cleaned, _ = sjson.Set(cleaned, "$schema", "http://json-schema.org/draft-07/schema#")
anthropicTool, _ = sjson.SetRaw(anthropicTool, "input_schema", cleaned)
}
anthropicTools = append(anthropicTools, gjson.Parse(anthropicTool).Value())
return true
})
}
return true
})
if len(anthropicTools) > 0 {
out, _ = sjson.Set(out, "tools", anthropicTools)
}
}
// Tool config
if toolConfig := root.Get("tool_config"); toolConfig.Exists() {
if funcCalling := toolConfig.Get("function_calling_config"); funcCalling.Exists() {
if mode := funcCalling.Get("mode"); mode.Exists() {
switch mode.String() {
case "AUTO":
out, _ = sjson.Set(out, "tool_choice", map[string]interface{}{"type": "auto"})
case "NONE":
out, _ = sjson.Set(out, "tool_choice", map[string]interface{}{"type": "none"})
case "ANY":
out, _ = sjson.Set(out, "tool_choice", map[string]interface{}{"type": "any"})
}
}
}
}
// Stream setting
if stream := root.Get("stream"); stream.Exists() {
out, _ = sjson.Set(out, "stream", stream.Bool())
} else {
out, _ = sjson.Set(out, "stream", false)
}
var pathsToLower []string
toolsResult := gjson.Get(out, "tools")
util.Walk(toolsResult, "", "type", &pathsToLower)
for _, p := range pathsToLower {
fullPath := fmt.Sprintf("tools.%s", p)
out, _ = sjson.Set(out, fullPath, strings.ToLower(gjson.Get(out, fullPath).String()))
}
return out
}

View File

@@ -0,0 +1,555 @@
// Package gemini provides response translation functionality for Anthropic to Gemini API.
// This package handles the conversion of Anthropic API responses into Gemini-compatible
// JSON format, transforming streaming events and non-streaming responses into the format
// expected by Gemini API clients. It supports both streaming and non-streaming modes,
// handling text content, tool calls, and usage metadata appropriately.
package gemini
import (
"strings"
"time"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// ConvertAnthropicResponseToGeminiParams holds parameters for response conversion
// It also carries minimal streaming state across calls to assemble tool_use input_json_delta.
type ConvertAnthropicResponseToGeminiParams struct {
Model string
CreatedAt int64
ResponseID string
LastStorageOutput string
IsStreaming bool
// Streaming state for tool_use assembly
// Keyed by content_block index from Claude SSE events
ToolUseNames map[int]string // function/tool name per block index
ToolUseArgs map[int]*strings.Builder // accumulates partial_json across deltas
}
// ConvertAnthropicResponseToGemini converts Anthropic streaming response format to Gemini format.
// This function processes various Anthropic event types and transforms them into Gemini-compatible JSON responses.
// It handles text content, tool calls, and usage metadata, outputting responses that match the Gemini API format.
func ConvertAnthropicResponseToGemini(rawJSON []byte, param *ConvertAnthropicResponseToGeminiParams) []string {
root := gjson.ParseBytes(rawJSON)
eventType := root.Get("type").String()
// Base Gemini response template
template := `{"candidates":[{"content":{"role":"model","parts":[]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"","createTime":"","responseId":""}`
// Set model version
if param.Model != "" {
// Map Claude model names back to Gemini model names
template, _ = sjson.Set(template, "modelVersion", param.Model)
}
// Set response ID and creation time
if param.ResponseID != "" {
template, _ = sjson.Set(template, "responseId", param.ResponseID)
}
// Set creation time to current time if not provided
if param.CreatedAt == 0 {
param.CreatedAt = time.Now().Unix()
}
template, _ = sjson.Set(template, "createTime", time.Unix(param.CreatedAt, 0).Format(time.RFC3339Nano))
switch eventType {
case "message_start":
// Initialize response with message metadata
if message := root.Get("message"); message.Exists() {
param.ResponseID = message.Get("id").String()
param.Model = message.Get("model").String()
template, _ = sjson.Set(template, "responseId", param.ResponseID)
template, _ = sjson.Set(template, "modelVersion", param.Model)
}
return []string{template}
case "content_block_start":
// Start of a content block - record tool_use name by index for functionCall
if cb := root.Get("content_block"); cb.Exists() {
if cb.Get("type").String() == "tool_use" {
idx := int(root.Get("index").Int())
if param.ToolUseNames == nil {
param.ToolUseNames = map[int]string{}
}
if name := cb.Get("name"); name.Exists() {
param.ToolUseNames[idx] = name.String()
}
}
}
return []string{template}
case "content_block_delta":
// Handle content delta (text, thinking, or tool use)
if delta := root.Get("delta"); delta.Exists() {
deltaType := delta.Get("type").String()
switch deltaType {
case "text_delta":
// Regular text content delta
if text := delta.Get("text"); text.Exists() && text.String() != "" {
textPart := `{"text":""}`
textPart, _ = sjson.Set(textPart, "text", text.String())
template, _ = sjson.SetRaw(template, "candidates.0.content.parts.-1", textPart)
}
case "thinking_delta":
// Thinking/reasoning content delta
if text := delta.Get("text"); text.Exists() && text.String() != "" {
thinkingPart := `{"thought":true,"text":""}`
thinkingPart, _ = sjson.Set(thinkingPart, "text", text.String())
template, _ = sjson.SetRaw(template, "candidates.0.content.parts.-1", thinkingPart)
}
case "input_json_delta":
// Tool use input delta - accumulate partial_json by index for later assembly at content_block_stop
idx := int(root.Get("index").Int())
if param.ToolUseArgs == nil {
param.ToolUseArgs = map[int]*strings.Builder{}
}
b, ok := param.ToolUseArgs[idx]
if !ok || b == nil {
bb := &strings.Builder{}
param.ToolUseArgs[idx] = bb
b = bb
}
if pj := delta.Get("partial_json"); pj.Exists() {
b.WriteString(pj.String())
}
return []string{}
}
}
return []string{template}
case "content_block_stop":
// End of content block - finalize tool calls if any
idx := int(root.Get("index").Int())
// Claude's content_block_stop often doesn't include content_block payload (see docs/response-claude.txt)
// So we finalize using accumulated state captured during content_block_start and input_json_delta.
name := ""
if param.ToolUseNames != nil {
name = param.ToolUseNames[idx]
}
var argsTrim string
if param.ToolUseArgs != nil {
if b := param.ToolUseArgs[idx]; b != nil {
argsTrim = strings.TrimSpace(b.String())
}
}
if name != "" || argsTrim != "" {
functionCall := `{"functionCall":{"name":"","args":{}}}`
if name != "" {
functionCall, _ = sjson.Set(functionCall, "functionCall.name", name)
}
if argsTrim != "" {
functionCall, _ = sjson.SetRaw(functionCall, "functionCall.args", argsTrim)
}
template, _ = sjson.SetRaw(template, "candidates.0.content.parts.-1", functionCall)
template, _ = sjson.Set(template, "candidates.0.finishReason", "STOP")
param.LastStorageOutput = template
// cleanup used state for this index
if param.ToolUseArgs != nil {
delete(param.ToolUseArgs, idx)
}
if param.ToolUseNames != nil {
delete(param.ToolUseNames, idx)
}
return []string{template}
}
return []string{}
case "message_delta":
// Handle message-level changes (like stop reason)
if delta := root.Get("delta"); delta.Exists() {
if stopReason := delta.Get("stop_reason"); stopReason.Exists() {
switch stopReason.String() {
case "end_turn":
template, _ = sjson.Set(template, "candidates.0.finishReason", "STOP")
case "tool_use":
template, _ = sjson.Set(template, "candidates.0.finishReason", "STOP")
case "max_tokens":
template, _ = sjson.Set(template, "candidates.0.finishReason", "MAX_TOKENS")
case "stop_sequence":
template, _ = sjson.Set(template, "candidates.0.finishReason", "STOP")
default:
template, _ = sjson.Set(template, "candidates.0.finishReason", "STOP")
}
}
}
if usage := root.Get("usage"); usage.Exists() {
// Basic token counts
inputTokens := usage.Get("input_tokens").Int()
outputTokens := usage.Get("output_tokens").Int()
// Set basic usage metadata according to Gemini API specification
template, _ = sjson.Set(template, "usageMetadata.promptTokenCount", inputTokens)
template, _ = sjson.Set(template, "usageMetadata.candidatesTokenCount", outputTokens)
template, _ = sjson.Set(template, "usageMetadata.totalTokenCount", inputTokens+outputTokens)
// Add cache-related token counts if present (Anthropic API cache fields)
if cacheCreationTokens := usage.Get("cache_creation_input_tokens"); cacheCreationTokens.Exists() {
template, _ = sjson.Set(template, "usageMetadata.cachedContentTokenCount", cacheCreationTokens.Int())
}
if cacheReadTokens := usage.Get("cache_read_input_tokens"); cacheReadTokens.Exists() {
// Add cache read tokens to cached content count
existingCacheTokens := usage.Get("cache_creation_input_tokens").Int()
totalCacheTokens := existingCacheTokens + cacheReadTokens.Int()
template, _ = sjson.Set(template, "usageMetadata.cachedContentTokenCount", totalCacheTokens)
}
// Add thinking tokens if present (for models with reasoning capabilities)
if thinkingTokens := usage.Get("thinking_tokens"); thinkingTokens.Exists() {
template, _ = sjson.Set(template, "usageMetadata.thoughtsTokenCount", thinkingTokens.Int())
}
// Set traffic type (required by Gemini API)
template, _ = sjson.Set(template, "usageMetadata.trafficType", "PROVISIONED_THROUGHPUT")
}
template, _ = sjson.Set(template, "candidates.0.finishReason", "STOP")
return []string{template}
case "message_stop":
// Final message with usage information
return []string{}
case "error":
// Handle error responses
errorMsg := root.Get("error.message").String()
if errorMsg == "" {
errorMsg = "Unknown error occurred"
}
// Create error response in Gemini format
errorResponse := `{"error":{"code":400,"message":"","status":"INVALID_ARGUMENT"}}`
errorResponse, _ = sjson.Set(errorResponse, "error.message", errorMsg)
return []string{errorResponse}
default:
// Unknown event type, return empty
return []string{}
}
}
// ConvertAnthropicResponseToGeminiNonStream converts Anthropic streaming events to a single Gemini non-streaming response.
// This function processes multiple Anthropic streaming events and aggregates them into a complete
// Gemini-compatible JSON response that includes all content parts (including thinking/reasoning),
// function calls, and usage metadata. It simulates the streaming process internally but returns
// a single consolidated response.
func ConvertAnthropicResponseToGeminiNonStream(streamingEvents [][]byte, model string) string {
// Base Gemini response template for non-streaming
template := `{"candidates":[{"content":{"role":"model","parts":[]},"finishReason":"STOP"}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"","createTime":"","responseId":""}`
// Set model version
template, _ = sjson.Set(template, "modelVersion", model)
// Initialize parameters for streaming conversion
param := &ConvertAnthropicResponseToGeminiParams{
Model: model,
IsStreaming: false,
}
// Process each streaming event and collect parts
var allParts []interface{}
var finalUsage map[string]interface{}
var responseID string
var createdAt int64
for _, eventData := range streamingEvents {
if len(eventData) == 0 {
continue
}
root := gjson.ParseBytes(eventData)
eventType := root.Get("type").String()
switch eventType {
case "message_start":
// Extract response metadata
if message := root.Get("message"); message.Exists() {
responseID = message.Get("id").String()
param.ResponseID = responseID
param.Model = message.Get("model").String()
// Set creation time to current time if not provided
createdAt = time.Now().Unix()
param.CreatedAt = createdAt
}
case "content_block_start":
// Prepare for content block; record tool_use name by index for later functionCall assembly
idx := int(root.Get("index").Int())
if cb := root.Get("content_block"); cb.Exists() {
if cb.Get("type").String() == "tool_use" {
if param.ToolUseNames == nil {
param.ToolUseNames = map[int]string{}
}
if name := cb.Get("name"); name.Exists() {
param.ToolUseNames[idx] = name.String()
}
}
}
continue
case "content_block_delta":
// Handle content delta (text, thinking, or tool input)
if delta := root.Get("delta"); delta.Exists() {
deltaType := delta.Get("type").String()
switch deltaType {
case "text_delta":
if text := delta.Get("text"); text.Exists() && text.String() != "" {
partJSON := `{"text":""}`
partJSON, _ = sjson.Set(partJSON, "text", text.String())
part := gjson.Parse(partJSON).Value().(map[string]interface{})
allParts = append(allParts, part)
}
case "thinking_delta":
if text := delta.Get("text"); text.Exists() && text.String() != "" {
partJSON := `{"thought":true,"text":""}`
partJSON, _ = sjson.Set(partJSON, "text", text.String())
part := gjson.Parse(partJSON).Value().(map[string]interface{})
allParts = append(allParts, part)
}
case "input_json_delta":
// accumulate args partial_json for this index
idx := int(root.Get("index").Int())
if param.ToolUseArgs == nil {
param.ToolUseArgs = map[int]*strings.Builder{}
}
if _, ok := param.ToolUseArgs[idx]; !ok || param.ToolUseArgs[idx] == nil {
param.ToolUseArgs[idx] = &strings.Builder{}
}
if pj := delta.Get("partial_json"); pj.Exists() {
param.ToolUseArgs[idx].WriteString(pj.String())
}
}
}
case "content_block_stop":
// Handle tool use completion
idx := int(root.Get("index").Int())
// Claude's content_block_stop often doesn't include content_block payload (see docs/response-claude.txt)
// So we finalize using accumulated state captured during content_block_start and input_json_delta.
name := ""
if param.ToolUseNames != nil {
name = param.ToolUseNames[idx]
}
var argsTrim string
if param.ToolUseArgs != nil {
if b := param.ToolUseArgs[idx]; b != nil {
argsTrim = strings.TrimSpace(b.String())
}
}
if name != "" || argsTrim != "" {
functionCallJSON := `{"functionCall":{"name":"","args":{}}}`
if name != "" {
functionCallJSON, _ = sjson.Set(functionCallJSON, "functionCall.name", name)
}
if argsTrim != "" {
functionCallJSON, _ = sjson.SetRaw(functionCallJSON, "functionCall.args", argsTrim)
}
// Parse back to interface{} for allParts
functionCall := gjson.Parse(functionCallJSON).Value().(map[string]interface{})
allParts = append(allParts, functionCall)
// cleanup used state for this index
if param.ToolUseArgs != nil {
delete(param.ToolUseArgs, idx)
}
if param.ToolUseNames != nil {
delete(param.ToolUseNames, idx)
}
}
case "message_delta":
// Extract final usage information using sjson
if usage := root.Get("usage"); usage.Exists() {
usageJSON := `{}`
// Basic token counts
inputTokens := usage.Get("input_tokens").Int()
outputTokens := usage.Get("output_tokens").Int()
// Set basic usage metadata according to Gemini API specification
usageJSON, _ = sjson.Set(usageJSON, "promptTokenCount", inputTokens)
usageJSON, _ = sjson.Set(usageJSON, "candidatesTokenCount", outputTokens)
usageJSON, _ = sjson.Set(usageJSON, "totalTokenCount", inputTokens+outputTokens)
// Add cache-related token counts if present (Anthropic API cache fields)
if cacheCreationTokens := usage.Get("cache_creation_input_tokens"); cacheCreationTokens.Exists() {
usageJSON, _ = sjson.Set(usageJSON, "cachedContentTokenCount", cacheCreationTokens.Int())
}
if cacheReadTokens := usage.Get("cache_read_input_tokens"); cacheReadTokens.Exists() {
// Add cache read tokens to cached content count
existingCacheTokens := usage.Get("cache_creation_input_tokens").Int()
totalCacheTokens := existingCacheTokens + cacheReadTokens.Int()
usageJSON, _ = sjson.Set(usageJSON, "cachedContentTokenCount", totalCacheTokens)
}
// Add thinking tokens if present (for models with reasoning capabilities)
if thinkingTokens := usage.Get("thinking_tokens"); thinkingTokens.Exists() {
usageJSON, _ = sjson.Set(usageJSON, "thoughtsTokenCount", thinkingTokens.Int())
}
// Set traffic type (required by Gemini API)
usageJSON, _ = sjson.Set(usageJSON, "trafficType", "PROVISIONED_THROUGHPUT")
// Convert to map[string]interface{} using gjson
finalUsage = gjson.Parse(usageJSON).Value().(map[string]interface{})
}
}
}
// Set response metadata
if responseID != "" {
template, _ = sjson.Set(template, "responseId", responseID)
}
if createdAt > 0 {
template, _ = sjson.Set(template, "createTime", time.Unix(createdAt, 0).Format(time.RFC3339Nano))
}
// Consolidate consecutive text parts and thinking parts
consolidatedParts := consolidateParts(allParts)
// Set the consolidated parts array
if len(consolidatedParts) > 0 {
template, _ = sjson.SetRaw(template, "candidates.0.content.parts", convertToJSONString(consolidatedParts))
}
// Set usage metadata
if finalUsage != nil {
template, _ = sjson.SetRaw(template, "usageMetadata", convertToJSONString(finalUsage))
}
return template
}
// consolidateParts merges consecutive text parts and thinking parts to create a cleaner response
func consolidateParts(parts []interface{}) []interface{} {
if len(parts) == 0 {
return parts
}
var consolidated []interface{}
var currentTextPart strings.Builder
var currentThoughtPart strings.Builder
var hasText, hasThought bool
flushText := func() {
if hasText && currentTextPart.Len() > 0 {
textPartJSON := `{"text":""}`
textPartJSON, _ = sjson.Set(textPartJSON, "text", currentTextPart.String())
textPart := gjson.Parse(textPartJSON).Value().(map[string]interface{})
consolidated = append(consolidated, textPart)
currentTextPart.Reset()
hasText = false
}
}
flushThought := func() {
if hasThought && currentThoughtPart.Len() > 0 {
thoughtPartJSON := `{"thought":true,"text":""}`
thoughtPartJSON, _ = sjson.Set(thoughtPartJSON, "text", currentThoughtPart.String())
thoughtPart := gjson.Parse(thoughtPartJSON).Value().(map[string]interface{})
consolidated = append(consolidated, thoughtPart)
currentThoughtPart.Reset()
hasThought = false
}
}
for _, part := range parts {
partMap, ok := part.(map[string]interface{})
if !ok {
// Flush any pending parts and add this non-text part
flushText()
flushThought()
consolidated = append(consolidated, part)
continue
}
if thought, isThought := partMap["thought"]; isThought && thought == true {
// This is a thinking part
flushText() // Flush any pending text first
if text, hasTextContent := partMap["text"].(string); hasTextContent {
currentThoughtPart.WriteString(text)
hasThought = true
}
} else if text, hasTextContent := partMap["text"].(string); hasTextContent {
// This is a regular text part
flushThought() // Flush any pending thought first
currentTextPart.WriteString(text)
hasText = true
} else {
// This is some other type of part (like function call)
flushText()
flushThought()
consolidated = append(consolidated, part)
}
}
// Flush any remaining parts
flushThought() // Flush thought first to maintain order
flushText()
return consolidated
}
// convertToJSONString converts interface{} to JSON string using sjson/gjson
func convertToJSONString(v interface{}) string {
switch val := v.(type) {
case []interface{}:
return convertArrayToJSON(val)
case map[string]interface{}:
return convertMapToJSON(val)
default:
// For simple types, create a temporary JSON and extract the value
temp := `{"temp":null}`
temp, _ = sjson.Set(temp, "temp", val)
return gjson.Get(temp, "temp").Raw
}
}
// convertArrayToJSON converts []interface{} to JSON array string
func convertArrayToJSON(arr []interface{}) string {
result := "[]"
for _, item := range arr {
switch itemData := item.(type) {
case map[string]interface{}:
itemJSON := convertMapToJSON(itemData)
result, _ = sjson.SetRaw(result, "-1", itemJSON)
case string:
result, _ = sjson.Set(result, "-1", itemData)
case bool:
result, _ = sjson.Set(result, "-1", itemData)
case float64, int, int64:
result, _ = sjson.Set(result, "-1", itemData)
default:
result, _ = sjson.Set(result, "-1", itemData)
}
}
return result
}
// convertMapToJSON converts map[string]interface{} to JSON object string
func convertMapToJSON(m map[string]interface{}) string {
result := "{}"
for key, value := range m {
switch val := value.(type) {
case map[string]interface{}:
nestedJSON := convertMapToJSON(val)
result, _ = sjson.SetRaw(result, key, nestedJSON)
case []interface{}:
arrayJSON := convertArrayToJSON(val)
result, _ = sjson.SetRaw(result, key, arrayJSON)
case string:
result, _ = sjson.Set(result, key, val)
case bool:
result, _ = sjson.Set(result, key, val)
case float64, int, int64:
result, _ = sjson.Set(result, key, val)
default:
result, _ = sjson.Set(result, key, val)
}
}
return result
}