mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
639b32703a
* Support streaming * fix: stream pico reasoning updates Route Pico reasoning through the active streamer and hide empty thought placeholders. * fix: harden configured streaming delivery * fix ci * fix split issue
928 lines
27 KiB
Go
928 lines
27 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestGeminiProvider_ChatSeparatesThoughtAndToolCall(t *testing.T) {
|
|
var capturedBody map[string]any
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
t.Fatalf("method = %s, want POST", r.Method)
|
|
}
|
|
if !strings.Contains(r.URL.Path, ":generateContent") {
|
|
t.Fatalf("path = %s, expected generateContent endpoint", r.URL.Path)
|
|
}
|
|
if got := r.Header.Get("X-Goog-Api-Key"); got != "test-key" {
|
|
t.Fatalf("X-Goog-Api-Key = %q, want %q", got, "test-key")
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&capturedBody); err != nil {
|
|
t.Fatalf("decode request body: %v", err)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"candidates": []any{
|
|
map[string]any{
|
|
"content": map[string]any{
|
|
"role": "model",
|
|
"parts": []any{
|
|
map[string]any{"text": "hidden", "thought": true},
|
|
map[string]any{"text": "visible"},
|
|
map[string]any{
|
|
"functionCall": map[string]any{
|
|
"id": "call_1",
|
|
"name": "search",
|
|
"args": map[string]any{"q": "hi"},
|
|
},
|
|
"thoughtSignature": "sig-1",
|
|
},
|
|
},
|
|
},
|
|
"finishReason": "STOP",
|
|
},
|
|
},
|
|
"usageMetadata": map[string]any{
|
|
"promptTokenCount": 2,
|
|
"candidatesTokenCount": 3,
|
|
"totalTokenCount": 5,
|
|
},
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
provider := NewGeminiProvider("test-key", server.URL, "", "picoclaw-test", 0, nil, nil)
|
|
resp, err := provider.Chat(
|
|
t.Context(),
|
|
[]Message{{Role: "user", Content: "hello"}},
|
|
nil,
|
|
"gemini-3-flash-preview",
|
|
map[string]any{"thinking_level": "high"},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Chat() error = %v", err)
|
|
}
|
|
if resp.Content != "visible" {
|
|
t.Fatalf("Content = %q, want %q", resp.Content, "visible")
|
|
}
|
|
if resp.ReasoningContent != "hidden" {
|
|
t.Fatalf("ReasoningContent = %q, want %q", resp.ReasoningContent, "hidden")
|
|
}
|
|
if resp.FinishReason != "tool_calls" {
|
|
t.Fatalf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls")
|
|
}
|
|
if resp.Usage == nil || resp.Usage.TotalTokens != 5 {
|
|
t.Fatalf("Usage = %#v, expected total tokens = 5", resp.Usage)
|
|
}
|
|
if len(resp.ToolCalls) != 1 {
|
|
t.Fatalf("ToolCalls len = %d, want 1", len(resp.ToolCalls))
|
|
}
|
|
if resp.ToolCalls[0].ID != "call_1" {
|
|
t.Fatalf("ToolCall ID = %q, want %q", resp.ToolCalls[0].ID, "call_1")
|
|
}
|
|
if resp.ToolCalls[0].Name != "search" {
|
|
t.Fatalf("ToolCall Name = %q, want %q", resp.ToolCalls[0].Name, "search")
|
|
}
|
|
if resp.ToolCalls[0].ThoughtSignature != "sig-1" {
|
|
t.Fatalf("ToolCall ThoughtSignature = %q, want %q", resp.ToolCalls[0].ThoughtSignature, "sig-1")
|
|
}
|
|
if resp.ToolCalls[0].Function == nil || !strings.Contains(resp.ToolCalls[0].Function.Arguments, `"q":"hi"`) {
|
|
t.Fatalf("ToolCall Function arguments = %#v, want q=hi", resp.ToolCalls[0].Function)
|
|
}
|
|
|
|
generationConfig, ok := capturedBody["generationConfig"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("request missing generationConfig: %#v", capturedBody)
|
|
}
|
|
thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("request missing thinkingConfig: %#v", generationConfig)
|
|
}
|
|
if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || !includeThoughts {
|
|
t.Fatalf("thinkingConfig.includeThoughts = %#v, want true", thinkingConfig["includeThoughts"])
|
|
}
|
|
if got := thinkingConfig["thinkingLevel"]; got != "high" {
|
|
t.Fatalf("thinkingConfig.thinkingLevel = %#v, want %q", got, "high")
|
|
}
|
|
}
|
|
|
|
func TestGeminiProvider_ChatStreamParsesThoughtTextAndToolCalls(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if !strings.Contains(r.URL.Path, ":streamGenerateContent") {
|
|
t.Fatalf("path = %s, expected streamGenerateContent endpoint", r.URL.Path)
|
|
}
|
|
if got := r.URL.Query().Get("alt"); got != "sse" {
|
|
t.Fatalf("alt query = %q, want %q", got, "sse")
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
t.Fatal("response writer is not flushable")
|
|
}
|
|
|
|
chunks := []map[string]any{
|
|
{
|
|
"candidates": []any{map[string]any{
|
|
"content": map[string]any{
|
|
"parts": []any{
|
|
map[string]any{"text": "think ", "thought": true},
|
|
map[string]any{"text": "Hello "},
|
|
},
|
|
},
|
|
}},
|
|
},
|
|
{
|
|
"candidates": []any{map[string]any{
|
|
"content": map[string]any{
|
|
"parts": []any{
|
|
map[string]any{"text": "World"},
|
|
map[string]any{
|
|
"functionCall": map[string]any{
|
|
"id": "call_stream",
|
|
"name": "search",
|
|
"args": map[string]any{"q": "stream"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"finishReason": "STOP",
|
|
}},
|
|
"usageMetadata": map[string]any{
|
|
"promptTokenCount": 1,
|
|
"candidatesTokenCount": 2,
|
|
"totalTokenCount": 3,
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, chunk := range chunks {
|
|
raw, err := json.Marshal(chunk)
|
|
if err != nil {
|
|
t.Fatalf("marshal chunk: %v", err)
|
|
}
|
|
if _, err := fmt.Fprintf(w, "data: %s\n\n", raw); err != nil {
|
|
t.Fatalf("write chunk: %v", err)
|
|
}
|
|
flusher.Flush()
|
|
}
|
|
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
|
|
flusher.Flush()
|
|
}))
|
|
defer server.Close()
|
|
|
|
provider := NewGeminiProvider("test-key", server.URL, "", "", 0, nil, nil)
|
|
updates := make([]string, 0)
|
|
resp, err := provider.ChatStream(
|
|
t.Context(),
|
|
[]Message{{Role: "user", Content: "hello"}},
|
|
nil,
|
|
"gemini-2.5-flash",
|
|
nil,
|
|
func(accumulated string) {
|
|
updates = append(updates, accumulated)
|
|
},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("ChatStream() error = %v", err)
|
|
}
|
|
if resp.Content != "Hello World" {
|
|
t.Fatalf("Content = %q, want %q", resp.Content, "Hello World")
|
|
}
|
|
if resp.ReasoningContent != "think " {
|
|
t.Fatalf("ReasoningContent = %q, want %q", resp.ReasoningContent, "think ")
|
|
}
|
|
if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].ID != "call_stream" {
|
|
t.Fatalf("ToolCalls = %#v, want single call_stream", resp.ToolCalls)
|
|
}
|
|
if resp.FinishReason != "tool_calls" {
|
|
t.Fatalf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls")
|
|
}
|
|
if resp.Usage == nil || resp.Usage.TotalTokens != 3 {
|
|
t.Fatalf("Usage = %#v, expected total tokens = 3", resp.Usage)
|
|
}
|
|
if len(updates) < 2 || updates[len(updates)-1] != "Hello World" {
|
|
t.Fatalf("stream updates = %#v, expected final accumulated text", updates)
|
|
}
|
|
}
|
|
|
|
func TestGeminiProvider_ChatStreamEventsStreamsThoughtBeforeContent(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if !strings.Contains(r.URL.Path, ":streamGenerateContent") {
|
|
t.Fatalf("path = %s, expected streamGenerateContent endpoint", r.URL.Path)
|
|
}
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
t.Fatal("response writer is not flushable")
|
|
}
|
|
|
|
chunk := map[string]any{
|
|
"candidates": []any{map[string]any{
|
|
"content": map[string]any{
|
|
"parts": []any{
|
|
map[string]any{"text": "think", "thought": true},
|
|
map[string]any{"text": "answer"},
|
|
},
|
|
},
|
|
"finishReason": "STOP",
|
|
}},
|
|
}
|
|
raw, err := json.Marshal(chunk)
|
|
if err != nil {
|
|
t.Fatalf("marshal chunk: %v", err)
|
|
}
|
|
_, _ = fmt.Fprintf(w, "data: %s\n\n", raw)
|
|
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
|
|
flusher.Flush()
|
|
}))
|
|
defer server.Close()
|
|
|
|
provider := NewGeminiProvider("test-key", server.URL, "", "", 0, nil, nil)
|
|
events := make([]string, 0)
|
|
resp, err := provider.ChatStreamEvents(
|
|
t.Context(),
|
|
[]Message{{Role: "user", Content: "hello"}},
|
|
nil,
|
|
"gemini-2.5-flash",
|
|
nil,
|
|
func(chunk StreamChunk) {
|
|
if chunk.ReasoningContent != "" {
|
|
events = append(events, "reasoning:"+chunk.ReasoningContent)
|
|
}
|
|
if chunk.Content != "" {
|
|
events = append(events, "content:"+chunk.Content)
|
|
}
|
|
},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("ChatStreamEvents() error = %v", err)
|
|
}
|
|
if resp.Content != "answer" {
|
|
t.Fatalf("Content = %q, want %q", resp.Content, "answer")
|
|
}
|
|
if resp.ReasoningContent != "think" {
|
|
t.Fatalf("ReasoningContent = %q, want %q", resp.ReasoningContent, "think")
|
|
}
|
|
want := []string{"reasoning:think", "content:answer"}
|
|
if len(events) != len(want) {
|
|
t.Fatalf("events = %#v, want %#v", events, want)
|
|
}
|
|
for i := range want {
|
|
if events[i] != want[i] {
|
|
t.Fatalf("events = %#v, want %#v", events, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
type geminiBlockingReadCloser struct {
|
|
closeOnce sync.Once
|
|
closed chan struct{}
|
|
}
|
|
|
|
func newGeminiBlockingReadCloser() *geminiBlockingReadCloser {
|
|
return &geminiBlockingReadCloser{closed: make(chan struct{})}
|
|
}
|
|
|
|
func (r *geminiBlockingReadCloser) Read([]byte) (int, error) {
|
|
<-r.closed
|
|
return 0, io.ErrClosedPipe
|
|
}
|
|
|
|
func (r *geminiBlockingReadCloser) Close() error {
|
|
r.closeOnce.Do(func() {
|
|
close(r.closed)
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func TestGeminiStreamingReadIdleTimeoutClosesStalledBody(t *testing.T) {
|
|
body := newGeminiBlockingReadCloser()
|
|
wrapped := withGeminiStreamingReadIdleTimeout(body, 10*time.Millisecond)
|
|
|
|
_, err := wrapped.Read(make([]byte, 1))
|
|
if err == nil {
|
|
t.Fatal("expected stalled stream read to return an error")
|
|
}
|
|
if !strings.Contains(err.Error(), "gemini stream idle timeout") {
|
|
t.Fatalf("error = %v, want gemini stream idle timeout", err)
|
|
}
|
|
}
|
|
|
|
func TestGeminiProvider_ChatStreamSkipsEmptyDataFrames(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
t.Fatal("response writer is not flushable")
|
|
}
|
|
|
|
_, _ = fmt.Fprint(w, "data: \n\n")
|
|
flusher.Flush()
|
|
|
|
chunk := map[string]any{
|
|
"candidates": []any{map[string]any{
|
|
"content": map[string]any{
|
|
"parts": []any{map[string]any{"text": "ok"}},
|
|
},
|
|
"finishReason": "STOP",
|
|
}},
|
|
}
|
|
raw, err := json.Marshal(chunk)
|
|
if err != nil {
|
|
t.Fatalf("marshal chunk: %v", err)
|
|
}
|
|
_, _ = fmt.Fprintf(w, "data: %s\n\n", raw)
|
|
flusher.Flush()
|
|
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
|
|
flusher.Flush()
|
|
}))
|
|
defer server.Close()
|
|
|
|
provider := NewGeminiProvider("test-key", server.URL, "", "", 0, nil, nil)
|
|
resp, err := provider.ChatStream(
|
|
t.Context(),
|
|
[]Message{{Role: "user", Content: "hello"}},
|
|
nil,
|
|
"gemini-2.5-flash",
|
|
nil,
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("ChatStream() error = %v", err)
|
|
}
|
|
if resp.Content != "ok" {
|
|
t.Fatalf("Content = %q, want %q", resp.Content, "ok")
|
|
}
|
|
}
|
|
|
|
func TestGeminiProvider_BuildRequestBody_PreservesComplexToolSchemasByDefault(t *testing.T) {
|
|
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
|
|
schema := map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"parent": map[string]any{
|
|
"anyOf": []any{
|
|
map[string]any{"$ref": "#/$defs/pageParent"},
|
|
map[string]any{"$ref": "#/$defs/databaseParent"},
|
|
},
|
|
},
|
|
},
|
|
"$defs": map[string]any{
|
|
"pageParent": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"page_id": map[string]any{"type": "string"},
|
|
},
|
|
"required": []any{"page_id"},
|
|
},
|
|
"databaseParent": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"database_id": map[string]any{"type": "string"},
|
|
},
|
|
"required": []any{"database_id"},
|
|
},
|
|
},
|
|
}
|
|
|
|
body := provider.buildRequestBody(
|
|
[]Message{{Role: "user", Content: "hello"}},
|
|
[]ToolDefinition{{
|
|
Type: "function",
|
|
Function: ToolFunctionDefinition{
|
|
Name: "mcp_notion_create",
|
|
Description: "Create a Notion object",
|
|
Parameters: schema,
|
|
},
|
|
}},
|
|
"gemini-3-flash-preview",
|
|
nil,
|
|
)
|
|
|
|
tools, ok := body["tools"].([]geminiTool)
|
|
if !ok || len(tools) != 1 {
|
|
t.Fatalf("tools = %#v, want one geminiTool", body["tools"])
|
|
}
|
|
got, ok := tools[0].FunctionDeclarations[0].Parameters.(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("parameters = %#v, want map", tools[0].FunctionDeclarations[0].Parameters)
|
|
}
|
|
|
|
if got["$defs"] == nil {
|
|
t.Fatalf("parameters = %#v, want raw schema with $defs preserved by default", got)
|
|
}
|
|
}
|
|
|
|
func TestGeminiProvider_ChatStreamReturnsErrorOnInvalidDataFrame(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
t.Fatal("response writer is not flushable")
|
|
}
|
|
|
|
_, _ = fmt.Fprint(w, "data: {invalid-json}\n\n")
|
|
flusher.Flush()
|
|
}))
|
|
defer server.Close()
|
|
|
|
provider := NewGeminiProvider("test-key", server.URL, "", "", 0, nil, nil)
|
|
_, err := provider.ChatStream(
|
|
t.Context(),
|
|
[]Message{{Role: "user", Content: "hello"}},
|
|
nil,
|
|
"gemini-2.5-flash",
|
|
nil,
|
|
nil,
|
|
)
|
|
if err == nil {
|
|
t.Fatal("ChatStream() expected error for invalid SSE data frame")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid gemini stream chunk") {
|
|
t.Fatalf("error = %v, want contains %q", err, "invalid gemini stream chunk")
|
|
}
|
|
}
|
|
|
|
func TestGeminiProvider_BuildRequestBody_UsesCamelCaseThoughtSignatureOnly(t *testing.T) {
|
|
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
|
|
|
|
body := provider.buildRequestBody(
|
|
[]Message{{
|
|
Role: "assistant",
|
|
ToolCalls: []ToolCall{{
|
|
ID: "call_1",
|
|
Name: "search",
|
|
Arguments: map[string]any{"q": "hello"},
|
|
Function: &FunctionCall{
|
|
Name: "search",
|
|
Arguments: `{"q":"hello"}`,
|
|
ThoughtSignature: "sig-1",
|
|
},
|
|
}},
|
|
}},
|
|
nil,
|
|
"gemini-2.5-flash",
|
|
nil,
|
|
)
|
|
|
|
raw, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatalf("marshal request body: %v", err)
|
|
}
|
|
jsonBody := string(raw)
|
|
|
|
if !strings.Contains(jsonBody, `"thoughtSignature":"sig-1"`) {
|
|
t.Fatalf("request body = %s, expected camelCase thoughtSignature", jsonBody)
|
|
}
|
|
if strings.Contains(jsonBody, `"thought_signature"`) {
|
|
t.Fatalf("request body = %s, unexpected snake_case thought_signature", jsonBody)
|
|
}
|
|
}
|
|
|
|
func TestGeminiProvider_ChatStreamCoalescesToolCallWithoutWireID(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
t.Fatal("response writer is not flushable")
|
|
}
|
|
|
|
chunks := []map[string]any{
|
|
{
|
|
"candidates": []any{map[string]any{
|
|
"content": map[string]any{
|
|
"parts": []any{
|
|
map[string]any{
|
|
"functionCall": map[string]any{
|
|
"name": "search",
|
|
"args": map[string]any{"q": "first"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}},
|
|
},
|
|
{
|
|
"candidates": []any{map[string]any{
|
|
"content": map[string]any{
|
|
"parts": []any{
|
|
map[string]any{
|
|
"functionCall": map[string]any{
|
|
"name": "search",
|
|
"args": map[string]any{"q": "second"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"finishReason": "STOP",
|
|
}},
|
|
},
|
|
}
|
|
|
|
for _, chunk := range chunks {
|
|
raw, err := json.Marshal(chunk)
|
|
if err != nil {
|
|
t.Fatalf("marshal chunk: %v", err)
|
|
}
|
|
if _, err := fmt.Fprintf(w, "data: %s\n\n", raw); err != nil {
|
|
t.Fatalf("write chunk: %v", err)
|
|
}
|
|
flusher.Flush()
|
|
}
|
|
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
|
|
flusher.Flush()
|
|
}))
|
|
defer server.Close()
|
|
|
|
provider := NewGeminiProvider("test-key", server.URL, "", "", 0, nil, nil)
|
|
resp, err := provider.ChatStream(
|
|
t.Context(),
|
|
[]Message{{Role: "user", Content: "hello"}},
|
|
nil,
|
|
"gemini-2.5-flash",
|
|
nil,
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("ChatStream() error = %v", err)
|
|
}
|
|
if len(resp.ToolCalls) != 1 {
|
|
t.Fatalf("ToolCalls len = %d, want 1", len(resp.ToolCalls))
|
|
}
|
|
tc := resp.ToolCalls[0]
|
|
if tc.ID != "search#1" {
|
|
t.Fatalf("ToolCall ID = %q, want %q", tc.ID, "search#1")
|
|
}
|
|
if tc.Name != "search" {
|
|
t.Fatalf("ToolCall Name = %q, want %q", tc.Name, "search")
|
|
}
|
|
if argQ, ok := tc.Arguments["q"].(string); !ok || argQ != "second" {
|
|
t.Fatalf("ToolCall Arguments = %#v, want q=second", tc.Arguments)
|
|
}
|
|
if resp.FinishReason != "tool_calls" {
|
|
t.Fatalf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls")
|
|
}
|
|
}
|
|
|
|
func TestGeminiProvider_BuildRequestBodyIncludesMediaAndThinkingConfig(t *testing.T) {
|
|
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
|
|
|
|
body := provider.buildRequestBody(
|
|
[]Message{{
|
|
Role: "user",
|
|
Content: "analyze attachments",
|
|
Media: []string{
|
|
"data:application/pdf;base64,UEZERGF0YQ==",
|
|
"data:image/png;base64,aW1hZ2VEYXRh",
|
|
},
|
|
}},
|
|
nil,
|
|
"gemini-3-flash-preview",
|
|
map[string]any{
|
|
"thinking_level": "low",
|
|
"max_tokens": 128,
|
|
"temperature": 0.2,
|
|
},
|
|
)
|
|
|
|
contents, ok := body["contents"].([]geminiContent)
|
|
if !ok || len(contents) != 1 {
|
|
t.Fatalf("contents = %#v, want one gemini content", body["contents"])
|
|
}
|
|
parts := contents[0].Parts
|
|
mimeSet := map[string]bool{}
|
|
for _, part := range parts {
|
|
if part.InlineData != nil {
|
|
mimeSet[part.InlineData.MIMEType] = true
|
|
}
|
|
}
|
|
if !mimeSet["application/pdf"] {
|
|
t.Fatalf("inline media missing application/pdf: %#v", parts)
|
|
}
|
|
if !mimeSet["image/png"] {
|
|
t.Fatalf("inline media missing image/png: %#v", parts)
|
|
}
|
|
|
|
generationConfig, ok := body["generationConfig"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("generationConfig = %#v, want map", body["generationConfig"])
|
|
}
|
|
if got := generationConfig["maxOutputTokens"]; got != 128 {
|
|
t.Fatalf("maxOutputTokens = %#v, want 128", got)
|
|
}
|
|
if got := generationConfig["temperature"]; got != 0.2 {
|
|
t.Fatalf("temperature = %#v, want 0.2", got)
|
|
}
|
|
thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"])
|
|
}
|
|
if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || !includeThoughts {
|
|
t.Fatalf("includeThoughts = %#v, want true", thinkingConfig["includeThoughts"])
|
|
}
|
|
if got := thinkingConfig["thinkingLevel"]; got != "low" {
|
|
t.Fatalf("thinkingLevel = %#v, want %q", got, "low")
|
|
}
|
|
}
|
|
|
|
func TestGeminiProvider_BuildRequestBody_UsesThinkingBudgetForGemini25(t *testing.T) {
|
|
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
|
|
body := provider.buildRequestBody(
|
|
[]Message{{Role: "user", Content: "hello"}},
|
|
nil,
|
|
"gemini-2.5-flash",
|
|
map[string]any{"thinking_level": "medium"},
|
|
)
|
|
|
|
generationConfig, ok := body["generationConfig"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("generationConfig = %#v, want map", body["generationConfig"])
|
|
}
|
|
thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"])
|
|
}
|
|
if got := thinkingConfig["thinkingBudget"]; got != 4096 {
|
|
t.Fatalf("thinkingBudget = %#v, want 4096", got)
|
|
}
|
|
if _, hasLevel := thinkingConfig["thinkingLevel"]; hasLevel {
|
|
t.Fatalf("thinkingLevel should not be set for Gemini 2.5: %#v", thinkingConfig)
|
|
}
|
|
}
|
|
|
|
func TestGeminiProvider_BuildRequestBody_OmitsThinkingConfigForGemini20(t *testing.T) {
|
|
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
|
|
body := provider.buildRequestBody(
|
|
[]Message{{Role: "user", Content: "hello"}},
|
|
nil,
|
|
"gemini-2.0-flash-exp",
|
|
map[string]any{"thinking_level": "high"},
|
|
)
|
|
|
|
if _, ok := body["generationConfig"]; ok {
|
|
t.Fatalf("generationConfig should be omitted for Gemini 2.0 when only thinking_level is set: %#v", body)
|
|
}
|
|
}
|
|
|
|
func TestGeminiProvider_BuildRequestBody_DefaultsThinkingOffForGemini25(t *testing.T) {
|
|
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
|
|
body := provider.buildRequestBody(
|
|
[]Message{{Role: "user", Content: "hello"}},
|
|
nil,
|
|
"gemini-2.5-flash",
|
|
nil,
|
|
)
|
|
|
|
generationConfig, ok := body["generationConfig"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("generationConfig = %#v, want map", body["generationConfig"])
|
|
}
|
|
thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"])
|
|
}
|
|
if got := thinkingConfig["thinkingBudget"]; got != 0 {
|
|
t.Fatalf("thinkingBudget = %#v, want 0 for default/off", got)
|
|
}
|
|
if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || includeThoughts {
|
|
t.Fatalf("includeThoughts = %#v, want false for default/off", thinkingConfig["includeThoughts"])
|
|
}
|
|
}
|
|
|
|
func TestGeminiProvider_BuildRequestBody_DefaultsThinkingOffForGemini3(t *testing.T) {
|
|
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
|
|
body := provider.buildRequestBody(
|
|
[]Message{{Role: "user", Content: "hello"}},
|
|
nil,
|
|
"gemini-3-flash-preview",
|
|
nil,
|
|
)
|
|
|
|
generationConfig, ok := body["generationConfig"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("generationConfig = %#v, want map", body["generationConfig"])
|
|
}
|
|
thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"])
|
|
}
|
|
if got := thinkingConfig["thinkingLevel"]; got != "minimal" {
|
|
t.Fatalf("thinkingLevel = %#v, want minimal for default/off", got)
|
|
}
|
|
if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || includeThoughts {
|
|
t.Fatalf("includeThoughts = %#v, want false for default/off", thinkingConfig["includeThoughts"])
|
|
}
|
|
}
|
|
|
|
func TestGeminiProvider_BuildRequestBody_DefaultsThinkingOffForGemini25Pro(t *testing.T) {
|
|
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
|
|
body := provider.buildRequestBody(
|
|
[]Message{{Role: "user", Content: "hello"}},
|
|
nil,
|
|
"gemini-2.5-pro",
|
|
nil,
|
|
)
|
|
|
|
generationConfig, ok := body["generationConfig"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("generationConfig = %#v, want map", body["generationConfig"])
|
|
}
|
|
thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"])
|
|
}
|
|
if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || includeThoughts {
|
|
t.Fatalf("includeThoughts = %#v, want false for default/off", thinkingConfig["includeThoughts"])
|
|
}
|
|
if _, hasBudget := thinkingConfig["thinkingBudget"]; hasBudget {
|
|
t.Fatalf("thinkingBudget should be omitted for Gemini 2.5 Pro default/off: %#v", thinkingConfig)
|
|
}
|
|
}
|
|
|
|
func TestGeminiProvider_BuildRequestBody_DefaultsThinkingOffForGemini31Pro(t *testing.T) {
|
|
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
|
|
body := provider.buildRequestBody(
|
|
[]Message{{Role: "user", Content: "hello"}},
|
|
nil,
|
|
"gemini-3.1-pro",
|
|
nil,
|
|
)
|
|
|
|
generationConfig, ok := body["generationConfig"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("generationConfig = %#v, want map", body["generationConfig"])
|
|
}
|
|
thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("thinkingConfig = %#v, want map", generationConfig["thinkingConfig"])
|
|
}
|
|
if includeThoughts, ok := thinkingConfig["includeThoughts"].(bool); !ok || includeThoughts {
|
|
t.Fatalf("includeThoughts = %#v, want false for default/off", thinkingConfig["includeThoughts"])
|
|
}
|
|
if _, hasLevel := thinkingConfig["thinkingLevel"]; hasLevel {
|
|
t.Fatalf("thinkingLevel should be omitted for Gemini 3.1 Pro default/off: %#v", thinkingConfig)
|
|
}
|
|
}
|
|
|
|
func TestGeminiProvider_BuildRequestBody_PreservesMultipleSystemMessages(t *testing.T) {
|
|
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
|
|
body := provider.buildRequestBody(
|
|
[]Message{
|
|
{Role: "system", Content: "You are helpful."},
|
|
{Role: "system", Content: "Be concise."},
|
|
{Role: "user", Content: "hello"},
|
|
},
|
|
nil,
|
|
"gemini-3-flash-preview",
|
|
nil,
|
|
)
|
|
|
|
systemInstruction, ok := body["systemInstruction"].(*geminiContent)
|
|
if !ok || systemInstruction == nil {
|
|
t.Fatalf("systemInstruction = %#v, want *geminiContent", body["systemInstruction"])
|
|
}
|
|
if len(systemInstruction.Parts) != 2 {
|
|
t.Fatalf("systemInstruction.Parts len = %d, want 2", len(systemInstruction.Parts))
|
|
}
|
|
if systemInstruction.Parts[0].Text != "You are helpful." || systemInstruction.Parts[1].Text != "Be concise." {
|
|
t.Fatalf("systemInstruction.Parts = %#v, want ordered system prompts", systemInstruction.Parts)
|
|
}
|
|
}
|
|
|
|
func TestGeminiProvider_BuildRequestBody_PreservesToolResponseMedia(t *testing.T) {
|
|
provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil)
|
|
body := provider.buildRequestBody(
|
|
[]Message{
|
|
{
|
|
Role: "assistant",
|
|
ToolCalls: []ToolCall{{
|
|
ID: "call_1",
|
|
Name: "load_image",
|
|
Arguments: map[string]any{"path": "demo.png"},
|
|
}},
|
|
},
|
|
{
|
|
Role: "tool",
|
|
ToolCallID: "call_1",
|
|
Content: "tool result",
|
|
Media: []string{
|
|
"data:image/png;base64,aW1hZ2VEYXRh",
|
|
"data:application/pdf;base64,UEZERGF0YQ==",
|
|
},
|
|
},
|
|
},
|
|
nil,
|
|
"gemini-3-flash-preview",
|
|
nil,
|
|
)
|
|
|
|
contents, ok := body["contents"].([]geminiContent)
|
|
if !ok || len(contents) != 2 {
|
|
t.Fatalf("contents = %#v, want two content entries", body["contents"])
|
|
}
|
|
parts := contents[1].Parts
|
|
if len(parts) != 1 || parts[0].FunctionResponse == nil {
|
|
t.Fatalf("tool response part = %#v, want functionResponse", parts)
|
|
}
|
|
response := parts[0].FunctionResponse
|
|
if response.Name != "load_image" {
|
|
t.Fatalf("functionResponse.Name = %q, want %q", response.Name, "load_image")
|
|
}
|
|
if response.Response["result"] != "tool result" {
|
|
t.Fatalf("functionResponse.Response = %#v, want result=tool result", response.Response)
|
|
}
|
|
if len(response.Parts) != 2 {
|
|
t.Fatalf("functionResponse.Parts len = %d, want 2", len(response.Parts))
|
|
}
|
|
}
|
|
|
|
func TestGeminiProvider_ChatAllowsCustomAuthHeaderWithoutAPIKey(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
|
|
t.Fatalf("Authorization = %q, want %q", got, "Bearer test-token")
|
|
}
|
|
if got := r.Header.Get("X-Goog-Api-Key"); got != "" {
|
|
t.Fatalf("X-Goog-Api-Key = %q, want empty", got)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"candidates": []any{
|
|
map[string]any{
|
|
"content": map[string]any{
|
|
"parts": []any{map[string]any{"text": "ok"}},
|
|
},
|
|
"finishReason": "STOP",
|
|
},
|
|
},
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
provider := NewGeminiProvider(
|
|
"",
|
|
server.URL,
|
|
"",
|
|
"",
|
|
0,
|
|
nil,
|
|
map[string]string{"Authorization": "Bearer test-token"},
|
|
)
|
|
|
|
resp, err := provider.Chat(
|
|
t.Context(),
|
|
[]Message{{Role: "user", Content: "hello"}},
|
|
nil,
|
|
"gemini-2.5-flash",
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Chat() error = %v", err)
|
|
}
|
|
if resp.Content != "ok" {
|
|
t.Fatalf("Content = %q, want %q", resp.Content, "ok")
|
|
}
|
|
}
|
|
|
|
func TestGeminiProvider_ChatAllowsMissingAPIKeyForCustomAPIBase(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if got := r.Header.Get("X-Goog-Api-Key"); got != "" {
|
|
t.Fatalf("X-Goog-Api-Key = %q, want empty", got)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"candidates": []any{
|
|
map[string]any{
|
|
"content": map[string]any{"parts": []any{map[string]any{"text": "ok"}}},
|
|
"finishReason": "STOP",
|
|
},
|
|
},
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
provider := NewGeminiProvider("", server.URL, "", "", 0, nil, nil)
|
|
resp, err := provider.Chat(
|
|
t.Context(),
|
|
[]Message{{Role: "user", Content: "hello"}},
|
|
nil,
|
|
"gemini-2.5-flash",
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Chat() error = %v", err)
|
|
}
|
|
if resp.Content != "ok" {
|
|
t.Fatalf("Content = %q, want %q", resp.Content, "ok")
|
|
}
|
|
}
|