267 lines
5.8 KiB
Go
267 lines
5.8 KiB
Go
package providers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gophergate/internal/models"
|
|
)
|
|
|
|
func TestCleanParametersSchema(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "flat additionalProperties and $schema",
|
|
input: `{
|
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {
|
|
"type": "string"
|
|
}
|
|
},
|
|
"required": ["query"],
|
|
"additionalProperties": false
|
|
}`,
|
|
expected: `{
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {
|
|
"type": "string"
|
|
}
|
|
},
|
|
"required": ["query"]
|
|
}`,
|
|
},
|
|
{
|
|
name: "nested additionalProperties",
|
|
input: `{
|
|
"type": "object",
|
|
"properties": {
|
|
"user": {
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {
|
|
"type": "integer"
|
|
}
|
|
},
|
|
"additionalProperties": false
|
|
}
|
|
},
|
|
"additionalProperties": true
|
|
}`,
|
|
expected: `{
|
|
"type": "object",
|
|
"properties": {
|
|
"user": {
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {
|
|
"type": "integer"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`,
|
|
},
|
|
{
|
|
name: "nested additionalProperties in array items",
|
|
input: `{
|
|
"type": "object",
|
|
"properties": {
|
|
"items": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {
|
|
"type": "string"
|
|
}
|
|
},
|
|
"additionalProperties": false
|
|
}
|
|
}
|
|
}
|
|
}`,
|
|
expected: `{
|
|
"type": "object",
|
|
"properties": {
|
|
"items": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {
|
|
"type": "string"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
inputRaw := json.RawMessage(tc.input)
|
|
cleanedRaw := cleanParametersSchema(inputRaw)
|
|
|
|
var cleanedMap, expectedMap map[string]interface{}
|
|
if err := json.Unmarshal(cleanedRaw, &cleanedMap); err != nil {
|
|
t.Fatalf("failed to unmarshal cleaned: %v", err)
|
|
}
|
|
if err := json.Unmarshal([]byte(tc.expected), &expectedMap); err != nil {
|
|
t.Fatalf("failed to unmarshal expected: %v", err)
|
|
}
|
|
|
|
if !reflect.DeepEqual(cleanedMap, expectedMap) {
|
|
t.Errorf("expected %v, got %v", expectedMap, cleanedMap)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEmitGeminiChunk_ToolCalls(t *testing.T) {
|
|
ch := make(chan *models.ChatCompletionStreamResponse, 1)
|
|
defer close(ch)
|
|
|
|
chunk := &geminiStreamChunk{}
|
|
chunk.Candidates = []struct {
|
|
Content struct {
|
|
Parts []struct {
|
|
Text string `json:"text,omitempty"`
|
|
Thought string `json:"thought,omitempty"`
|
|
FunctionCall *struct {
|
|
Name string `json:"name"`
|
|
Args json.RawMessage `json:"args"`
|
|
} `json:"functionCall,omitempty"`
|
|
} `json:"parts"`
|
|
} `json:"content"`
|
|
FinishReason string `json:"finishReason"`
|
|
}{
|
|
{
|
|
Content: struct {
|
|
Parts []struct {
|
|
Text string `json:"text,omitempty"`
|
|
Thought string `json:"thought,omitempty"`
|
|
FunctionCall *struct {
|
|
Name string `json:"name"`
|
|
Args json.RawMessage `json:"args"`
|
|
} `json:"functionCall,omitempty"`
|
|
} `json:"parts"`
|
|
}{
|
|
Parts: []struct {
|
|
Text string `json:"text,omitempty"`
|
|
Thought string `json:"thought,omitempty"`
|
|
FunctionCall *struct {
|
|
Name string `json:"name"`
|
|
Args json.RawMessage `json:"args"`
|
|
} `json:"functionCall,omitempty"`
|
|
}{
|
|
{
|
|
FunctionCall: &struct {
|
|
Name string `json:"name"`
|
|
Args json.RawMessage `json:"args"`
|
|
}{
|
|
Name: "google_search",
|
|
Args: json.RawMessage(`{"query": "test"}`),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
FinishReason: "",
|
|
},
|
|
}
|
|
|
|
emitted := emitGeminiChunk(ch, chunk, "gemini-3-flash-preview")
|
|
if !emitted {
|
|
t.Fatalf("expected emitGeminiChunk to return true")
|
|
}
|
|
|
|
select {
|
|
case resp := <-ch:
|
|
if len(resp.Choices) != 1 {
|
|
t.Fatalf("expected 1 choice, got %d", len(resp.Choices))
|
|
}
|
|
choice := resp.Choices[0]
|
|
if choice.FinishReason == nil || *choice.FinishReason != "tool_calls" {
|
|
t.Errorf("expected finish_reason 'tool_calls', got %v", choice.FinishReason)
|
|
}
|
|
if len(choice.Delta.ToolCalls) != 1 {
|
|
t.Fatalf("expected 1 tool call in delta, got %d", len(choice.Delta.ToolCalls))
|
|
}
|
|
tc := choice.Delta.ToolCalls[0]
|
|
if tc.ID == nil || *tc.ID != "call_google_search" {
|
|
t.Errorf("expected ID 'call_google_search', got %v", tc.ID)
|
|
}
|
|
if tc.Function == nil || tc.Function.Name == nil || *tc.Function.Name != "google_search" {
|
|
t.Errorf("expected function name 'google_search', got %v", tc.Function)
|
|
}
|
|
if tc.Function == nil || tc.Function.Arguments == nil || *tc.Function.Arguments != `{"query": "test"}` {
|
|
t.Errorf("expected arguments '{\"query\": \"test\"}', got %v", tc.Function)
|
|
}
|
|
default:
|
|
t.Fatalf("expected response on channel")
|
|
}
|
|
}
|
|
|
|
func TestStreamGeminiJSONArrayStream(t *testing.T) {
|
|
input := `[
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"parts": [
|
|
{"text": "Hello"}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"parts": [
|
|
{"text": " world"}
|
|
]
|
|
}
|
|
}
|
|
],
|
|
"usageMetadata": {
|
|
"promptTokenCount": 5,
|
|
"candidatesTokenCount": 2,
|
|
"totalTokenCount": 7
|
|
}
|
|
}
|
|
]`
|
|
|
|
ch := make(chan *models.ChatCompletionStreamResponse, 5)
|
|
defer close(ch)
|
|
|
|
streamGeminiJSONArrayStream(strings.NewReader(input), ch, "gemini-2.5-flash")
|
|
|
|
var texts []string
|
|
for i := 0; i < 2; i++ {
|
|
select {
|
|
case resp := <-ch:
|
|
if len(resp.Choices) > 0 && resp.Choices[0].Delta.Content != nil {
|
|
texts = append(texts, *resp.Choices[0].Delta.Content)
|
|
}
|
|
default:
|
|
t.Fatalf("expected chunk %d", i+1)
|
|
}
|
|
}
|
|
|
|
joined := strings.Join(texts, "")
|
|
if joined != "Hello world" {
|
|
t.Errorf("expected 'Hello world', got %q", joined)
|
|
}
|
|
}
|