diff --git a/backend/internal/application/conversation/model_option_policy_test.go b/backend/internal/application/conversation/model_option_policy_test.go
index ef8fb7b74..d8a10375d 100644
--- a/backend/internal/application/conversation/model_option_policy_test.go
+++ b/backend/internal/application/conversation/model_option_policy_test.go
@@ -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"},
@@ -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)
@@ -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",
},
@@ -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" {
@@ -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{}{
diff --git a/backend/internal/application/settings/service.go b/backend/internal/application/settings/service.go
index 87574d209..f78e48bbc 100644
--- a/backend/internal/application/settings/service.go
+++ b/backend/internal/application/settings/service.go
@@ -198,16 +198,68 @@ func isLegacyDefaultModelOptionAllowedPaths(value string) bool {
if err := json.Unmarshal([]byte(strings.TrimSpace(value)), ¤t); 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 {
diff --git a/backend/internal/application/settings/service_seed_test.go b/backend/internal/application/settings/service_seed_test.go
index 22662adbd..a68eea0cc 100644
--- a/backend/internal/application/settings/service_seed_test.go
+++ b/backend/internal/application/settings/service_seed_test.go
@@ -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{
diff --git a/backend/internal/infra/config/config.go b/backend/internal/infra/config/config.go
index 209f7a570..6406fb820 100644
--- a/backend/internal/infra/config/config.go
+++ b/backend/internal/infra/config/config.go
@@ -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",
@@ -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",
@@ -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",
@@ -163,12 +172,6 @@ func DefaultModelOptionAllowedPathsJSON() string {
"aspect_ratio",
"duration",
"resolution"
- ],
- "gemini_generate_content": [
- "generationConfig.temperature",
- "generationConfig.topP",
- "generationConfig.maxOutputTokens",
- "generationConfig.responseMimeType"
]
}`
}
diff --git a/backend/internal/infra/llm/gemini_interactions.go b/backend/internal/infra/llm/gemini_interactions.go
index cdfe64329..64f579288 100644
--- a/backend/internal/infra/llm/gemini_interactions.go
+++ b/backend/internal/infra/llm/gemini_interactions.go
@@ -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
diff --git a/backend/internal/infra/llm/gemini_interactions_test.go b/backend/internal/infra/llm/gemini_interactions_test.go
index 7c885daaf..856377299 100644
--- a/backend/internal/infra/llm/gemini_interactions_test.go
+++ b/backend/internal/infra/llm/gemini_interactions_test.go
@@ -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",
},
},
})
@@ -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{})
@@ -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,
diff --git a/backend/internal/shared/nativetool/catalog.go b/backend/internal/shared/nativetool/catalog.go
index 507252c28..deeab0f72 100644
--- a/backend/internal/shared/nativetool/catalog.go
+++ b/backend/internal/shared/nativetool/catalog.go
@@ -66,6 +66,7 @@ var protocolOrder = []string{
"anthropic_messages",
"xai_responses",
"gemini_generate_content",
+ "gemini_interactions",
"google_image_generation",
}
@@ -374,6 +375,43 @@ var definitions = []Definition{
UsageAliases: []string{"url_context"},
rawTypeFieldKeys: []string{"url_context", "urlContext"},
},
+ {
+ Protocol: "gemini_interactions",
+ Provider: "Google",
+ Type: "google_search",
+ Key: "google.google_search",
+ Label: "Google Search",
+ Description: "Google hosted search grounding tool.",
+ Payload: map[string]interface{}{"type": "google_search"},
+ DefaultEnabled: true,
+ PriceLabel: "notMetered",
+ UsageAliases: []string{"google_search"},
+ },
+ {
+ Protocol: "gemini_interactions",
+ Provider: "Google",
+ Type: "code_execution",
+ Key: "google.code_execution",
+ Label: "Code Execution",
+ Description: "Google hosted code execution tool.",
+ Payload: map[string]interface{}{"type": "code_execution"},
+ DefaultEnabled: true,
+ PriceLabel: "notMetered",
+ RiskLevel: "high",
+ UsageAliases: []string{"code_execution"},
+ },
+ {
+ Protocol: "gemini_interactions",
+ Provider: "Google",
+ Type: "url_context",
+ Key: "google.url_context",
+ Label: "URL Context",
+ Description: "Google hosted URL context tool.",
+ Payload: map[string]interface{}{"type": "url_context"},
+ DefaultEnabled: true,
+ PriceLabel: "notMetered",
+ UsageAliases: []string{"url_context"},
+ },
}
var usagePricesByKey = map[string]UsagePrice{
@@ -898,7 +936,7 @@ func UsagePricingKey(protocol string, toolName string) (string, bool) {
case "file_search", "collection_search", "collections_search":
return "xai.collections_search", true
}
- case "gemini_generate_content", "google_image_generation":
+ case "gemini_generate_content", "gemini_interactions", "google_image_generation":
switch tool {
case "google_search":
return "google.google_search", true
diff --git a/backend/internal/shared/nativetool/catalog_test.go b/backend/internal/shared/nativetool/catalog_test.go
index 10eccd90c..fd19ca8a8 100644
--- a/backend/internal/shared/nativetool/catalog_test.go
+++ b/backend/internal/shared/nativetool/catalog_test.go
@@ -62,6 +62,15 @@ func TestPayloadFromOptionPreservesToolParametersAndFixesIdentity(t *testing.T)
t.Fatalf("expected %s definition payload to preserve empty object, got %#v", item, definition.Payload)
}
}
+ for _, item := range []string{"google_search", "code_execution", "url_context"} {
+ definition, ok := Find("gemini_interactions", item)
+ if !ok {
+ t.Fatalf("expected Gemini Interactions %s definition", item)
+ }
+ if definition.Payload["type"] != item {
+ t.Fatalf("expected Gemini Interactions %s type payload, got %#v", item, definition.Payload)
+ }
+ }
for _, item := range []string{"code_execution", "url_context"} {
_, payload, ok = PayloadFromOption("gemini_generate_content", map[string]interface{}{
@@ -126,6 +135,10 @@ func TestUsagePricingKeyMapsObservedToolUsage(t *testing.T) {
if !ok || key != "google.url_context" {
t.Fatalf("expected Google URL context price key, got key=%q ok=%v", key, ok)
}
+ key, ok = UsagePricingKey("gemini_interactions", "code_execution")
+ if !ok || key != "google.code_execution" {
+ t.Fatalf("expected Gemini Interactions code execution price key, got key=%q ok=%v", key, ok)
+ }
}
func TestPricingOverridesApplyToDisplayAndUsagePricing(t *testing.T) {
diff --git a/frontend/features/admin/components/sections/conversation/admin-conversation.tsx b/frontend/features/admin/components/sections/conversation/admin-conversation.tsx
index fede411d2..29908055e 100644
--- a/frontend/features/admin/components/sections/conversation/admin-conversation.tsx
+++ b/frontend/features/admin/components/sections/conversation/admin-conversation.tsx
@@ -337,7 +337,7 @@ generationConfig.safetySettings.threshold`}
{t("guide.protocolTitle")}
{t("guide.protocolDescription")}
- {["default", "openai_chat_completions", "openrouter_chat_completions", "openai_responses", "openrouter_responses", "openai_image_generations", "openai_image_edits", "google_image_generation", "gemini_interactions", "xai_image", "xai_image_edits", "xai_video", "anthropic_messages", "xai_responses", "gemini_generate_content"].map((item) => (
+ {MODEL_OPTION_POLICY_PROTOCOLS.map((item) => (
{item}
))}
diff --git a/frontend/features/admin/components/sections/models/models-capabilities-config.tsx b/frontend/features/admin/components/sections/models/models-capabilities-config.tsx
index 57e4ba6c8..33af86afe 100644
--- a/frontend/features/admin/components/sections/models/models-capabilities-config.tsx
+++ b/frontend/features/admin/components/sections/models/models-capabilities-config.tsx
@@ -520,10 +520,27 @@ function sortNativeToolOptionsByRoute(
return leftMatched ? -1 : 1;
}
const providerOrder = left.provider.localeCompare(right.provider);
- return providerOrder || left.label.localeCompare(right.label) || left.toolKey.localeCompare(right.toolKey) || left.type.localeCompare(right.type);
+ return providerOrder
+ || left.label.localeCompare(right.label)
+ || left.toolKey.localeCompare(right.toolKey)
+ || left.type.localeCompare(right.type)
+ || left.protocols.join(",").localeCompare(right.protocols.join(","));
});
}
+function nativeToolPayloadSignature(value: unknown): string {
+ if (Array.isArray(value)) {
+ return `[${value.map((item) => nativeToolPayloadSignature(item)).join(",")}]`;
+ }
+ if (isPlainJSONObject(value)) {
+ return `{${Object.keys(value)
+ .sort()
+ .map((key) => `${JSON.stringify(key)}:${nativeToolPayloadSignature(value[key])}`)
+ .join(",")}}`;
+ }
+ return JSON.stringify(value) ?? String(value);
+}
+
function nativeToolOptionsFromCatalog(
nativeTools: NativeToolDefinition[],
routeProtocols: string[] = [],
@@ -533,12 +550,29 @@ function nativeToolOptionsFromCatalog(
nativeTools.forEach((tool) => {
const toolKey = tool.toolKey.trim();
const type = tool.type.trim();
- const id = nativeToolOptionID(toolKey, type);
- const existing = options.get(id);
+ const protocol = canonicalNativeToolProtocol(tool.protocol);
+ const payload = tool.payload ?? {};
+ const payloadSignature = nativeToolPayloadSignature(payload);
+ const existing = Array.from(options.values()).find((option) =>
+ option.toolKey === toolKey
+ && option.type === type
+ && nativeToolPayloadSignature(option.payload) === payloadSignature,
+ );
if (existing) {
- existing.protocols = Array.from(new Set([...existing.protocols, tool.protocol].filter(Boolean)));
+ const existingMatchesRoute = existing.protocols.some((protocol) =>
+ routeProtocolSet.has(resolveModelOptionPolicyProtocol(protocol)),
+ );
+ const toolMatchesRoute = routeProtocolSet.has(resolveModelOptionPolicyProtocol(protocol));
+ if (toolMatchesRoute && !existingMatchesRoute) {
+ existing.provider = tool.provider || "Provider";
+ existing.label = tool.label || tool.type || tool.toolKey;
+ existing.description = tool.description || tool.type || tool.toolKey;
+ existing.payload = payload;
+ }
+ existing.protocols = Array.from(new Set([...existing.protocols, protocol].filter(Boolean)));
return;
}
+ const id = `${nativeToolOptionID(toolKey, type, [protocol])}:${payloadSignature}`;
options.set(id, {
id,
toolKey,
@@ -546,15 +580,19 @@ function nativeToolOptionsFromCatalog(
label: tool.label || tool.type || tool.toolKey,
description: tool.description || tool.type || tool.toolKey,
type,
- payload: tool.payload ?? {},
- protocols: [tool.protocol].filter(Boolean),
+ payload,
+ protocols: [protocol].filter(Boolean),
});
});
return sortNativeToolOptionsByRoute(Array.from(options.values()), routeProtocolSet);
}
-function nativeToolOptionID(key: string, type: string): string {
- return [key.trim(), type.trim()].filter(Boolean).join(":");
+function nativeToolOptionID(key: string, type: string, protocols: string[] = []): string {
+ return [
+ key.trim(),
+ type.trim(),
+ ...protocols.map((protocol) => protocol.trim()).filter(Boolean).sort(),
+ ].filter(Boolean).join(":");
}
function nativeToolMatchesRawTool(rawTool: Record, tool: NativeToolDefinition): boolean {
@@ -662,8 +700,13 @@ export function normalizeModelCapabilitiesJSON(
return Object.keys(payload).length > 0 ? JSON.stringify(payload, null, 2) : "";
}
+function canonicalNativeToolProtocol(protocol: string): string {
+ const value = protocol.trim();
+ return value.toLowerCase() === "google_generate_content" ? "gemini_generate_content" : value;
+}
+
function formatNativeToolProtocols(protocols: string[]): string {
- return protocols
+ return Array.from(new Set(protocols.map(canonicalNativeToolProtocol).filter(Boolean)))
.map((protocol) => MODEL_OPTION_POLICY_PROTOCOL_LABELS[protocol as keyof typeof MODEL_OPTION_POLICY_PROTOCOL_LABELS] ?? protocol)
.join(" / ");
}
@@ -673,14 +716,14 @@ function parseNativeToolProtocolsInput(value: string): string[] {
new Set(
value
.split(",")
- .map((item) => item.trim())
+ .map(canonicalNativeToolProtocol)
.filter(Boolean),
),
);
}
function formatNativeToolProtocolsInput(protocols: string[]): string {
- return protocols.map((protocol) => protocol.trim()).filter(Boolean).join(", ");
+ return Array.from(new Set(protocols.map(canonicalNativeToolProtocol).filter(Boolean))).join(", ");
}
function nativeToolProtocolSelectOptions(
@@ -817,7 +860,7 @@ function nativeToolRowFromConfig(value: Record, index: number):
const type = typeof value.type === "string" ? value.type.trim() : nativeToolPayloadType(payload);
const id = typeof value.id === "string" && value.id.trim()
? value.id.trim()
- : nativeToolOptionID(key, type) || createCapabilityRowID();
+ : nativeToolOptionID(key, type, protocols) || createCapabilityRowID();
if (!key && protocols.length === 0 && !type && Object.keys(payload).length === 0) {
return null;
}
@@ -845,13 +888,46 @@ function parseNativeToolRows(
const routeProtocolSet = new Set(routeProtocols.map((protocol) => resolveModelOptionPolicyProtocol(protocol)).filter(Boolean));
const rows = options.map((option) => nativeToolRowFromOption(option, false));
const applyRow = (row: NativeToolRow) => {
- const id = nativeToolOptionID(row.key, row.type) || row.id;
- const index = rows.findIndex((item) => item.id === id);
- if (index < 0) {
- rows.unshift({ ...row, id });
+ const configuredProtocols = new Set(
+ parseNativeToolProtocolsInput(row.protocols)
+ .map((protocol) => resolveModelOptionPolicyProtocol(protocol))
+ .filter(Boolean),
+ );
+ const matchingIndexes = rows.flatMap((item, index) => {
+ if (item.key !== row.key || item.type !== row.type) {
+ return [];
+ }
+ const protocolMatched = configuredProtocols.size === 0
+ || parseNativeToolProtocolsInput(item.protocols)
+ .some((protocol) => configuredProtocols.has(resolveModelOptionPolicyProtocol(protocol)));
+ return protocolMatched ? [index] : [];
+ });
+ if (matchingIndexes.length === 0) {
+ rows.unshift({ ...row, id: row.id || createCapabilityRowID() });
return;
}
- rows[index] = { ...rows[index], ...row, id, catalog: rows[index].catalog };
+ const configuredPayloadSignature = nativeToolPayloadSignature(JSON.parse(row.payload || "{}") as unknown);
+ for (const index of matchingIndexes) {
+ const catalogRow = rows[index];
+ if (!catalogRow) {
+ continue;
+ }
+ const matchedProtocols = parseNativeToolProtocolsInput(catalogRow.protocols)
+ .filter((protocol) => configuredProtocols.size === 0 || configuredProtocols.has(resolveModelOptionPolicyProtocol(protocol)));
+ const catalogPayloadSignature = nativeToolPayloadSignature(JSON.parse(catalogRow.payload || "{}") as unknown);
+ rows[index] = {
+ ...catalogRow,
+ ...row,
+ id: catalogRow.id,
+ provider: row.provider || catalogRow.provider,
+ description: row.description || catalogRow.description,
+ protocols: formatNativeToolProtocolsInput(matchedProtocols),
+ payload: matchingIndexes.length === 1 || configuredPayloadSignature === catalogPayloadSignature
+ ? row.payload
+ : catalogRow.payload,
+ catalog: true,
+ };
+ }
};
if (Array.isArray(payload.nativeTools)) {
diff --git a/frontend/features/admin/components/sections/models/models-capabilities-presets.tsx b/frontend/features/admin/components/sections/models/models-capabilities-presets.tsx
index a12cf0f8c..20ddb8f62 100644
--- a/frontend/features/admin/components/sections/models/models-capabilities-presets.tsx
+++ b/frontend/features/admin/components/sections/models/models-capabilities-presets.tsx
@@ -299,18 +299,20 @@ const MODEL_CAPABILITY_PRESETS: CapabilityPreset[] = [
nativeTools: [
{
key: "google.code_execution",
- protocols: ["google_generate_content", "gemini_generate_content"],
+ protocols: ["gemini_generate_content"],
label: "Code Execution",
enabled: true,
defaultEnabled: true,
payload: {
code_execution: {},
},
+ provider: "Google",
type: "code_execution",
+ description: "Google hosted code execution tool.",
},
{
key: "google.google_search",
- protocols: ["google_generate_content", "gemini_generate_content"],
+ protocols: ["gemini_generate_content"],
label: "Google Search",
enabled: true,
defaultEnabled: true,
@@ -323,14 +325,90 @@ const MODEL_CAPABILITY_PRESETS: CapabilityPreset[] = [
},
{
key: "google.url_context",
- protocols: ["google_generate_content", "gemini_generate_content"],
+ protocols: ["gemini_generate_content"],
label: "URL Context",
enabled: true,
defaultEnabled: true,
payload: {
url_context: {},
},
+ provider: "Google",
+ type: "url_context",
+ description: "Google hosted URL context tool.",
+ },
+ ],
+ },
+ },
+ {
+ id: "gemini_interactions",
+ protocol: "gemini_interactions",
+ payload: {
+ defaultOptions: {
+ generation_config: {
+ thinking_level: "medium",
+ },
+ },
+ optionControls: [
+ {
+ path: "generation_config.thinking_level",
+ type: "select",
+ label: "Thinking Level",
+ description: "Controls the depth of the model's internal reasoning.",
+ options: ["minimal", "low", "medium", "high"],
+ },
+ {
+ path: "generation_config.thinking_summaries",
+ type: "select",
+ label: "Thinking Summaries",
+ description: "Controls whether thought summaries are included in the response.",
+ options: ["none", "auto"],
+ },
+ {
+ path: "generation_config.max_output_tokens",
+ type: "number",
+ label: "Max Output Tokens",
+ description: "Maximum number of tokens to include in the response.",
+ },
+ ],
+ nativeTools: [
+ {
+ key: "google.code_execution",
+ protocols: ["gemini_interactions"],
+ label: "Code Execution",
+ enabled: true,
+ defaultEnabled: true,
+ payload: {
+ type: "code_execution",
+ },
+ provider: "Google",
+ type: "code_execution",
+ description: "Google hosted code execution tool.",
+ },
+ {
+ key: "google.google_search",
+ protocols: ["gemini_interactions"],
+ label: "Google Search",
+ enabled: true,
+ defaultEnabled: true,
+ payload: {
+ type: "google_search",
+ },
+ provider: "Google",
+ type: "google_search",
+ description: "Google hosted search grounding tool.",
+ },
+ {
+ key: "google.url_context",
+ protocols: ["gemini_interactions"],
+ label: "URL Context",
+ enabled: true,
+ defaultEnabled: true,
+ payload: {
+ type: "url_context",
+ },
+ provider: "Google",
type: "url_context",
+ description: "Google hosted URL context tool.",
},
],
},
diff --git a/frontend/features/admin/model/conversation-settings.ts b/frontend/features/admin/model/conversation-settings.ts
index 739002a74..88e65fb39 100644
--- a/frontend/features/admin/model/conversation-settings.ts
+++ b/frontend/features/admin/model/conversation-settings.ts
@@ -120,6 +120,21 @@ export const DEFAULT_MODEL_OPTION_ALLOWED_PATHS = `{
"size",
"user"
],
+ "anthropic_messages": [
+ "speed",
+ "top_k",
+ "cache_control",
+ "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",
@@ -130,6 +145,7 @@ export const DEFAULT_MODEL_OPTION_ALLOWED_PATHS = `{
"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",
@@ -141,13 +157,6 @@ export const DEFAULT_MODEL_OPTION_ALLOWED_PATHS = `{
"generationConfig.videoConfig.task",
"generation_config.video_config.task"
],
- "anthropic_messages": [
- "speed",
- "top_k",
- "cache_control",
- "thinking.type",
- "thinking.budget_tokens"
- ],
"xai_responses": [
"reasoning.effort",
"min_p",
@@ -171,12 +180,6 @@ export const DEFAULT_MODEL_OPTION_ALLOWED_PATHS = `{
"aspect_ratio",
"duration",
"resolution"
- ],
- "gemini_generate_content": [
- "generationConfig.temperature",
- "generationConfig.topP",
- "generationConfig.maxOutputTokens",
- "generationConfig.responseMimeType"
]
}`;
diff --git a/frontend/features/chat/components/sections/chat-model-config.tsx b/frontend/features/chat/components/sections/chat-model-config.tsx
index 9407f5d7c..dda5b4a41 100644
--- a/frontend/features/chat/components/sections/chat-model-config.tsx
+++ b/frontend/features/chat/components/sections/chat-model-config.tsx
@@ -115,6 +115,9 @@ const OPTION_LABEL_KEYS = new Set([
"generationConfig.thinkingConfig.includeThoughts",
"generationConfig.thinkingConfig.thinkingBudget",
"generationConfig.thinkingConfig.thinkingLevel",
+ "generation_config.max_output_tokens",
+ "generation_config.thinking_level",
+ "generation_config.thinking_summaries",
"generationConfig.topK",
"logprobs",
"max_completion_tokens",
diff --git a/frontend/features/chat/hooks/use-chat-model-options.ts b/frontend/features/chat/hooks/use-chat-model-options.ts
index 39a23b518..59551340e 100644
--- a/frontend/features/chat/hooks/use-chat-model-options.ts
+++ b/frontend/features/chat/hooks/use-chat-model-options.ts
@@ -85,16 +85,16 @@ function normalizeNativeToolStrings(value: unknown): string[] {
function nativeToolID({
key,
- protocol,
+ protocols,
type,
index,
}: {
key: string;
- protocol: string;
+ protocols: string[];
type: string;
index: number;
}): string {
- return [key, protocol, type].map((item) => item.trim()).filter(Boolean).join(":") || `native-tool-${index}`;
+ return [key, ...protocols, type].map((item) => item.trim()).filter(Boolean).join(":") || `native-tool-${index}`;
}
function resolveNativeTools(raw: string): ModelNativeToolConfig[] {
@@ -114,14 +114,15 @@ function resolveNativeTools(raw: string): ModelNativeToolConfig[] {
const type = normalizeNativeToolString(source.type) || normalizeNativeToolString(payload.type);
const protocol = normalizeNativeToolString(source.protocol);
const protocols = normalizeNativeToolStrings(source.protocols);
+ const effectiveProtocols = protocols.length > 0 ? protocols : (protocol ? [protocol] : []);
if (!key && !type && Object.keys(payload).length === 0) {
return [];
}
return [{
- id: normalizeNativeToolString(source.id) || nativeToolID({ key, protocol, type, index }),
+ id: normalizeNativeToolString(source.id) || nativeToolID({ key, protocols: effectiveProtocols, type, index }),
key,
protocol,
- protocols: protocols.length > 0 ? protocols : (protocol ? [protocol] : []),
+ protocols: effectiveProtocols,
provider: normalizeNativeToolString(source.provider) || undefined,
type,
label: normalizeNativeToolString(source.label) || type || key,
@@ -133,7 +134,7 @@ function resolveNativeTools(raw: string): ModelNativeToolConfig[] {
}).filter((item) => item.enabled);
}
return resolveNativeToolKeys(raw).map((key, index) => ({
- id: nativeToolID({ key, protocol: "", type: "", index }),
+ id: nativeToolID({ key, protocols: [], type: "", index }),
key,
protocol: "",
protocols: [],
diff --git a/frontend/i18n/messages/en-US/admin-models.json b/frontend/i18n/messages/en-US/admin-models.json
index b9149cd93..8eb7bde42 100644
--- a/frontend/i18n/messages/en-US/admin-models.json
+++ b/frontend/i18n/messages/en-US/admin-models.json
@@ -383,7 +383,7 @@
"toolsIntro": "Configure official native tools allowed for this model. Only tools enabled here can enter conversation settings.",
"defaultsHelp": "Default parameters are written to defaultOptions. value is parsed as a JSON value, for example 0.7, true, null, \"high\", or an object.",
"controlsHelp": "Parameter controls are written to optionControls. path maps to the real request option path, and final passthrough is still controlled by the option policy.",
- "toolsHelp": "Official native tools are written to nativeTools. One tool can target multiple protocols; default-enabled tools are added to the user's default options automatically. User JSON tools are preserved by the backend only when they match these official tools.",
+ "toolsHelp": "Official native tools are written to nativeTools. Tools marked as default-enabled are added to the model's default request options; tools in user JSON are preserved only when they match an enabled official tool.",
"addParameter": "Add",
"addNativeTool": "Add",
"emptyParameters": "No parameters",
@@ -454,7 +454,7 @@
"policyTab": "Policy",
"defaultsDescription": "defaultOptions becomes this model's default request parameters. The chat page uses it when the user has no saved local parameters for the model; backend option policy still applies before sending.",
"controlsDescription": "optionControls only defines how the chat parameters dialog renders controls. It is not sent to the model by itself. path maps to the real options path.",
- "toolsDescription": "nativeTools defines the provider-hosted official tools allowed for this model. Each tool is configured once, and protocols lists the applicable request protocols; a multi-protocol tool does not need duplicate rows.",
+ "toolsDescription": "nativeTools defines the provider-hosted official tools allowed for this model, and protocols specifies where each definition applies. Requests use only tool definitions that match the active protocol.",
"toolsAutoDescription": "When users hand-write options.tools, only admin-allowed official tools are preserved. Parameters on allowed tools pass through; unknown tools and MCP/function tools cannot be injected through JSON.",
"controlTypes": "type supports text, select, number, and boolean. select controls should provide options.",
"policyDescription": "Rendering a control does not guarantee passthrough. The same path still needs to be allowed by the option policy; the backend filters options before sending."
diff --git a/frontend/i18n/messages/en-US/chat.json b/frontend/i18n/messages/en-US/chat.json
index b332728f6..545467ccf 100644
--- a/frontend/i18n/messages/en-US/chat.json
+++ b/frontend/i18n/messages/en-US/chat.json
@@ -689,6 +689,9 @@
"generationConfig__thinkingConfig__includeThoughts": "Include thoughts",
"generationConfig__thinkingConfig__thinkingBudget": "Thinking budget",
"generationConfig__thinkingConfig__thinkingLevel": "Thinking level",
+ "generation_config__max_output_tokens": "Max output tokens",
+ "generation_config__thinking_level": "Thinking level",
+ "generation_config__thinking_summaries": "Thinking summaries",
"generationConfig__topK": "Top K",
"logprobs": "Logprobs count",
"max_completion_tokens": "Max output tokens",
@@ -777,6 +780,9 @@
"generationConfig__thinkingConfig__includeThoughts": "Controls whether Gemini thinking content is included.",
"generationConfig__thinkingConfig__thinkingBudget": "Gemini thinking token budget.",
"generationConfig__thinkingConfig__thinkingLevel": "Gemini thinking level.",
+ "generation_config__max_output_tokens": "Limits the maximum number of tokens included in the Gemini response.",
+ "generation_config__thinking_level": "Controls the depth of thinking used by the Gemini model.",
+ "generation_config__thinking_summaries": "Controls whether the Gemini response includes thinking summaries.",
"generationConfig__topK": "Gemini Top K sampling range.",
"imageConfig__aspectRatio": "Google image output aspect ratio.",
"imageConfig__imageSize": "Google image output size tier.",
diff --git a/frontend/i18n/messages/zh-CN/admin-models.json b/frontend/i18n/messages/zh-CN/admin-models.json
index 23d462600..a43eacee8 100644
--- a/frontend/i18n/messages/zh-CN/admin-models.json
+++ b/frontend/i18n/messages/zh-CN/admin-models.json
@@ -383,7 +383,7 @@
"toolsIntro": "配置该模型允许使用的官方原生工具;只有管理员在这里开启的官方工具才会进入会话配置。",
"defaultsHelp": "默认参数会写入 defaultOptions。value 按 JSON 值解析,例如 0.7、true、null、\"high\" 或对象。",
"controlsHelp": "参数控件会写入 optionControls。path 对应真实请求参数路径,最终是否透传仍由参数过滤规则决定。",
- "toolsHelp": "原生官方工具会写入 nativeTools。同一个工具可配置多个适用协议;默认开启会自动进入用户默认参数;用户 JSON 中的 tools 只有命中这里的官方工具才会被后端保留。",
+ "toolsHelp": "官方原生工具会写入 nativeTools。设为默认开启的工具会加入模型默认请求参数;用户 JSON 中的 tools 仅在匹配已启用的官方工具时保留。",
"addParameter": "新增",
"addNativeTool": "新增",
"emptyParameters": "暂无参数",
@@ -454,7 +454,7 @@
"policyTab": "过滤规则",
"defaultsDescription": "defaultOptions 会作为该模型的默认请求参数。用户未保存过本地参数时,聊天页会使用这里的值;最终发送前仍会经过后端参数策略。",
"controlsDescription": "optionControls 只定义聊天参数 Dialog 如何展示控件,不会单独发送给模型。path 对应真实 options 路径。",
- "toolsDescription": "nativeTools 定义当前模型允许的厂商官方原生工具。每个工具一行,protocols 表示适用协议列表;同一工具支持多个协议时不需要拆成多条配置。",
+ "toolsDescription": "nativeTools 定义当前模型允许使用的厂商官方原生工具,protocols 指定适用协议;请求时系统只会使用与当前协议匹配的工具定义。",
"toolsAutoDescription": "用户侧手写 options.tools 时,只会保留管理员允许的官方工具;已允许工具的子参数会透传,未知工具和 MCP/function tool 不会通过 JSON 注入。",
"controlTypes": "type 支持 text、select、number、boolean;select 需要提供 options。",
"policyDescription": "控件展示不等于一定透传。对应 path 仍需要在参数过滤规则中允许,后端发送前会统一过滤。"
diff --git a/frontend/i18n/messages/zh-CN/chat.json b/frontend/i18n/messages/zh-CN/chat.json
index c03014bf9..7948330ce 100644
--- a/frontend/i18n/messages/zh-CN/chat.json
+++ b/frontend/i18n/messages/zh-CN/chat.json
@@ -689,6 +689,9 @@
"generationConfig__thinkingConfig__includeThoughts": "思考内容",
"generationConfig__thinkingConfig__thinkingBudget": "思考预算",
"generationConfig__thinkingConfig__thinkingLevel": "思考级别",
+ "generation_config__max_output_tokens": "最大输出 Tokens",
+ "generation_config__thinking_level": "思考级别",
+ "generation_config__thinking_summaries": "思考摘要",
"generationConfig__topK": "Top K",
"logprobs": "Logprobs 数量",
"max_completion_tokens": "最大输出 Tokens",
@@ -777,6 +780,9 @@
"generationConfig__thinkingConfig__includeThoughts": "控制是否包含 Gemini 思考内容。",
"generationConfig__thinkingConfig__thinkingBudget": "Gemini 思考 token 预算。",
"generationConfig__thinkingConfig__thinkingLevel": "Gemini 思考级别。",
+ "generation_config__max_output_tokens": "限制 Gemini 响应包含的最大 token 数。",
+ "generation_config__thinking_level": "控制 Gemini 模型的思考深度。",
+ "generation_config__thinking_summaries": "控制 Gemini 响应是否包含思考摘要。",
"generationConfig__topK": "Gemini Top K 采样范围。",
"imageConfig__aspectRatio": "Google 图片输出画幅比例。",
"imageConfig__imageSize": "Google 图片输出尺寸档位。",