Merge pull request #2681 from afjcjsbx/fix/gemini-mcp-schema-sanitization

fix(mcp): sanitize MCP tool schemas for Gemini function calling
This commit is contained in:
Mauro 2026-05-03 20:25:35 +02:00 committed by GitHub
commit 828a7cba70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1663 additions and 248 deletions

View file

@ -43,6 +43,7 @@
}, },
{ {
"model_name": "gemini", "model_name": "gemini",
"_comment": "Optional: set \"tool_schema_transform\": \"simple\" for providers that reject complex tool JSON Schema.",
"model": "antigravity/gemini-2.0-flash", "model": "antigravity/gemini-2.0-flash",
"auth_method": "oauth" "auth_method": "oauth"
}, },

View file

@ -116,23 +116,47 @@ This design also enables **multi-agent support** with flexible provider selectio
#### `model_list` Entry Fields #### `model_list` Entry Fields
| Field | Type | Required | Description | | Field | Type | Required | Description |
|-------|------|----------|-------------| |-------|------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `model_name` | string | Yes | Unique name used to reference this model in agent config | | `model_name` | string | Yes | Unique name used to reference this model in agent config |
| `provider` | string | No | Preferred provider identifier. When present, PicoClaw sends `model` unchanged to that provider | | `provider` | string | No | Preferred provider identifier. When present, PicoClaw sends `model` unchanged to that provider |
| `model` | string | Yes | Native model ID when `provider` is set. If `provider` is omitted, the legacy `provider/model` form is still supported | | `model` | string | Yes | Native model ID when `provider` is set. If `provider` is omitted, the legacy `provider/model` form is still supported |
| `api_keys` | string[] | Yes* | API key(s) for authentication. Multiple keys enable per-request rotation. Not required for local providers (Ollama, LM Studio, VLLM) | | `api_keys` | string[] | Yes* | API key(s) for authentication. Multiple keys enable per-request rotation. Not required for local providers (Ollama, LM Studio, VLLM) |
| `api_base` | string | No | Override the default API endpoint URL | | `api_base` | string | No | Override the default API endpoint URL |
| `proxy` | string | No | HTTP proxy URL for this model entry | | `proxy` | string | No | HTTP proxy URL for this model entry |
| `user_agent` | string | No | Custom `User-Agent` header sent with API requests (supported by OpenAI-compatible, Gemini, Anthropic, and Azure providers) | | `user_agent` | string | No | Custom `User-Agent` header sent with API requests (supported by OpenAI-compatible, Gemini, Anthropic, and Azure providers) |
| `request_timeout` | int | No | Request timeout in seconds (default varies by provider) | | `request_timeout` | int | No | Request timeout in seconds (default varies by provider) |
| `max_tokens_field` | string | No | Override the max tokens field name in request body (e.g., `max_completion_tokens` for o1 models) | | `max_tokens_field` | string | No | Override the max tokens field name in request body (e.g., `max_completion_tokens` for o1 models) |
| `thinking_level` | string | No | Extended thinking level: `off`, `low`, `medium`, `high`, `xhigh`, or `adaptive` | | `thinking_level` | string | No | Extended thinking level: `off`, `low`, `medium`, `high`, `xhigh`, or `adaptive` |
| `extra_body` | object | No | Additional fields to inject into every request body | | `tool_schema_transform` | string | No | Optional compatibility transform for tool parameter schemas. Default: disabled. Supported values: `simple`. |
| `extra_body` | object | No | Additional fields to inject into every request body |
| `custom_headers` | object | No | Additional HTTP headers to inject into every request (e.g., `{"X-Source":"coding-plan"}`). If a key matches a built-in header, the custom value overrides the built-in one (e.g., `Authorization`, `User-Agent`, `Content-Type`, `Accept`). | | `custom_headers` | object | No | Additional HTTP headers to inject into every request (e.g., `{"X-Source":"coding-plan"}`). If a key matches a built-in header, the custom value overrides the built-in one (e.g., `Authorization`, `User-Agent`, `Content-Type`, `Accept`). |
| `rpm` | int | No | Per-minute request rate limit | | `rpm` | int | No | Per-minute request rate limit |
| `fallbacks` | string[] | No | Fallback model names for automatic failover | | `fallbacks` | string[] | No | Fallback model names for automatic failover |
| `enabled` | bool | No | Whether this model entry is active (default: `true`) | | `enabled` | bool | No | Whether this model entry is active (default: `true`) |
#### Tool Schema Compatibility
By default, PicoClaw now forwards tool JSON Schemas unchanged.
Some providers reject advanced JSON Schema features such as `$ref`, `$defs`, `anyOf`, `oneOf`, `allOf`, `pattern`, or numeric/string constraints inside tool declarations. For those models, you can opt into a compatibility transform per model entry with `tool_schema_transform`.
Use `simple` when the upstream provider expects the conservative style function schema subset:
```json
{
"model_name": "gemini-2.5-flash-safe-tools",
"provider": "gemini",
"model": "gemini-2.5-flash",
"api_keys": ["your-gemini-key"],
"tool_schema_transform": "simple"
}
```
Notes:
- Default behavior is disabled. If you omit `tool_schema_transform`, PicoClaw sends the original tool schema.
- The setting is per model entry, so you can enable it only for the providers that need it.
#### Provider / Model Resolution #### Provider / Model Resolution

View file

