Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,10 @@ func TestFilterModelOptionsGeminiPolicyKeyMatchesGoogleAdapter(t *testing.T) {
"temperature": 0.4,
"responseMimeType": "application/json",
"candidateCount": 3,
"thinkingConfig": map[string]interface{}{
"includeThoughts": true,
"thinkingLevel": "high",
},
},
"tools": []interface{}{
map[string]interface{}{"type": "google_search"},
Expand All @@ -696,6 +700,10 @@ func TestFilterModelOptionsGeminiPolicyKeyMatchesGoogleAdapter(t *testing.T) {
if _, ok := generationConfig["candidateCount"]; ok {
t.Fatalf("expected unlisted gemini option removed, got %#v", generationConfig)
}
thinkingConfig, ok := generationConfig["thinkingConfig"].(map[string]interface{})
if !ok || thinkingConfig["includeThoughts"] != true || thinkingConfig["thinkingLevel"] != "high" {
t.Fatalf("expected Gemini thinking options to pass, got %#v", generationConfig)
}
tools := filtered["tools"].([]map[string]interface{})
if len(tools) != 1 {
t.Fatalf("expected Gemini google_search tool, got %#v", tools)
Expand Down Expand Up @@ -972,8 +980,10 @@ func TestFilterModelOptionsGeminiInteractionsAllowsVideoParams(t *testing.T) {
"delivery": "b64_json",
},
"generation_config": map[string]interface{}{
"temperature": 0.3,
"thinking_level": "low",
"temperature": 0.3,
"thinking_level": "low",
"thinking_summaries": "auto",
"max_output_tokens": 1024,
"video_config": map[string]interface{}{
"task": "image_to_video",
},
Expand All @@ -998,7 +1008,10 @@ func TestFilterModelOptionsGeminiInteractionsAllowsVideoParams(t *testing.T) {
t.Fatalf("expected Gemini generation_config to pass, got %#v", filtered)
}
videoConfig, ok := generationConfig["video_config"].(map[string]interface{})
if generationConfig["temperature"] != 0.3 || generationConfig["thinking_level"] != "low" {
if generationConfig["temperature"] != 0.3 ||
generationConfig["thinking_level"] != "low" ||
generationConfig["thinking_summaries"] != "auto" ||
generationConfig["max_output_tokens"] != 1024 {
t.Fatalf("expected Gemini generation config fields to pass, got %#v", generationConfig)
}
if !ok || videoConfig["task"] != "image_to_video" {
Expand All @@ -1011,6 +1024,33 @@ func TestFilterModelOptionsGeminiInteractionsAllowsVideoParams(t *testing.T) {
}
}

func TestFilterModelOptionsGeminiInteractionsPreservesConfiguredNativeTools(t *testing.T) {
filtered := filterModelOptions(map[string]interface{}{
"tools": []interface{}{
map[string]interface{}{"type": "google_search"},
map[string]interface{}{"type": "code_execution"},
map[string]interface{}{"type": "url_context"},
map[string]interface{}{"type": "external_function", "name": "not_allowed"},
},
}, llm.AdapterGeminiInteractions, modelOptionPolicyConfig{
Mode: modelOptionPolicyAllowlist,
AllowedPathsJSON: config.DefaultModelOptionAllowedPathsJSON(),
DeniedPathsJSON: config.DefaultModelOptionDeniedPathsJSON(),
ModelCapabilitiesJSON: `{"nativeToolKeys":["google.google_search","google.code_execution","google.url_context"]}`,
})

tools, ok := filtered["tools"].([]map[string]interface{})
if !ok || len(tools) != 3 {
t.Fatalf("expected three configured Gemini Interactions tools, got %#v", filtered["tools"])
}
wantTypes := []string{"google_search", "code_execution", "url_context"}
for index, wantType := range wantTypes {
if tools[index]["type"] != wantType {
t.Fatalf("tool %d type = %#v, want %q", index, tools[index]["type"], wantType)
}
}
}

func TestFilterModelOptionsGeminiInteractionsAllowsCamelCaseVideoConfig(t *testing.T) {
filtered := filterModelOptions(map[string]interface{}{
"generationConfig": map[string]interface{}{
Expand Down
66 changes: 59 additions & 7 deletions backend/internal/application/settings/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,16 +198,68 @@ func isLegacyDefaultModelOptionAllowedPaths(value string) bool {
if err := json.Unmarshal([]byte(strings.TrimSpace(value)), &current); err != nil {
return false
}
previousDefault := map[string][]string{}
if err := json.Unmarshal([]byte(config.DefaultModelOptionAllowedPathsJSON()), &previousDefault); err != nil {
latestDefault := map[string][]string{}
if err := json.Unmarshal([]byte(config.DefaultModelOptionAllowedPathsJSON()), &latestDefault); err != nil {
return false
}
delete(previousDefault, "xai_video")
if sameStringSliceMap(current, previousDefault) {
return true
previousGenerateContentDefault := cloneStringSliceMap(latestDefault)
previousGenerateContentDefault["gemini_generate_content"] = removeStringValue(
removeStringValue(
previousGenerateContentDefault["gemini_generate_content"],
"generationConfig.thinkingConfig.includeThoughts",
),
"generationConfig.thinkingConfig.thinkingLevel",
)
previousInteractionsDefault := cloneStringSliceMap(latestDefault)
previousInteractionsDefault["gemini_interactions"] = removeStringValue(
previousInteractionsDefault["gemini_interactions"],
"generation_config.thinking_summaries",
)
previousCombinedDefault := cloneStringSliceMap(previousGenerateContentDefault)
previousCombinedDefault["gemini_interactions"] = removeStringValue(
previousCombinedDefault["gemini_interactions"],
"generation_config.thinking_summaries",
)
previousDefaults := []map[string][]string{
previousGenerateContentDefault,
previousInteractionsDefault,
previousCombinedDefault,
}
for _, previousDefault := range previousDefaults {
if sameStringSliceMap(current, previousDefault) {
return true
}
}
olderDefaults := append([]map[string][]string{cloneStringSliceMap(latestDefault)}, previousDefaults...)
for _, olderDefault := range olderDefaults {
delete(olderDefault, "xai_video")
if sameStringSliceMap(current, olderDefault) {
return true
}
olderDefault["xai_responses"] = []string{"reasoning.effort"}
if sameStringSliceMap(current, olderDefault) {
return true
}
}
return false
}

func cloneStringSliceMap(value map[string][]string) map[string][]string {
result := make(map[string][]string, len(value))
for key, items := range value {
result[key] = append([]string(nil), items...)
}
previousDefault["xai_responses"] = []string{"reasoning.effort"}
return sameStringSliceMap(current, previousDefault)
return result
}

func removeStringValue(values []string, target string) []string {
result := make([]string, 0, len(values))
for _, value := range values {
if value != target {
result = append(result, value)
}
}
return result
}

func sameStringSliceMap(left map[string][]string, right map[string][]string) bool {
Expand Down
61 changes: 61 additions & 0 deletions backend/internal/application/settings/service_seed_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,67 @@ func TestSeedAddsXAIVideoToPreviousDefaultModelOptionAllowedPaths(t *testing.T)
}
}

func TestSeedAddsGeminiThinkingSummariesToPreviousDefaultModelOptionAllowedPaths(t *testing.T) {
previousDefault := map[string][]string{}
if err := json.Unmarshal([]byte(config.DefaultModelOptionAllowedPathsJSON()), &previousDefault); err != nil {
t.Fatalf("decode current model option defaults: %v", err)
}
previousDefault["gemini_interactions"] = removeStringValue(
previousDefault["gemini_interactions"],
"generation_config.thinking_summaries",
)
previousJSON, err := json.Marshal(previousDefault)
if err != nil {
t.Fatalf("encode previous model option defaults: %v", err)
}
repo := newSettingsSeedRepo(domainsettings.SystemSetting{
Namespace: "chat",
Key: "model_option_allowed_paths",
Value: string(previousJSON),
ValueType: "json",
})
service := NewService(repo, "")

if err := service.Seed(context.Background(), config.Config{}); err != nil {
t.Fatalf("seed settings: %v", err)
}
if got := repo.items["chat:model_option_allowed_paths"].Value; got != config.DefaultModelOptionAllowedPathsJSON() {
t.Fatalf("expected Gemini thinking summaries default to be added, got %q", got)
}
}

func TestSeedAddsGeminiGenerateContentThinkingPathsToPreviousDefaultModelOptionAllowedPaths(t *testing.T) {
previousDefault := map[string][]string{}
if err := json.Unmarshal([]byte(config.DefaultModelOptionAllowedPathsJSON()), &previousDefault); err != nil {
t.Fatalf("decode current model option defaults: %v", err)
}
previousDefault["gemini_generate_content"] = removeStringValue(
removeStringValue(
previousDefault["gemini_generate_content"],
"generationConfig.thinkingConfig.includeThoughts",
),
"generationConfig.thinkingConfig.thinkingLevel",
)
previousJSON, err := json.Marshal(previousDefault)
if err != nil {
t.Fatalf("encode previous model option defaults: %v", err)
}
repo := newSettingsSeedRepo(domainsettings.SystemSetting{
Namespace: "chat",
Key: "model_option_allowed_paths",
Value: string(previousJSON),
ValueType: "json",
})
service := NewService(repo, "")

if err := service.Seed(context.Background(), config.Config{}); err != nil {
t.Fatalf("seed settings: %v", err)
}
if got := repo.items["chat:model_option_allowed_paths"].Value; got != config.DefaultModelOptionAllowedPathsJSON() {
t.Fatalf("expected Gemini Generate Content thinking defaults to be added, got %q", got)
}
}

func TestSeedKeepsCustomModelOptionAllowedPaths(t *testing.T) {
custom := `{"default":["temperature"],"xai_responses":["reasoning.effort"]}`
repo := newSettingsSeedRepo(domainsettings.SystemSetting{
Expand Down
27 changes: 15 additions & 12 deletions backend/internal/infra/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,20 @@ func DefaultModelOptionAllowedPathsJSON() string {
"size",
"user"
],
"anthropic_messages": [
"speed",
"top_k",
"thinking.type",
"thinking.budget_tokens"
],
"gemini_generate_content": [
"generationConfig.temperature",
"generationConfig.topP",
"generationConfig.maxOutputTokens",
"generationConfig.responseMimeType",
"generationConfig.thinkingConfig.includeThoughts",
"generationConfig.thinkingConfig.thinkingLevel"
],
"google_image_generation": [
"generationConfig.responseModalities",
"generationConfig.imageConfig.aspectRatio",
Expand All @@ -123,6 +137,7 @@ func DefaultModelOptionAllowedPathsJSON() string {
"generation_config.top_p",
"generation_config.max_output_tokens",
"generation_config.thinking_level",
"generation_config.thinking_summaries",
"response_format.type",
"response_format.aspect_ratio",
"response_format.image_size",
Expand All @@ -134,12 +149,6 @@ func DefaultModelOptionAllowedPathsJSON() string {
"generationConfig.videoConfig.task",
"generation_config.video_config.task"
],
"anthropic_messages": [
"speed",
"top_k",
"thinking.type",
"thinking.budget_tokens"
],
"xai_responses": [
"reasoning.effort",
"min_p",
Expand All @@ -163,12 +172,6 @@ func DefaultModelOptionAllowedPathsJSON() string {
"aspect_ratio",
"duration",
"resolution"
],
"gemini_generate_content": [
"generationConfig.temperature",
"generationConfig.topP",
"generationConfig.maxOutputTokens",
"generationConfig.responseMimeType"
]
}`
}
Expand Down
8 changes: 6 additions & 2 deletions backend/internal/infra/llm/gemini_interactions.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,12 @@ func buildGeminiInteractionRequestBody(route RouteConfig, input GenerateInput) (
if previousID := strings.TrimSpace(input.PreviousResponseID); previousID != "" {
payload["previous_interaction_id"] = previousID
}
if tools := buildGeminiInteractionTools(input.Tools); len(tools) > 0 && !input.DisableTools {
payload["tools"] = tools
providerTools, toolDefinitions, toolsEnabled, err := toolDeclarationsForInput(input)
if err != nil {
return nil, err
}
if toolsEnabled {
appendToolDeclarations(payload, providerTools, buildGeminiInteractionTools(toolDefinitions))
}
applyProviderOptions(payload, input.Options, geminiInteractionsProtectedProviderOptionKeys()...)
return payload, nil
Expand Down
55 changes: 53 additions & 2 deletions backend/internal/infra/llm/gemini_interactions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,8 @@ func TestBuildGeminiInteractionRequestBodySupportsUniversalOptionsAndTools(t *te
"max_output_tokens": 512,
"thinking_level": "low",
"generation_config": map[string]interface{}{
"thinkingLevel": "high",
"thinkingLevel": "high",
"thinking_summaries": "auto",
},
},
})
Expand All @@ -164,7 +165,7 @@ func TestBuildGeminiInteractionRequestBodySupportsUniversalOptionsAndTools(t *te
t.Fatalf("unexpected image response_format: %#v", imageFormat)
}
config, ok := payload["generation_config"].(map[string]interface{})
if !ok || config["temperature"] != 0.4 || config["top_p"] != 0.9 || config["max_output_tokens"] != 512 || config["thinking_level"] != "low" {
if !ok || config["temperature"] != 0.4 || config["top_p"] != 0.9 || config["max_output_tokens"] != 512 || config["thinking_level"] != "low" || config["thinking_summaries"] != "auto" {
t.Fatalf("unexpected generation_config: %#v", payload["generation_config"])
}
tools, ok := payload["tools"].([]map[string]interface{})
Expand Down Expand Up @@ -193,6 +194,56 @@ func TestBuildGeminiInteractionRequestBodySupportsUniversalOptionsAndTools(t *te
}
}

func TestBuildGeminiInteractionRequestBodyMergesNativeAndFunctionTools(t *testing.T) {
input := GenerateInput{
Messages: []Message{{Role: "user", Content: "Research and calculate."}},
Options: map[string]interface{}{
"tools": []interface{}{
map[string]interface{}{"type": "google_search"},
map[string]interface{}{"type": "code_execution"},
map[string]interface{}{"type": "url_context"},
},
},
Tools: []ToolDefinition{{
Name: "get_weather",
Description: "Gets weather.",
InputSchema: json.RawMessage(`{"type":"object"}`),
}},
}
payload, err := buildGeminiInteractionRequestBody(RouteConfig{
Endpoint: EndpointInteractions,
UpstreamModel: "gemini-3.5-flash",
}, input)
if err != nil {
t.Fatalf("build Gemini interaction request body: %v", err)
}
tools, ok := payload["tools"].([]map[string]interface{})
if !ok || len(tools) != 4 {
t.Fatalf("expected three native tools and one function, got %#v", payload["tools"])
}
wantTypes := []string{"google_search", "code_execution", "url_context", "function"}
for index, wantType := range wantTypes {
if tools[index]["type"] != wantType {
t.Fatalf("tool %d type = %#v, want %q", index, tools[index]["type"], wantType)
}
}
if tools[3]["name"] != "get_weather" {
t.Fatalf("expected function tool to be preserved, got %#v", tools[3])
}

input.DisableTools = true
disabledPayload, err := buildGeminiInteractionRequestBody(RouteConfig{
Endpoint: EndpointInteractions,
UpstreamModel: "gemini-3.5-flash",
}, input)
if err != nil {
t.Fatalf("build disabled-tools Gemini interaction request body: %v", err)
}
if _, exists := disabledPayload["tools"]; exists {
t.Fatalf("expected DisableTools to remove native and function tools, got %#v", disabledPayload["tools"])
}
}

func TestBuildGeminiInteractionToolsPreservesJSONSchemaReferences(t *testing.T) {
payload, err := buildGeminiInteractionRequestBody(RouteConfig{
Endpoint: EndpointInteractions,
Expand Down
Loading
Loading