| package gemini |
|
|
| import ( |
| "fmt" |
| "strings" |
| ) |
|
|
| const maxGeminiRawPromptChars = 1024 |
|
|
| func geminiMessagesFromRequest(req map[string]any) []any { |
| out := make([]any, 0, 8) |
| toolCallCounter := 0 |
| nextToolCallID := func() string { |
| toolCallCounter++ |
| return fmt.Sprintf("call_gemini_%d", toolCallCounter) |
| } |
| lastToolCallIDByName := map[string]string{} |
| if sys := normalizeGeminiSystemInstruction(req["systemInstruction"]); strings.TrimSpace(sys) != "" { |
| out = append(out, map[string]any{ |
| "role": "system", |
| "content": sys, |
| }) |
| } |
|
|
| contents, _ := req["contents"].([]any) |
| for _, item := range contents { |
| content, ok := item.(map[string]any) |
| if !ok { |
| continue |
| } |
| role := mapGeminiRole(content["role"]) |
| if role == "" { |
| role = "user" |
| } |
| parts, _ := content["parts"].([]any) |
| if len(parts) == 0 { |
| if text := strings.TrimSpace(asString(content["text"])); text != "" { |
| out = append(out, map[string]any{ |
| "role": role, |
| "content": text, |
| }) |
| } |
| continue |
| } |
|
|
| textParts := make([]string, 0, len(parts)) |
| flushText := func() { |
| if len(textParts) == 0 { |
| return |
| } |
| out = append(out, map[string]any{ |
| "role": role, |
| "content": strings.Join(textParts, "\n"), |
| }) |
| textParts = textParts[:0] |
| } |
|
|
| for _, rawPart := range parts { |
| part, ok := rawPart.(map[string]any) |
| if !ok { |
| continue |
| } |
| if text := strings.TrimSpace(asString(part["text"])); text != "" { |
| textParts = append(textParts, text) |
| continue |
| } |
|
|
| if fnCall, ok := part["functionCall"].(map[string]any); ok { |
| flushText() |
| if name := strings.TrimSpace(asString(fnCall["name"])); name != "" { |
| callID := strings.TrimSpace(asString(fnCall["id"])) |
| if callID == "" { |
| if callID = strings.TrimSpace(asString(fnCall["call_id"])); callID == "" { |
| callID = nextToolCallID() |
| } |
| } |
| lastToolCallIDByName[strings.ToLower(name)] = callID |
| out = append(out, map[string]any{ |
| "role": "assistant", |
| "tool_calls": []any{ |
| map[string]any{ |
| "id": callID, |
| "type": "function", |
| "function": map[string]any{ |
| "name": name, |
| "arguments": stringifyJSON(fnCall["args"]), |
| }, |
| }, |
| }, |
| }) |
| } |
| continue |
| } |
|
|
| if fnResp, ok := part["functionResponse"].(map[string]any); ok { |
| flushText() |
| name := strings.TrimSpace(asString(fnResp["name"])) |
| callID := strings.TrimSpace(asString(fnResp["id"])) |
| if callID == "" { |
| callID = strings.TrimSpace(asString(fnResp["callId"])) |
| } |
| if callID == "" { |
| callID = strings.TrimSpace(asString(fnResp["tool_call_id"])) |
| } |
| if callID == "" { |
| callID = strings.TrimSpace(lastToolCallIDByName[strings.ToLower(name)]) |
| } |
| if callID == "" { |
| callID = nextToolCallID() |
| } |
| content := fnResp["response"] |
| if content == nil { |
| content = fnResp["output"] |
| } |
| if content == nil { |
| content = "" |
| } |
| msg := map[string]any{ |
| "role": "tool", |
| "tool_call_id": callID, |
| "content": content, |
| } |
| if name != "" { |
| msg["name"] = name |
| } |
| out = append(out, msg) |
| continue |
| } |
|
|
| if raw := strings.TrimSpace(formatGeminiUnknownPartForPrompt(part)); raw != "" && raw != "null" { |
| textParts = append(textParts, raw) |
| } |
| } |
| flushText() |
| } |
| return out |
| } |
|
|
| func normalizeGeminiSystemInstruction(raw any) string { |
| switch v := raw.(type) { |
| case string: |
| return strings.TrimSpace(v) |
| case map[string]any: |
| if parts, ok := v["parts"].([]any); ok { |
| texts := make([]string, 0, len(parts)) |
| for _, item := range parts { |
| part, ok := item.(map[string]any) |
| if !ok { |
| continue |
| } |
| if text := strings.TrimSpace(asString(part["text"])); text != "" { |
| texts = append(texts, text) |
| } |
| } |
| return strings.Join(texts, "\n") |
| } |
| if text := strings.TrimSpace(asString(v["text"])); text != "" { |
| return text |
| } |
| } |
| return "" |
| } |
|
|
| func mapGeminiRole(v any) string { |
| switch strings.ToLower(strings.TrimSpace(asString(v))) { |
| case "user": |
| return "user" |
| case "model", "assistant": |
| return "assistant" |
| case "system": |
| return "system" |
| default: |
| return "" |
| } |
| } |
|
|
| func formatGeminiUnknownPartForPrompt(part map[string]any) string { |
| safe := sanitizeGeminiPartForPrompt(part) |
| raw := strings.TrimSpace(stringifyJSON(safe)) |
| if raw == "" { |
| return "" |
| } |
| if len(raw) > maxGeminiRawPromptChars { |
| return raw[:maxGeminiRawPromptChars] + "...(truncated)" |
| } |
| return raw |
| } |
|
|
| func sanitizeGeminiPartForPrompt(part map[string]any) map[string]any { |
| out := make(map[string]any, len(part)) |
| for k, v := range part { |
| if looksLikeGeminiBinaryField(k) { |
| out[k] = "[omitted_binary_payload]" |
| continue |
| } |
| switch x := v.(type) { |
| case map[string]any: |
| out[k] = sanitizeGeminiPartForPrompt(x) |
| case []any: |
| out[k] = sanitizeGeminiArrayForPrompt(x) |
| case string: |
| out[k] = sanitizeGeminiStringForPrompt(k, x) |
| default: |
| out[k] = v |
| } |
| } |
| return out |
| } |
|
|
| func sanitizeGeminiArrayForPrompt(items []any) []any { |
| out := make([]any, 0, len(items)) |
| for _, item := range items { |
| switch x := item.(type) { |
| case map[string]any: |
| out = append(out, sanitizeGeminiPartForPrompt(x)) |
| case []any: |
| out = append(out, sanitizeGeminiArrayForPrompt(x)) |
| default: |
| out = append(out, x) |
| } |
| } |
| return out |
| } |
|
|
| func sanitizeGeminiStringForPrompt(key, value string) string { |
| trimmed := strings.TrimSpace(value) |
| if trimmed == "" { |
| return "" |
| } |
| if looksLikeGeminiBinaryField(key) || looksLikeGeminiBase64(trimmed) { |
| return "[omitted_binary_payload]" |
| } |
| if len(trimmed) > maxGeminiRawPromptChars { |
| return trimmed[:maxGeminiRawPromptChars] + "...(truncated)" |
| } |
| return trimmed |
| } |
|
|
| func looksLikeGeminiBinaryField(name string) bool { |
| n := strings.ToLower(strings.TrimSpace(name)) |
| return n == "data" || n == "bytes" || n == "inlinedata" || n == "inline_data" || n == "base64" |
| } |
|
|
| func looksLikeGeminiBase64(v string) bool { |
| if len(v) < 512 { |
| return false |
| } |
| compact := strings.TrimRight(v, "=") |
| if compact == "" { |
| return false |
| } |
| for _, ch := range compact { |
| if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '+' || ch == '/' || ch == '-' || ch == '_' { |
| continue |
| } |
| return false |
| } |
| return true |
| } |
|
|