@ -17,6 +17,7 @@ import (
"github.com/sipeed/picoclaw/pkg" "github.com/sipeed/picoclaw/pkg"
"github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
providercommon "github.com/sipeed/picoclaw/pkg/providers/common"
) )
// rrCounter is a global counter for round-robin load balancing across models. // rrCounter is a global counter for round-robin load balancing across models.
@ -555,12 +556,13 @@ type ModelConfig struct {
Workspace string `json:"workspace,omitempty"` // Workspace path for CLI-based providers Workspace string `json:"workspace,omitempty"` // Workspace path for CLI-based providers
// Optional optimizations // Optional optimizations
RPM int `json:"rpm,omitempty"` // Requests per minute limit RPM int `json:"rpm,omitempty"` // Requests per minute limit
MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens") MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
RequestTimeout int `json:"request_timeout,omitempty"` RequestTimeout int `json:"request_timeout,omitempty"`
ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive
ExtraBody map[string]any `json:"extra_body,omitempty"` // Additional fields to inject into request body ToolSchemaTransform string `json:"tool_schema_transform,omitempty"` // Optional tool schema compatibility transform (e.g. "simple")
CustomHeaders map[string]string `json:"custom_headers,omitempty"` // Additional headers to inject into every HTTP request ExtraBody map[string]any `json:"extra_body,omitempty"` // Additional fields to inject into request body
CustomHeaders map[string]string `json:"custom_headers,omitempty"` // Additional headers to inject into every HTTP request
APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty"` // API authentication keys (multiple keys for failover) APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty"` // API authentication keys (multiple keys for failover)
@ -597,6 +599,9 @@ func (c *ModelConfig) Validate() error {
if c.Model == "" { if c.Model == "" {
return fmt.Errorf("model is required") return fmt.Errorf("model is required")
} }
if _, err := providercommon.NormalizeToolSchemaTransform(c.ToolSchemaTransform); err != nil {
return err
}
return nil return nil
} }
@ -1468,23 +1473,24 @@ func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig {
// Create a copy for the additional key // Create a copy for the additional key
additionalEntry := &ModelConfig{ additionalEntry := &ModelConfig{
ModelName: expandedName, ModelName: expandedName,
Provider: m.Provider, Provider: m.Provider,
Model: m.Model, Model: m.Model,
APIBase: m.APIBase, APIBase: m.APIBase,
APIKeys: SimpleSecureStrings(keys[i]), APIKeys: SimpleSecureStrings(keys[i]),
Proxy: m.Proxy, Proxy: m.Proxy,
AuthMethod: m.AuthMethod, AuthMethod: m.AuthMethod,
ConnectMode: m.ConnectMode, ConnectMode: m.ConnectMode,
Workspace: m.Workspace, Workspace: m.Workspace,
RPM: m.RPM, RPM: m.RPM,
MaxTokensField: m.MaxTokensField, MaxTokensField: m.MaxTokensField,
RequestTimeout: m.RequestTimeout, RequestTimeout: m.RequestTimeout,
ThinkingLevel: m.ThinkingLevel, ThinkingLevel: m.ThinkingLevel,
ExtraBody: m.ExtraBody, ToolSchemaTransform: m.ToolSchemaTransform,
CustomHeaders: m.CustomHeaders, ExtraBody: m.ExtraBody,
UserAgent: m.UserAgent, CustomHeaders: m.CustomHeaders,
isVirtual: true, UserAgent: m.UserAgent,
isVirtual: true,
} }
expanded = append(expanded, additionalEntry) expanded = append(expanded, additionalEntry)
fallbackNames = append(fallbackNames, expandedName) fallbackNames = append(fallbackNames, expandedName)
@ -1492,22 +1498,23 @@ func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig {
// Create the primary entry with first key and fallbacks // Create the primary entry with first key and fallbacks
primaryEntry := &ModelConfig{ primaryEntry := &ModelConfig{
ModelName: originalName, ModelName: originalName,
Provider: m.Provider, Provider: m.Provider,
Model: m.Model, Model: m.Model,
APIBase: m.APIBase, APIBase: m.APIBase,
Proxy: m.Proxy, Proxy: m.Proxy,
AuthMethod: m.AuthMethod, AuthMethod: m.AuthMethod,
ConnectMode: m.ConnectMode, ConnectMode: m.ConnectMode,
Workspace: m.Workspace, Workspace: m.Workspace,
RPM: m.RPM, RPM: m.RPM,
MaxTokensField: m.MaxTokensField, MaxTokensField: m.MaxTokensField,
RequestTimeout: m.RequestTimeout, RequestTimeout: m.RequestTimeout,
ThinkingLevel: m.ThinkingLevel, ThinkingLevel: m.ThinkingLevel,
ExtraBody: m.ExtraBody, ToolSchemaTransform: m.ToolSchemaTransform,
CustomHeaders: m.CustomHeaders, ExtraBody: m.ExtraBody,
UserAgent: m.UserAgent, CustomHeaders: m.CustomHeaders,
APIKeys: SimpleSecureStrings(keys[0]), UserAgent: m.UserAgent,
APIKeys: SimpleSecureStrings(keys[0]),
} }
// Prepend new fallbacks to existing ones // Prepend new fallbacks to existing ones

View file

@ -1998,6 +1998,36 @@ func TestModelConfig_CustomHeadersRoundTrip(t *testing.T) {
} }
} }
func TestModelConfig_ToolSchemaTransformRoundTrip(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
cfg := &Config{
Version: CurrentVersion,
ModelList: []*ModelConfig{
{
ModelName: "test-model",
Model: "openai/test",
APIKeys: SimpleSecureStrings("sk-test"),
ToolSchemaTransform: "simple",
},
},
}
if err := SaveConfig(cfgPath, cfg); err != nil {
t.Fatalf("SaveConfig error: %v", err)
}
loaded, err := LoadConfig(cfgPath)
if err != nil {
t.Fatalf("LoadConfig error: %v", err)
}
if got := loaded.ModelList[0].ToolSchemaTransform; got != "simple" {
t.Fatalf("ToolSchemaTransform = %q, want %q", got, "simple")
}
}
func TestDefaultConfig_MinimaxExtraBody(t *testing.T) { func TestDefaultConfig_MinimaxExtraBody(t *testing.T) {
cfg := DefaultConfig() cfg := DefaultConfig()

View file

@ -158,6 +158,15 @@ func TestModelConfig_Validate(t *testing.T) {
}, },
wantErr: false, wantErr: false,
}, },
{
name: "valid tool schema transform",
config: ModelConfig{
ModelName: "test",
Model: "openai/gpt-4o",
ToolSchemaTransform: "simple",
},
wantErr: false,
},
{ {
name: "missing model_name", name: "missing model_name",
config: ModelConfig{ config: ModelConfig{
@ -177,6 +186,15 @@ func TestModelConfig_Validate(t *testing.T) {
config: ModelConfig{}, config: ModelConfig{},
wantErr: true, wantErr: true,
}, },
{
name: "invalid tool schema transform",
config: ModelConfig{
ModelName: "test",
Model: "openai/gpt-4o",
ToolSchemaTransform: "invalid",
},
wantErr: true,
},
} }
for _, tt := range tests { for _, tt := range tests {

View file

@ -187,15 +187,16 @@ func TestExpandMultiKeyModels_Deduplication(t *testing.T) {
func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) { func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) {
modelCfg := &ModelConfig{ modelCfg := &ModelConfig{
ModelName: "gpt-4", ModelName: "gpt-4",
Provider: "openrouter", Provider: "openrouter",
Model: "openai/gpt-4o", Model: "openai/gpt-4o",
APIBase: "https://api.example.com", APIBase: "https://api.example.com",
Proxy: "http://proxy:8080", Proxy: "http://proxy:8080",
RPM: 60, RPM: 60,
MaxTokensField: "max_completion_tokens", MaxTokensField: "max_completion_tokens",
RequestTimeout: 30, RequestTimeout: 30,
ThinkingLevel: "high", ThinkingLevel: "high",
ToolSchemaTransform: "simple",
} }
modelCfg.APIKeys = SimpleSecureStrings("key0", "key1") // Use internal field for multi-key testing modelCfg.APIKeys = SimpleSecureStrings("key0", "key1") // Use internal field for multi-key testing
models := []*ModelConfig{modelCfg} models := []*ModelConfig{modelCfg}
@ -225,6 +226,9 @@ func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) {
if primary.ThinkingLevel != "high" { if primary.ThinkingLevel != "high" {
t.Errorf("expected thinking_level preserved, got %q", primary.ThinkingLevel) t.Errorf("expected thinking_level preserved, got %q", primary.ThinkingLevel)
} }
if primary.ToolSchemaTransform != "simple" {
t.Errorf("expected tool_schema_transform preserved, got %q", primary.ToolSchemaTransform)
}
// Check additional entry also preserves fields // Check additional entry also preserves fields
additional := result[0] additional := result[0]
@ -237,6 +241,9 @@ func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) {
if additional.RPM != 60 { if additional.RPM != 60 {
t.Errorf("expected additional rpm preserved, got %d", additional.RPM) t.Errorf("expected additional rpm preserved, got %d", additional.RPM)
} }
if additional.ToolSchemaTransform != "simple" {
t.Errorf("expected additional tool_schema_transform preserved, got %q", additional.ToolSchemaTransform)
}
} }
func TestExpandMultiKeyModels_IsVirtualFlag(t *testing.T) { func TestExpandMultiKeyModels_IsVirtualFlag(t *testing.T) {

View file

@ -0,0 +1,642 @@
package common
import (
"strconv"
"strings"
)
const maxGeminiSchemaDepth = 64
var geminiSupportedTypes = map[string]bool{
"array": true,
"boolean": true,
"integer": true,
"number": true,
"object": true,
"string": true,
}
// SanitizeSchemaForGoogle reduces a JSON Schema to the conservative subset
// accepted by Google/Gemini-style function declarations. It resolves local
// refs, collapses composition keywords like anyOf/oneOf/allOf, and strips
// advanced keywords that Gemini-compatible backends often reject.
func SanitizeSchemaForGoogle(schema map[string]any) map[string]any {
if schema == nil {
return nil
}
sanitizer := geminiSchemaSanitizer{root: schema}
result := sanitizer.sanitizeNode(schema, nil, 0)
if len(result) == 0 {
return map[string]any{
"type": "object",
"properties": map[string]any{},
}
}
if _, hasProps := result["properties"]; hasProps {
result["type"] = "object"
}
return result
}
// SanitizeSchemaForGemini is kept as a compatibility alias for the original
// Google/Gemini sanitizer name.
func SanitizeSchemaForGemini(schema map[string]any) map[string]any {
return SanitizeSchemaForGoogle(schema)
}
type geminiSchemaSanitizer struct {
root map[string]any
}
func (s geminiSchemaSanitizer) sanitizeNode(
node map[string]any,
refTrail map[string]struct{},
depth int,
) map[string]any {
if node == nil || depth > maxGeminiSchemaDepth {
return map[string]any{}
}
normalized := s.normalizeNode(node, refTrail, depth)
if len(normalized) == 0 {
return map[string]any{}
}
result := make(map[string]any)
if desc, ok := normalized["description"].(string); ok && strings.TrimSpace(desc) != "" {
result["description"] = desc
}
if schemaType := sanitizeGeminiSchemaType(normalized["type"]); schemaType != "" {
result["type"] = schemaType
}
if enumValues := sanitizeGeminiEnum(normalized["enum"]); len(enumValues) > 0 {
result["enum"] = enumValues
}
if propsRaw, ok := normalized["properties"].(map[string]any); ok {
props := make(map[string]any, len(propsRaw))
for name, rawProp := range propsRaw {
propSchema, ok := rawProp.(map[string]any)
if !ok {
continue
}
sanitizedProp := s.sanitizeNode(propSchema, refTrail, depth+1)
if len(sanitizedProp) == 0 {
sanitizedProp = map[string]any{}
}
props[name] = sanitizedProp
}
result["properties"] = props
result["type"] = "object"
if required := sanitizeGeminiRequired(normalized["required"], props); len(required) > 0 {
result["required"] = required
}
}
if itemsRaw, ok := normalized["items"].(map[string]any); ok {
items := s.sanitizeNode(itemsRaw, refTrail, depth+1)
if len(items) == 0 {
items = map[string]any{}
}
result["items"] = items
if _, hasType := result["type"]; !hasType {
result["type"] = "array"
}
}
return result
}
func (s geminiSchemaSanitizer) normalizeNode(
node map[string]any,
refTrail map[string]struct{},
depth int,
) map[string]any {
if node == nil || depth > maxGeminiSchemaDepth {
return map[string]any{}
}
normalized := cloneGeminiSchemaMap(node)
if ref, ok := normalized["$ref"].(string); ok {
delete(normalized, "$ref")
if _, seen := refTrail[ref]; !seen {
if target, ok := s.resolveLocalSchemaRef(ref); ok {
nextTrail := cloneRefTrail(refTrail)
nextTrail[ref] = struct{}{}
normalized = mergeGeminiSchemaMaps(
s.normalizeNode(target, nextTrail, depth+1),
normalized,
)
}
}
}
if rawAllOf, ok := normalized["allOf"]; ok {
delete(normalized, "allOf")
for _, part := range schemaSlice(rawAllOf) {
normalized = mergeGeminiSchemaMaps(
normalized,
s.normalizeNode(part, refTrail, depth+1),
)
}
}
if rawAnyOf, ok := normalized["anyOf"]; ok {
delete(normalized, "anyOf")
normalized = mergeGeminiSchemaMaps(
s.mergeUnionBranches(schemaSlice(rawAnyOf), refTrail, depth+1),
normalized,
)
}
if rawOneOf, ok := normalized["oneOf"]; ok {
delete(normalized, "oneOf")
normalized = mergeGeminiSchemaMaps(
s.mergeUnionBranches(schemaSlice(rawOneOf), refTrail, depth+1),
normalized,
)
}
return normalized
}
func (s geminiSchemaSanitizer) mergeUnionBranches(
branches []map[string]any,
refTrail map[string]struct{},
depth int,
) map[string]any {
if len(branches) == 0 {
return map[string]any{}
}
objectBranches := make([]map[string]any, 0, len(branches))
arrayBranches := make([]map[string]any, 0, len(branches))
nonNullBranches := make([]map[string]any, 0, len(branches))
sameType := ""
sameTypeConsistent := true
for _, branch := range branches {
normalized := s.normalizeNode(branch, refTrail, depth+1)
if len(normalized) == 0 {
continue
}
branchType := geminiSchemaBranchType(normalized["type"])
if branchType == "null" {
continue
}
nonNullBranches = append(nonNullBranches, normalized)
if sameType == "" {
sameType = branchType
} else if branchType != "" && branchType != sameType {
sameTypeConsistent = false
}
if branchType == "object" || hasSchemaProperties(normalized) {
objectBranches = append(objectBranches, normalized)
continue
}
if branchType == "array" || hasSchemaItems(normalized) {
arrayBranches = append(arrayBranches, normalized)
}
}
if len(nonNullBranches) == 0 {
return map[string]any{}
}
if len(objectBranches) > 0 {
return mergeUnionObjectSchemas(objectBranches)
}
if len(arrayBranches) == len(nonNullBranches) && len(arrayBranches) > 0 {
return mergeUnionArraySchemas(arrayBranches)
}
if sameTypeConsistent && sameType != "" {
merged := map[string]any{}
for _, branch := range nonNullBranches {
merged = mergeGeminiSchemaMaps(merged, branch)
}
return merged
}
best := nonNullBranches[0]
bestScore := geminiUnionBranchScore(best)
for _, branch := range nonNullBranches[1:] {
if score := geminiUnionBranchScore(branch); score > bestScore {
best = branch
bestScore = score
}
}
return cloneGeminiSchemaMap(best)
}
func (s geminiSchemaSanitizer) resolveLocalSchemaRef(ref string) (map[string]any, bool) {
if ref == "#" {
return s.root, true
}
if !strings.HasPrefix(ref, "#/") {
return nil, false
}
var current any = s.root
for _, rawToken := range strings.Split(strings.TrimPrefix(ref, "#/"), "/") {
token := strings.ReplaceAll(strings.ReplaceAll(rawToken, "~1", "/"), "~0", "~")
switch value := current.(type) {
case map[string]any:
next, ok := value[token]
if !ok {
return nil, false
}
current = next
case []any:
index, err := strconv.Atoi(token)
if err != nil || index < 0 || index >= len(value) {
return nil, false
}
current = value[index]
default:
return nil, false
}
}
resolved, ok := current.(map[string]any)
return resolved, ok
}
func mergeUnionObjectSchemas(branches []map[string]any) map[string]any {
merged := map[string]any{
"type": "object",
"properties": map[string]any{},
}
var commonRequired map[string]struct{}
var requiredOrder []string
for i, branch := range branches {
merged = mergeGeminiSchemaMaps(merged, branch)
required := requiredStrings(branch["required"])
if i == 0 {
commonRequired = make(map[string]struct{}, len(required))
requiredOrder = append(requiredOrder, required...)
for _, name := range required {
commonRequired[name] = struct{}{}
}
continue
}
current := make(map[string]struct{}, len(required))
for _, name := range required {
current[name] = struct{}{}
}
for name := range commonRequired {
if _, ok := current[name]; !ok {
delete(commonRequired, name)
}
}
}
if len(commonRequired) > 0 {
filtered := make([]string, 0, len(commonRequired))
for _, name := range requiredOrder {
if _, ok := commonRequired[name]; ok {
filtered = append(filtered, name)
}
}
if len(filtered) > 0 {
merged["required"] = filtered
}
} else {
delete(merged, "required")
}
return merged
}
func mergeUnionArraySchemas(branches []map[string]any) map[string]any {
merged := map[string]any{
"type": "array",
}
for _, branch := range branches {
merged = mergeGeminiSchemaMaps(merged, branch)
}
return merged
}
func mergeGeminiSchemaMaps(base map[string]any, overlay map[string]any) map[string]any {
if len(base) == 0 {
return cloneGeminiSchemaMap(overlay)
}
if len(overlay) == 0 {
return cloneGeminiSchemaMap(base)
}
result := cloneGeminiSchemaMap(base)
for key, value := range overlay {
switch key {
case "properties":
overlayProps, ok := value.(map[string]any)
if !ok {
continue
}
existing, _ := result["properties"].(map[string]any)
mergedProps := cloneGeminiSchemaMap(existing)
if mergedProps == nil {
mergedProps = make(map[string]any, len(overlayProps))
}
for name, rawProp := range overlayProps {
propSchema, ok := rawProp.(map[string]any)
if !ok {
continue
}
if existingProp, ok := mergedProps[name].(map[string]any); ok {
mergedProps[name] = mergeGeminiSchemaMaps(existingProp, propSchema)
} else {
mergedProps[name] = cloneGeminiSchemaMap(propSchema)
}
}
result["properties"] = mergedProps
case "items":
overlayItems, ok := value.(map[string]any)
if !ok {
continue
}
if existingItems, ok := result["items"].(map[string]any); ok {
result["items"] = mergeGeminiSchemaMaps(existingItems, overlayItems)
} else {
result["items"] = cloneGeminiSchemaMap(overlayItems)
}
case "required":
if merged := mergeRequiredLists(result["required"], value); len(merged) > 0 {
result["required"] = merged
}
case "type":
if mergedType := mergeGeminiSchemaTypes(result["type"], value); mergedType != "" {
result["type"] = mergedType
} else {
delete(result, "type")
}
case "description":
desc, ok := value.(string)
if ok && strings.TrimSpace(desc) != "" {
result["description"] = desc
}
default:
result[key] = cloneGeminiSchemaValue(value)
}
}
return result
}
func mergeGeminiSchemaTypes(left any, right any) string {
leftType := geminiSchemaBranchType(left)
rightType := geminiSchemaBranchType(right)
switch {
case leftType == "":
return rightType
case rightType == "":
return leftType
case leftType == rightType:
return leftType
case leftType == "null":
return rightType
case rightType == "null":
return leftType
default:
return ""
}
}
func sanitizeGeminiSchemaType(raw any) string {
typeName := geminiSchemaBranchType(raw)
if typeName == "null" {
return ""
}
return typeName
}
func geminiSchemaBranchType(raw any) string {
switch value := raw.(type) {
case string:
if value == "null" {
return value
}
if geminiSupportedTypes[value] {
return value
}
return ""
case []string:
return geminiSchemaBranchType(stringSliceToAny(value))
case []any:
candidate := ""
sawNull := false
for _, item := range value {
typeName, ok := item.(string)
if !ok {
continue
}
if typeName == "null" {
sawNull = true
continue
}
if !geminiSupportedTypes[typeName] {
continue
}
if candidate == "" {
candidate = typeName
continue
}
if candidate != typeName {
return ""
}
}
if candidate == "" && sawNull {
return "null"
}
return candidate
default:
return ""
}
}
func sanitizeGeminiEnum(raw any) []any {
values, ok := raw.([]any)
if !ok {
if stringValues, ok := raw.([]string); ok {
return stringSliceToAny(stringValues)
}
return nil
}
sanitized := make([]any, 0, len(values))
for _, value := range values {
switch value.(type) {
case string, bool, float64, int, int32, int64:
sanitized = append(sanitized, value)
}
}
if len(sanitized) == 0 {
return nil
}
return sanitized
}
func sanitizeGeminiRequired(raw any, properties map[string]any) []string {
required := requiredStrings(raw)
if len(required) == 0 {
return nil
}
filtered := make([]string, 0, len(required))
seen := make(map[string]struct{}, len(required))
for _, name := range required {
if _, ok := properties[name]; !ok {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
filtered = append(filtered, name)
}
if len(filtered) == 0 {
return nil
}
return filtered
}
func requiredStrings(raw any) []string {
switch value := raw.(type) {
case []string:
return append([]string(nil), value...)
case []any:
required := make([]string, 0, len(value))
for _, item := range value {
name, ok := item.(string)
if ok {
required = append(required, name)
}
}
return required
default:
return nil
}
}
func mergeRequiredLists(left any, right any) []string {
merged := make([]string, 0)
seen := map[string]struct{}{}
for _, name := range append(requiredStrings(left), requiredStrings(right)...) {
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
merged = append(merged, name)
}
return merged
}
func geminiUnionBranchScore(schema map[string]any) int {
score := 0
if hasSchemaProperties(schema) {
score += 20
}
if hasSchemaItems(schema) {
score += 10
}
if _, ok := schema["enum"]; ok {
score += 5
}
if _, ok := schema["description"]; ok {
score += 2
}
score += len(schema)
return score
}
func hasSchemaProperties(schema map[string]any) bool {
props, ok := schema["properties"].(map[string]any)
return ok && len(props) > 0
}
func hasSchemaItems(schema map[string]any) bool {
_, ok := schema["items"].(map[string]any)
return ok
}
func schemaSlice(raw any) []map[string]any {
switch value := raw.(type) {
case []map[string]any:
return append([]map[string]any(nil), value...)
case []any:
schemas := make([]map[string]any, 0, len(value))
for _, item := range value {
schema, ok := item.(map[string]any)
if ok {
schemas = append(schemas, schema)
}
}
return schemas
default:
return nil
}
}
func cloneGeminiSchemaMap(in map[string]any) map[string]any {
if len(in) == 0 {
return nil
}
out := make(map[string]any, len(in))
for key, value := range in {
out[key] = cloneGeminiSchemaValue(value)
}
return out
}
func cloneGeminiSchemaValue(value any) any {
switch typed := value.(type) {
case map[string]any:
return cloneGeminiSchemaMap(typed)
case []any:
out := make([]any, len(typed))
for i, item := range typed {
out[i] = cloneGeminiSchemaValue(item)
}
return out
case []string:
return append([]string(nil), typed...)
default:
return typed
}
}
func cloneRefTrail(in map[string]struct{}) map[string]struct{} {
if len(in) == 0 {
return make(map[string]struct{})
}
out := make(map[string]struct{}, len(in))
for key := range in {
out[key] = struct{}{}
}
return out
}
func stringSliceToAny(values []string) []any {
if len(values) == 0 {
return nil
}
result := make([]any, len(values))
for i, value := range values {
result[i] = value
}
return result
}

View file

@ -0,0 +1,254 @@
package common
import "testing"
func TestSanitizeSchemaForGemini_DereferencesRefsAndFlattensUnions(t *testing.T) {
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"},
},
},
"icon": map[string]any{
"anyOf": []any{
map[string]any{"$ref": "#/$defs/emoji"},
map[string]any{"type": "null"},
},
},
"data": map[string]any{
"$ref": "#/$defs/dataPayload",
},
},
"required": []any{"parent", "icon", "missing"},
"$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"},
},
"emoji": map[string]any{
"type": "string",
"pattern": "^:[a-z_]+:$",
},
"dataPayload": map[string]any{
"type": "object",
"additionalProperties": false,
"properties": map[string]any{
"name": map[string]any{
"type": "string",
"minLength": 1,
},
"count": map[string]any{
"type": "integer",
"minimum": 1,
},
},
"required": []any{"name"},
},
},
}
got := SanitizeSchemaForGemini(schema)
assertSchemaKeyAbsent(t, got, "$defs")
assertSchemaKeyAbsent(t, got, "$ref")
assertSchemaKeyAbsent(t, got, "anyOf")
assertSchemaKeyAbsent(t, got, "oneOf")
assertSchemaKeyAbsent(t, got, "allOf")
assertSchemaKeyAbsent(t, got, "additionalProperties")
assertSchemaKeyAbsent(t, got, "pattern")
assertSchemaKeyAbsent(t, got, "minLength")
assertSchemaKeyAbsent(t, got, "minimum")
if got["type"] != "object" {
t.Fatalf("top-level type = %#v, want object", got["type"])
}
props, ok := got["properties"].(map[string]any)
if !ok {
t.Fatalf("properties = %#v, want map", got["properties"])
}
parent, ok := props["parent"].(map[string]any)
if !ok {
t.Fatalf("parent schema = %#v, want map", props["parent"])
}
if parent["type"] != "object" {
t.Fatalf("parent.type = %#v, want object", parent["type"])
}
parentProps, ok := parent["properties"].(map[string]any)
if !ok {
t.Fatalf("parent.properties = %#v, want map", parent["properties"])
}
if _, found := parentProps["page_id"]; !found {
t.Fatalf("parent.properties missing page_id: %#v", parentProps)
}
if _, found := parentProps["database_id"]; !found {
t.Fatalf("parent.properties missing database_id: %#v", parentProps)
}
if _, hasRequired := parent["required"]; hasRequired {
t.Fatalf("parent.required = %#v, want omitted for merged anyOf branches", parent["required"])
}
icon, ok := props["icon"].(map[string]any)
if !ok {
t.Fatalf("icon schema = %#v, want map", props["icon"])
}
if icon["type"] != "string" {
t.Fatalf("icon.type = %#v, want string", icon["type"])
}
data, ok := props["data"].(map[string]any)
if !ok {
t.Fatalf("data schema = %#v, want map", props["data"])
}
if data["type"] != "object" {
t.Fatalf("data.type = %#v, want object", data["type"])
}
dataProps, ok := data["properties"].(map[string]any)
if !ok {
t.Fatalf("data.properties = %#v, want map", data["properties"])
}
if _, found := dataProps["name"]; !found {
t.Fatalf("data.properties missing name: %#v", dataProps)
}
if _, found := dataProps["count"]; !found {
t.Fatalf("data.properties missing count: %#v", dataProps)
}
required, ok := got["required"].([]string)
if !ok {
t.Fatalf("required = %#v, want []string", got["required"])
}
if len(required) != 2 || required[0] != "parent" || required[1] != "icon" {
t.Fatalf("required = %#v, want [parent icon]", required)
}
}
func TestSanitizeSchemaForGemini_MergesAllOfAndFiltersRequired(t *testing.T) {
schema := map[string]any{
"type": "object",
"properties": map[string]any{
"payload": map[string]any{
"allOf": []any{
map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{
"type": "string",
},
},
"required": []any{"id"},
},
map[string]any{
"properties": map[string]any{
"name": map[string]any{
"type": "string",
},
"count": map[string]any{
"type": "integer",
"minimum": 1,
},
},
"required": []any{"name", "missing"},
},
},
},
},
}
got := SanitizeSchemaForGemini(schema)
props := got["properties"].(map[string]any)
payload := props["payload"].(map[string]any)
if payload["type"] != "object" {
t.Fatalf("payload.type = %#v, want object", payload["type"])
}
payloadProps, ok := payload["properties"].(map[string]any)
if !ok {
t.Fatalf("payload.properties = %#v, want map", payload["properties"])
}
for _, key := range []string{"id", "name", "count"} {
if _, found := payloadProps[key]; !found {
t.Fatalf("payload.properties missing %q: %#v", key, payloadProps)
}
}
required, ok := payload["required"].([]string)
if !ok {
t.Fatalf("payload.required = %#v, want []string", payload["required"])
}
if len(required) != 2 || required[0] != "id" || required[1] != "name" {
t.Fatalf("payload.required = %#v, want [id name]", required)
}
assertSchemaKeyAbsent(t, payload, "allOf")
assertSchemaKeyAbsent(t, payload, "minimum")
}
func TestSanitizeSchemaForGemini_HandlesRecursiveRefs(t *testing.T) {
schema := map[string]any{
"type": "object",
"properties": map[string]any{
"tree": map[string]any{
"$ref": "#/$defs/node",
},
},
"$defs": map[string]any{
"node": map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{
"type": "string",
},
"child": map[string]any{
"$ref": "#/$defs/node",
},
},
},
},
}
got := SanitizeSchemaForGemini(schema)
props := got["properties"].(map[string]any)
tree := props["tree"].(map[string]any)
if tree["type"] != "object" {
t.Fatalf("tree.type = %#v, want object", tree["type"])
}
assertSchemaKeyAbsent(t, tree, "$ref")
}
func assertSchemaKeyAbsent(t *testing.T, value any, key string) {
t.Helper()
switch typed := value.(type) {
case map[string]any:
if _, found := typed[key]; found {
t.Fatalf("schema still contains key %q: %#v", key, typed)
}
for _, nested := range typed {
assertSchemaKeyAbsent(t, nested, key)
}
case []any:
for _, nested := range typed {
assertSchemaKeyAbsent(t, nested, key)
}
case []string:
return
}
}

View file

@ -0,0 +1,59 @@
package common
import (
"fmt"
"strings"
)
const (
ToolSchemaTransformOff = ""
ToolSchemaTransformSimple = "simple"
)
// NormalizeToolSchemaTransform resolves user-facing aliases to a canonical
// transform mode. Empty values and explicit "off"-style values disable schema
// transformation.
func NormalizeToolSchemaTransform(raw string) (string, error) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", "off", "none", "native":
return ToolSchemaTransformOff, nil
case "simple", "basic", "strict", "flat":
return ToolSchemaTransformSimple, nil
default:
return "", fmt.Errorf("unsupported tool_schema_transform %q (supported: off, simple)", raw)
}
}
// TransformToolDefinitions clones tool definitions and applies the configured
// schema transform to function parameter schemas. When the transform is off, the
// original slice is returned unchanged.
func TransformToolDefinitions(tools []ToolDefinition, transform string) ([]ToolDefinition, error) {
transform, err := NormalizeToolSchemaTransform(transform)
if err != nil {
return nil, err
}
if transform == ToolSchemaTransformOff || len(tools) == 0 {
return tools, nil
}
out := make([]ToolDefinition, len(tools))
for i, tool := range tools {
out[i] = tool
if tool.Type != "function" {
continue
}
out[i].Function = tool.Function
out[i].Function.Parameters = transformToolSchema(tool.Function.Parameters, transform)
}
return out, nil
}
func transformToolSchema(schema map[string]any, transform string) map[string]any {
switch transform {
case ToolSchemaTransformSimple:
return SanitizeSchemaForGoogle(schema)
default:
return cloneGeminiSchemaMap(schema)
}
}

View file

@ -168,7 +168,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
return provider, modelID, nil return finalizeProviderFromConfig(provider, modelID, cfg)
} }
// OpenAI with API key // OpenAI with API key
if cfg.APIKey() == "" && cfg.APIBase == "" { if cfg.APIKey() == "" && cfg.APIBase == "" {
@ -189,7 +189,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
cfg.CustomHeaders, cfg.CustomHeaders,
) )
provider.SetProviderName(protocol) provider.SetProviderName(protocol)
return provider, modelID, nil return finalizeProviderFromConfig(provider, modelID, cfg)
case "azure", "azure-openai": case "azure", "azure-openai":
// Azure OpenAI uses deployment-based URLs, api-key header auth, // Azure OpenAI uses deployment-based URLs, api-key header auth,
@ -202,13 +202,13 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
"api_base is required for azure protocol (e.g., https://your-resource.openai.azure.com)", "api_base is required for azure protocol (e.g., https://your-resource.openai.azure.com)",
) )
} }
return azure.NewProviderWithTimeout( return finalizeProviderFromConfig(azure.NewProviderWithTimeout(
cfg.APIKey(), cfg.APIKey(),
cfg.APIBase, cfg.APIBase,
cfg.Proxy, cfg.Proxy,
userAgent, userAgent,
cfg.RequestTimeout, cfg.RequestTimeout,
), modelID, nil ), modelID, cfg)
case "bedrock": case "bedrock":
// AWS Bedrock uses AWS SDK credentials (env vars, profiles, IAM roles, etc.) // AWS Bedrock uses AWS SDK credentials (env vars, profiles, IAM roles, etc.)
@ -244,7 +244,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if err != nil { if err != nil {
return nil, "", fmt.Errorf("creating bedrock provider: %w", err) return nil, "", fmt.Errorf("creating bedrock provider: %w", err)
} }
return provider, modelID, nil return finalizeProviderFromConfig(provider, modelID, cfg)
case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "nvidia", "venice", case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "nvidia", "venice",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
@ -270,7 +270,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
cfg.CustomHeaders, cfg.CustomHeaders,
) )
provider.SetProviderName(protocol) provider.SetProviderName(protocol)
return provider, modelID, nil return finalizeProviderFromConfig(provider, modelID, cfg)
case "gemini": case "gemini":
if cfg.APIKey() == "" && cfg.APIBase == "" { if cfg.APIKey() == "" && cfg.APIBase == "" {
@ -280,7 +280,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if apiBase == "" { if apiBase == "" {
apiBase = getDefaultAPIBase(protocol) apiBase = getDefaultAPIBase(protocol)
} }
return NewGeminiProvider( return finalizeProviderFromConfig(NewGeminiProvider(
cfg.APIKey(), cfg.APIKey(),
apiBase, apiBase,
cfg.Proxy, cfg.Proxy,
@ -288,7 +288,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
cfg.RequestTimeout, cfg.RequestTimeout,
cfg.ExtraBody, cfg.ExtraBody,
cfg.CustomHeaders, cfg.CustomHeaders,
), modelID, nil ), modelID, cfg)
case "minimax": case "minimax":
// Minimax requires reasoning_split: true in the request body // Minimax requires reasoning_split: true in the request body
@ -317,7 +317,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
cfg.CustomHeaders, cfg.CustomHeaders,
) )
provider.SetProviderName(protocol) provider.SetProviderName(protocol)
return provider, modelID, nil return finalizeProviderFromConfig(provider, modelID, cfg)
case "anthropic": case "anthropic":
if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" { if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" {
@ -326,7 +326,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
return provider, modelID, nil return finalizeProviderFromConfig(provider, modelID, cfg)
} }
// Use API key with HTTP API // Use API key with HTTP API
apiBase := cfg.APIBase apiBase := cfg.APIBase
@ -347,7 +347,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
cfg.CustomHeaders, cfg.CustomHeaders,
) )
provider.SetProviderName(protocol) provider.SetProviderName(protocol)
return provider, modelID, nil return finalizeProviderFromConfig(provider, modelID, cfg)
case "anthropic-messages": case "anthropic-messages":
// Anthropic Messages API with native format (HTTP-based, no SDK) // Anthropic Messages API with native format (HTTP-based, no SDK)
@ -358,12 +358,12 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if cfg.APIKey() == "" { if cfg.APIKey() == "" {
return nil, "", fmt.Errorf("api_key is required for anthropic-messages protocol (model: %s)", cfg.Model) return nil, "", fmt.Errorf("api_key is required for anthropic-messages protocol (model: %s)", cfg.Model)
} }
return anthropicmessages.NewProviderWithTimeout( return finalizeProviderFromConfig(anthropicmessages.NewProviderWithTimeout(
cfg.APIKey(), cfg.APIKey(),
apiBase, apiBase,
userAgent, userAgent,
cfg.RequestTimeout, cfg.RequestTimeout,
), modelID, nil ), modelID, cfg)
case "coding-plan-anthropic", "alibaba-coding-anthropic": case "coding-plan-anthropic", "alibaba-coding-anthropic":
// Alibaba Coding Plan with Anthropic-compatible API // Alibaba Coding Plan with Anthropic-compatible API
@ -374,29 +374,29 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if cfg.APIKey() == "" { if cfg.APIKey() == "" {
return nil, "", fmt.Errorf("api_key is required for %q protocol (model: %s)", protocol, cfg.Model) return nil, "", fmt.Errorf("api_key is required for %q protocol (model: %s)", protocol, cfg.Model)
} }
return anthropicmessages.NewProviderWithTimeout( return finalizeProviderFromConfig(anthropicmessages.NewProviderWithTimeout(
cfg.APIKey(), cfg.APIKey(),
apiBase, apiBase,
userAgent, userAgent,
cfg.RequestTimeout, cfg.RequestTimeout,
), modelID, nil ), modelID, cfg)
case "antigravity": case "antigravity":
return NewAntigravityProvider(), modelID, nil return finalizeProviderFromConfig(NewAntigravityProvider(), modelID, cfg)
case "claude-cli", "claudecli": case "claude-cli", "claudecli":
workspace := cfg.Workspace workspace := cfg.Workspace
if workspace == "" { if workspace == "" {
workspace = "." workspace = "."
} }
return NewClaudeCliProvider(workspace), modelID, nil return finalizeProviderFromConfig(NewClaudeCliProvider(workspace), modelID, cfg)
case "codex-cli", "codexcli": case "codex-cli", "codexcli":
workspace := cfg.Workspace workspace := cfg.Workspace
if workspace == "" { if workspace == "" {
workspace = "." workspace = "."
} }
return NewCodexCliProvider(workspace), modelID, nil return finalizeProviderFromConfig(NewCodexCliProvider(workspace), modelID, cfg)
case "github-copilot", "copilot": case "github-copilot", "copilot":
apiBase := cfg.APIBase apiBase := cfg.APIBase
@ -411,13 +411,25 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
return provider, modelID, nil return finalizeProviderFromConfig(provider, modelID, cfg)
default: default:
return nil, "", fmt.Errorf("unknown protocol %q in model %q", protocol, cfg.Model) return nil, "", fmt.Errorf("unknown protocol %q in model %q", protocol, cfg.Model)
} }
} }
func finalizeProviderFromConfig(
provider LLMProvider,
modelID string,
cfg *config.ModelConfig,
) (LLMProvider, string, error) {
wrapped, err := wrapProviderWithToolSchemaTransform(provider, cfg.ToolSchemaTransform)
if err != nil {
return nil, "", err
}
return wrapped, modelID, nil
}
func isEmptyAPIKeyAllowed(protocol string) bool { func isEmptyAPIKeyAllowed(protocol string) bool {
meta, ok := protocolMetaByName[protocol] meta, ok := protocolMetaByName[protocol]
return ok && meta.emptyAPIKeyAllowed return ok && meta.emptyAPIKeyAllowed

View file

@ -1202,3 +1202,42 @@ func TestCreateProviderFromConfig_BedrockWithEndpointURL(t *testing.T) {
// Unexpected error - fail the test // Unexpected error - fail the test
t.Errorf("unexpected error from bedrock provider: %v", err) t.Errorf("unexpected error from bedrock provider: %v", err)
} }
func TestCreateProviderFromConfig_ToolSchemaTransformWrapsProvider(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "claude-cli-test",
Provider: "claude-cli",
Model: "claude-sonnet-4.6",
Workspace: t.TempDir(),
ToolSchemaTransform: "simple",
}
provider, modelID, err := CreateProviderFromConfig(cfg)
if err != nil {
t.Fatalf("CreateProviderFromConfig() error = %v", err)
}
if modelID != "claude-sonnet-4.6" {
t.Fatalf("modelID = %q, want %q", modelID, "claude-sonnet-4.6")
}
if _, ok := provider.(*toolSchemaTransformProvider); !ok {
t.Fatalf("provider = %T, want *toolSchemaTransformProvider", provider)
}
}
func TestCreateProviderFromConfig_InvalidToolSchemaTransform(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "claude-cli-test",
Provider: "claude-cli",
Model: "claude-sonnet-4.6",
Workspace: t.TempDir(),
ToolSchemaTransform: "invalid",
}
_, _, err := CreateProviderFromConfig(cfg)
if err == nil {
t.Fatal("CreateProviderFromConfig() expected error for invalid tool_schema_transform")
}
if !strings.Contains(err.Error(), "tool_schema_transform") {
t.Fatalf("error = %v, want mention tool_schema_transform", err)
}
}

View file

@ -12,66 +12,6 @@ func extractPartThoughtSignature(thoughtSignature string, thoughtSignatureSnake
return "" return ""
} }
var geminiUnsupportedKeywords = map[string]bool{
"patternProperties": true,
"additionalProperties": true,
"$schema": true,
"$id": true,
"$ref": true,
"$defs": true,
"definitions": true,
"examples": true,
"minLength": true,
"maxLength": true,
"minimum": true,
"maximum": true,
"multipleOf": true,
"pattern": true,
"format": true,
"minItems": true,
"maxItems": true,
"uniqueItems": true,
"minProperties": true,
"maxProperties": true,
}
func sanitizeSchemaForGemini(schema map[string]any) map[string]any {
if schema == nil {
return nil
}
result := make(map[string]any)
for k, v := range schema {
if geminiUnsupportedKeywords[k] {
continue
}
switch val := v.(type) {
case map[string]any:
result[k] = sanitizeSchemaForGemini(val)
case []any:
sanitized := make([]any, len(val))
for i, item := range val {
if m, ok := item.(map[string]any); ok {
sanitized[i] = sanitizeSchemaForGemini(m)
} else {
sanitized[i] = item
}
}
result[k] = sanitized
default:
result[k] = v
}
}
if _, hasProps := result["properties"]; hasProps {
if _, hasType := result["type"]; !hasType {
result["type"] = "object"
}
}
return result
}
func extractProtocol(model string) (protocol, modelID string) { func extractProtocol(model string) (protocol, modelID string) {
model = strings.TrimSpace(model) model = strings.TrimSpace(model)
protocol, modelID, found := strings.Cut(model, "/") protocol, modelID, found := strings.Cut(model, "/")

View file

@ -264,7 +264,7 @@ func (p *GeminiProvider) buildRequestBody(
funcDecls = append(funcDecls, geminiFunctionDeclaration{ funcDecls = append(funcDecls, geminiFunctionDeclaration{
Name: t.Function.Name, Name: t.Function.Name,
Description: t.Function.Description, Description: t.Function.Description,
Parameters: sanitizeSchemaForGemini(t.Function.Parameters), Parameters: t.Function.Parameters,
}) })
} }
if len(funcDecls) > 0 { if len(funcDecls) > 0 {

View file

@ -259,6 +259,64 @@ func TestGeminiProvider_ChatStreamSkipsEmptyDataFrames(t *testing.T) {
} }
} }
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) { func TestGeminiProvider_ChatStreamReturnsErrorOnInvalidDataFrame(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Content-Type", "text/event-stream")

View file

@ -291,18 +291,17 @@ func (p *AntigravityProvider) buildRequest(
} }
} }
// Build tools (sanitize schemas for Gemini compatibility) // Build tools
if len(tools) > 0 { if len(tools) > 0 {
var funcDecls []antigravityFuncDecl var funcDecls []antigravityFuncDecl
for _, t := range tools { for _, t := range tools {
if t.Type != "function" { if t.Type != "function" {
continue continue
} }
params := sanitizeSchemaForGemini(t.Function.Parameters)
funcDecls = append(funcDecls, antigravityFuncDecl{ funcDecls = append(funcDecls, antigravityFuncDecl{
Name: t.Function.Name, Name: t.Function.Name,
Description: t.Function.Description, Description: t.Function.Description,
Parameters: params, Parameters: t.Function.Parameters,
}) })
} }
if len(funcDecls) > 0 { if len(funcDecls) > 0 {
@ -446,71 +445,6 @@ func extractPartThoughtSignature(thoughtSignature string, thoughtSignatureSnake
return "" return ""
} }
// --- Schema sanitization ---
// Google/Gemini doesn't support many JSON Schema keywords that other providers accept.
var geminiUnsupportedKeywords = map[string]bool{
"patternProperties": true,
"additionalProperties": true,
"$schema": true,
"$id": true,
"$ref": true,
"$defs": true,
"definitions": true,
"examples": true,
"minLength": true,
"maxLength": true,
"minimum": true,
"maximum": true,
"multipleOf": true,
"pattern": true,
"format": true,
"minItems": true,
"maxItems": true,
"uniqueItems": true,
"minProperties": true,
"maxProperties": true,
}
func sanitizeSchemaForGemini(schema map[string]any) map[string]any {
if schema == nil {
return nil
}
result := make(map[string]any)
for k, v := range schema {
if geminiUnsupportedKeywords[k] {
continue
}
// Recursively sanitize nested objects
switch val := v.(type) {
case map[string]any:
result[k] = sanitizeSchemaForGemini(val)
case []any:
sanitized := make([]any, len(val))
for i, item := range val {
if m, ok := item.(map[string]any); ok {
sanitized[i] = sanitizeSchemaForGemini(m)
} else {
sanitized[i] = item
}
}
result[k] = sanitized
default:
result[k] = v
}
}
// Ensure top-level has type: "object" if properties are present
if _, hasProps := result["properties"]; hasProps {
if _, hasType := result["type"]; !hasType {
result["type"] = "object"
}
}
return result
}
// --- Token source --- // --- Token source ---
func createAntigravityTokenSource() func() (string, string, error) { func createAntigravityTokenSource() func() (string, string, error) {

View file

@ -1,6 +1,8 @@
package oauthprovider package oauthprovider
import "testing" import (
"testing"
)
func TestBuildRequestUsesFunctionFieldsWhenToolCallNameMissing(t *testing.T) { func TestBuildRequestUsesFunctionFieldsWhenToolCallNameMissing(t *testing.T) {
p := &AntigravityProvider{} p := &AntigravityProvider{}
@ -71,3 +73,70 @@ func TestParseSSEResponse_SplitsThoughtAndVisibleContent(t *testing.T) {
t.Fatalf("Usage.TotalTokens = %v, want %d", resp.Usage, 216) t.Fatalf("Usage.TotalTokens = %v, want %d", resp.Usage, 216)
} }
} }
func TestBuildRequest_PreservesComplexToolSchemasByDefault(t *testing.T) {
p := &AntigravityProvider{}
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"},
},
},
"icon": map[string]any{
"anyOf": []any{
map[string]any{"type": "null"},
map[string]any{"$ref": "#/$defs/emoji"},
},
},
},
"$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"},
},
"emoji": map[string]any{
"type": "string",
"pattern": "^:[a-z_]+:$",
},
},
}
req := p.buildRequest(
[]Message{{Role: "user", Content: "hello"}},
[]ToolDefinition{{
Type: "function",
Function: ToolFunctionDefinition{
Name: "mcp_notion_create",
Description: "Create a Notion object",
Parameters: schema,
},
}},
"gemini-3-flash",
nil,
)
if len(req.Tools) != 1 || len(req.Tools[0].FunctionDeclarations) != 1 {
t.Fatalf("request tools = %#v, want one function declaration", req.Tools)
}
got, ok := req.Tools[0].FunctionDeclarations[0].Parameters.(map[string]any)
if !ok {
t.Fatalf("parameters = %#v, want map", req.Tools[0].FunctionDeclarations[0].Parameters)
}
if got["$defs"] == nil {
t.Fatalf("parameters = %#v, want raw schema with $defs preserved by default", got)
}
}

View file

@ -0,0 +1,84 @@
package providers
import (
"context"
"github.com/sipeed/picoclaw/pkg/providers/common"
)
type toolSchemaTransformProvider struct {
delegate LLMProvider
transform string
}
type toolSchemaStreamingProvider struct {
*toolSchemaTransformProvider
}
func wrapProviderWithToolSchemaTransform(delegate LLMProvider, transform string) (LLMProvider, error) {
transform, err := common.NormalizeToolSchemaTransform(transform)
if err != nil {
return nil, err
}
if transform == common.ToolSchemaTransformOff || delegate == nil {
return delegate, nil
}
base := &toolSchemaTransformProvider{
delegate: delegate,
transform: transform,
}
if _, ok := delegate.(StreamingProvider); ok {
return &toolSchemaStreamingProvider{toolSchemaTransformProvider: base}, nil
}
return base, nil
}
func (p *toolSchemaTransformProvider) Chat(
ctx context.Context,
messages []Message,
tools []ToolDefinition,
model string,
options map[string]any,
) (*LLMResponse, error) {
transformed, err := common.TransformToolDefinitions(tools, p.transform)
if err != nil {
return nil, err
}
return p.delegate.Chat(ctx, messages, transformed, model, options)
}
func (p *toolSchemaTransformProvider) GetDefaultModel() string {
return p.delegate.GetDefaultModel()
}
func (p *toolSchemaStreamingProvider) ChatStream(
ctx context.Context,
messages []Message,
tools []ToolDefinition,
model string,
options map[string]any,
onChunk func(accumulated string),
) (*LLMResponse, error) {
streaming := p.delegate.(StreamingProvider)
transformed, err := common.TransformToolDefinitions(tools, p.transform)
if err != nil {
return nil, err
}
return streaming.ChatStream(ctx, messages, transformed, model, options, onChunk)
}
func (p *toolSchemaTransformProvider) SupportsThinking() bool {
tc, ok := p.delegate.(ThinkingCapable)
return ok && tc.SupportsThinking()
}
func (p *toolSchemaTransformProvider) SupportsNativeSearch() bool {
ns, ok := p.delegate.(NativeSearchCapable)
return ok && ns.SupportsNativeSearch()
}
func (p *toolSchemaTransformProvider) Close() {
if stateful, ok := p.delegate.(StatefulProvider); ok {
stateful.Close()
}
}

View file

@ -0,0 +1,104 @@
package providers
import (
"context"
"reflect"
"testing"
providercommon "github.com/sipeed/picoclaw/pkg/providers/common"
)
type toolCaptureProvider struct {
lastTools []ToolDefinition
}
func (p *toolCaptureProvider) Chat(
_ context.Context,
_ []Message,
tools []ToolDefinition,
_ string,
_ map[string]any,
) (*LLMResponse, error) {
p.lastTools = tools
return &LLMResponse{Content: "ok"}, nil
}
func (p *toolCaptureProvider) GetDefaultModel() string {
return "test"
}
func TestWrapProviderWithToolSchemaTransform_DisabledPassesToolsThrough(t *testing.T) {
capture := &toolCaptureProvider{}
wrapped, err := wrapProviderWithToolSchemaTransform(capture, "")
if err != nil {
t.Fatalf("wrapProviderWithToolSchemaTransform() error = %v", err)
}
tools := []ToolDefinition{{
Type: "function",
Function: ToolFunctionDefinition{
Name: "noop",
Parameters: map[string]any{"type": "object"},
},
}}
_, err = wrapped.Chat(t.Context(), nil, tools, "test", nil)
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
if !reflect.DeepEqual(capture.lastTools, tools) {
t.Fatalf("tools mutated with transform off\n got: %#v\nwant: %#v", capture.lastTools, tools)
}
}
func TestWrapProviderWithToolSchemaTransform_GoogleSanitizesSchemas(t *testing.T) {
capture := &toolCaptureProvider{}
wrapped, err := wrapProviderWithToolSchemaTransform(capture, "simple")
if err != nil {
t.Fatalf("wrapProviderWithToolSchemaTransform() error = %v", err)
}
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"},
},
},
"databaseParent": map[string]any{
"type": "object",
"properties": map[string]any{
"database_id": map[string]any{"type": "string"},
},
},
},
}
tools := []ToolDefinition{{
Type: "function",
Function: ToolFunctionDefinition{
Name: "mcp_notion_create",
Parameters: schema,
},
}}
_, err = wrapped.Chat(t.Context(), nil, tools, "test", nil)
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
want := providercommon.SanitizeSchemaForGoogle(schema)
got := capture.lastTools[0].Function.Parameters
if !reflect.DeepEqual(got, want) {
t.Fatalf("sanitized parameters mismatch\n got: %#v\nwant: %#v", got, want)
}
}

View file

@ -35,14 +35,15 @@ type modelResponse struct {
Proxy string `json:"proxy,omitempty"` Proxy string `json:"proxy,omitempty"`
AuthMethod string `json:"auth_method,omitempty"` AuthMethod string `json:"auth_method,omitempty"`
// Advanced fields // Advanced fields
ConnectMode string `json:"connect_mode,omitempty"` ConnectMode string `json:"connect_mode,omitempty"`
Workspace string `json:"workspace,omitempty"` Workspace string `json:"workspace,omitempty"`
RPM int `json:"rpm,omitempty"` RPM int `json:"rpm,omitempty"`
MaxTokensField string `json:"max_tokens_field,omitempty"` MaxTokensField string `json:"max_tokens_field,omitempty"`
RequestTimeout int `json:"request_timeout,omitempty"` RequestTimeout int `json:"request_timeout,omitempty"`
ThinkingLevel string `json:"thinking_level,omitempty"` ThinkingLevel string `json:"thinking_level,omitempty"`
ExtraBody map[string]any `json:"extra_body,omitempty"` ToolSchemaTransform string `json:"tool_schema_transform,omitempty"`
CustomHeaders map[string]string `json:"custom_headers,omitempty"` ExtraBody map[string]any `json:"extra_body,omitempty"`
CustomHeaders map[string]string `json:"custom_headers,omitempty"`
// Meta // Meta
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Available bool `json:"available"` Available bool `json:"available"`
@ -78,27 +79,28 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
for i, m := range cfg.ModelList { for i, m := range cfg.ModelList {
provider, modelID := providers.ExtractProtocol(m) provider, modelID := providers.ExtractProtocol(m)
models = append(models, modelResponse{ models = append(models, modelResponse{
Index: i, Index: i,
ModelName: m.ModelName, ModelName: m.ModelName,
Provider: provider, Provider: provider,
Model: modelID, Model: modelID,
APIBase: m.APIBase, APIBase: m.APIBase,
APIKey: maskAPIKey(m.APIKey()), APIKey: maskAPIKey(m.APIKey()),
Proxy: m.Proxy, Proxy: m.Proxy,
AuthMethod: m.AuthMethod, AuthMethod: m.AuthMethod,
ConnectMode: m.ConnectMode, ConnectMode: m.ConnectMode,
Workspace: m.Workspace, Workspace: m.Workspace,
RPM: m.RPM, RPM: m.RPM,
MaxTokensField: m.MaxTokensField, MaxTokensField: m.MaxTokensField,
RequestTimeout: m.RequestTimeout, RequestTimeout: m.RequestTimeout,
ThinkingLevel: m.ThinkingLevel, ThinkingLevel: m.ThinkingLevel,
ExtraBody: m.ExtraBody, ToolSchemaTransform: m.ToolSchemaTransform,
CustomHeaders: m.CustomHeaders, ExtraBody: m.ExtraBody,
Enabled: m.Enabled, CustomHeaders: m.CustomHeaders,
Available: modelStatuses[i].Available, Enabled: m.Enabled,
Status: modelStatuses[i].Status, Available: modelStatuses[i].Available,
IsDefault: m.ModelName == defaultModel, Status: modelStatuses[i].Status,
IsVirtual: m.IsVirtual(), IsDefault: m.ModelName == defaultModel,
IsVirtual: m.IsVirtual(),
}) })
} }
@ -237,6 +239,9 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
} else if len(mc.CustomHeaders) == 0 { } else if len(mc.CustomHeaders) == 0 {
mc.CustomHeaders = nil mc.CustomHeaders = nil
} }
if _, ok := rawFields["tool_schema_transform"]; !ok {
mc.ToolSchemaTransform = cfg.ModelList[idx].ToolSchemaTransform
}
// Preserve the existing Provider when the caller omits it. This keeps the // Preserve the existing Provider when the caller omits it. This keeps the
// update API backward-compatible for clients that haven't started sending // update API backward-compatible for clients that haven't started sending
// the new field yet, while still allowing explicit clearing via "". // the new field yet, while still allowing explicit clearing via "".

View file

@ -584,6 +584,37 @@ func TestHandleAddModel_PersistsCustomHeaders(t *testing.T) {
} }
} }
func TestHandleAddModel_PersistsToolSchemaTransform(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{
"model_name":"new-model-transform",
"model":"openai/gpt-4o-mini",
"tool_schema_transform":"simple"
}`))
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
added := cfg.ModelList[len(cfg.ModelList)-1]
if got := added.ToolSchemaTransform; got != "simple" {
t.Fatalf("tool_schema_transform = %q, want %q", got, "simple")
}
}
func TestHandleUpdateModel_CustomHeadersPreserveAndClear(t *testing.T) { func TestHandleUpdateModel_CustomHeadersPreserveAndClear(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() defer cleanup()
@ -649,6 +680,69 @@ func TestHandleUpdateModel_CustomHeadersPreserveAndClear(t *testing.T) {
} }
} }
func TestHandleUpdateModel_ToolSchemaTransformPreserveAndClear(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
cfg.ModelList = []*config.ModelConfig{{
ModelName: "editable",
Model: "openai/gpt-4o-mini",
APIKeys: config.SimpleSecureStrings("sk-existing"),
ToolSchemaTransform: "simple",
}}
err = config.SaveConfig(configPath, cfg)
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
recPreserve := httptest.NewRecorder()
reqPreserve := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
"model_name":"editable",
"model":"openai/gpt-4o-mini"
}`))
reqPreserve.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(recPreserve, reqPreserve)
if recPreserve.Code != http.StatusOK {
t.Fatalf("preserve status = %d, want %d, body=%s", recPreserve.Code, http.StatusOK, recPreserve.Body.String())
}
afterPreserve, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() after preserve error = %v", err)
}
if got := afterPreserve.ModelList[0].ToolSchemaTransform; got != "simple" {
t.Fatalf("preserved tool_schema_transform = %q, want %q", got, "simple")
}
recClear := httptest.NewRecorder()
reqClear := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
"model_name":"editable",
"model":"openai/gpt-4o-mini",
"tool_schema_transform":""
}`))
reqClear.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(recClear, reqClear)
if recClear.Code != http.StatusOK {
t.Fatalf("clear status = %d, want %d, body=%s", recClear.Code, http.StatusOK, recClear.Body.String())
}
afterClear, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() after clear error = %v", err)
}
if afterClear.ModelList[0].ToolSchemaTransform != "" {
t.Fatalf("tool_schema_transform = %q, want empty", afterClear.ModelList[0].ToolSchemaTransform)
}
}
func TestHandleUpdateModel_PersistsProvider(t *testing.T) { func TestHandleUpdateModel_PersistsProvider(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() defer cleanup()

View file

@ -19,6 +19,7 @@ export interface ModelInfo {
max_tokens_field?: string max_tokens_field?: string
request_timeout?: number request_timeout?: number
thinking_level?: string thinking_level?: string
tool_schema_transform?: string
extra_body?: Record<string, unknown> extra_body?: Record<string, unknown>
custom_headers?: Record<string, string> custom_headers?: Record<string, string>
// Meta // Meta

View file

@ -39,6 +39,7 @@ interface AddForm {
maxTokensField: string maxTokensField: string
requestTimeout: string requestTimeout: string
thinkingLevel: string thinkingLevel: string
toolSchemaTransform: string
extraBody: string extraBody: string
customHeaders: string customHeaders: string
} }
@ -57,6 +58,7 @@ const EMPTY_ADD_FORM: AddForm = {
maxTokensField: "", maxTokensField: "",
requestTimeout: "", requestTimeout: "",
thinkingLevel: "", thinkingLevel: "",
toolSchemaTransform: "",
extraBody: "", extraBody: "",
customHeaders: "", customHeaders: "",
} }
@ -144,6 +146,7 @@ export function AddModelSheet({
? Number(form.requestTimeout) ? Number(form.requestTimeout)
: undefined, : undefined,
thinking_level: form.thinkingLevel.trim() || undefined, thinking_level: form.thinkingLevel.trim() || undefined,
tool_schema_transform: form.toolSchemaTransform.trim() || undefined,
extra_body: form.extraBody.trim() extra_body: form.extraBody.trim()
? JSON.parse(form.extraBody.trim()) ? JSON.parse(form.extraBody.trim())
: undefined, : undefined,
@ -345,6 +348,17 @@ export function AddModelSheet({
/> />
</Field> </Field>
<Field
label={t("models.field.toolSchemaTransform")}
hint={t("models.field.toolSchemaTransformHint")}
>
<Input
value={form.toolSchemaTransform}
onChange={setField("toolSchemaTransform")}
placeholder="google"
/>
</Field>
<Field <Field
label={t("models.field.extraBody")} label={t("models.field.extraBody")}
hint={t("models.field.extraBodyHint")} hint={t("models.field.extraBodyHint")}

View file

@ -38,6 +38,7 @@ interface EditForm {
maxTokensField: string maxTokensField: string
requestTimeout: string requestTimeout: string
thinkingLevel: string thinkingLevel: string
toolSchemaTransform: string
extraBody: string extraBody: string
customHeaders: string customHeaders: string
} }
@ -63,6 +64,7 @@ function buildInitialEditForm(model: ModelInfo): EditForm {
maxTokensField: model.max_tokens_field ?? "", maxTokensField: model.max_tokens_field ?? "",
requestTimeout: model.request_timeout ? String(model.request_timeout) : "", requestTimeout: model.request_timeout ? String(model.request_timeout) : "",
thinkingLevel: model.thinking_level ?? "", thinkingLevel: model.thinking_level ?? "",
toolSchemaTransform: model.tool_schema_transform ?? "", // <-- AGGIUNGI QUESTA RIGA
extraBody: model.extra_body extraBody: model.extra_body
? JSON.stringify(model.extra_body, null, 2) ? JSON.stringify(model.extra_body, null, 2)
: "", : "",
@ -92,6 +94,7 @@ export function EditModelSheet({
maxTokensField: "", maxTokensField: "",
requestTimeout: "", requestTimeout: "",
thinkingLevel: "", thinkingLevel: "",
toolSchemaTransform: "",
extraBody: "", extraBody: "",
customHeaders: "", customHeaders: "",
}) })
@ -105,12 +108,12 @@ export function EditModelSheet({
setAsDefault !== model.is_default) setAsDefault !== model.is_default)
useEffect(() => { useEffect(() => {
if (model) { if (model) {
setForm(buildInitialEditForm(model)) setForm(buildInitialEditForm(model))
setSetAsDefault(model.is_default) setSetAsDefault(model.is_default)
setError("") setError("")
} }
}, [model]) }, [model])
const setField = const setField =
(key: keyof EditForm) => (key: keyof EditForm) =>
@ -142,6 +145,7 @@ export function EditModelSheet({
? Number(form.requestTimeout) ? Number(form.requestTimeout)
: undefined, : undefined,
thinking_level: form.thinkingLevel || undefined, thinking_level: form.thinkingLevel || undefined,
tool_schema_transform: form.toolSchemaTransform.trim() || undefined,
extra_body: form.extraBody.trim() extra_body: form.extraBody.trim()
? JSON.parse(form.extraBody.trim()) ? JSON.parse(form.extraBody.trim())
: {}, : {},
@ -342,6 +346,17 @@ export function EditModelSheet({
/> />
</Field> </Field>
<Field
label={t("models.field.toolSchemaTransform")}
hint={t("models.field.toolSchemaTransformHint")}
>
<Input
value={form.toolSchemaTransform}
onChange={setField("toolSchemaTransform")}
placeholder="google"
/>
</Field>
<Field <Field
label={t("models.field.extraBody")} label={t("models.field.extraBody")}
hint={t("models.field.extraBodyHint")} hint={t("models.field.extraBodyHint")}

View file

@ -294,6 +294,8 @@
"thinkingLevelHint": "Extended thinking budget: off, low, medium, high, xhigh, adaptive.", "thinkingLevelHint": "Extended thinking budget: off, low, medium, high, xhigh, adaptive.",
"maxTokensField": "Max Tokens Field", "maxTokensField": "Max Tokens Field",
"maxTokensFieldHint": "Override the request field name for max tokens, e.g. max_completion_tokens.", "maxTokensFieldHint": "Override the request field name for max tokens, e.g. max_completion_tokens.",
"toolSchemaTransform": "Tool Schema Transform",
"toolSchemaTransformHint": "Optional compatibility transform for tool JSON schemas. Leave blank for native behavior. Supported values: simple.",
"extraBody": "Extra Body", "extraBody": "Extra Body",
"extraBodyHint": "Additional JSON fields to inject into the request body, e.g. {\"reasoning_split\": true}.", "extraBodyHint": "Additional JSON fields to inject into the request body, e.g. {\"reasoning_split\": true}.",
"customHeaders": "Custom Headers", "customHeaders": "Custom Headers",

View file

@ -294,6 +294,8 @@
"thinkingLevelHint": "扩展思考预算off、low、medium、high、xhigh、adaptive。", "thinkingLevelHint": "扩展思考预算off、low、medium、high、xhigh、adaptive。",
"maxTokensField": "Max Tokens 字段名", "maxTokensField": "Max Tokens 字段名",
"maxTokensFieldHint": "覆盖请求中 max_tokens 的字段名,例如 max_completion_tokens。", "maxTokensFieldHint": "覆盖请求中 max_tokens 的字段名,例如 max_completion_tokens。",
"toolSchemaTransform": "工具 Schema 转换",
"toolSchemaTransformHint": "可选的工具 JSON Schema 兼容性转换。留空表示保持原生行为。当前支持值simple。",
"extraBody": "Extra Body", "extraBody": "Extra Body",
"extraBodyHint": "要注入到请求体中的额外 JSON 字段,例如 {\"reasoning_split\": true}。", "extraBodyHint": "要注入到请求体中的额外 JSON 字段,例如 {\"reasoning_split\": true}。",
"customHeaders": "Custom Headers", "customHeaders": "Custom Headers",