From be4d7500c35132f4d1279682060872fa73268550 Mon Sep 17 00:00:00 2001 From: wangjunwei Date: Sat, 2 May 2026 12:07:17 +0800 Subject: [PATCH 1/3] feat(model): support strict tool use in fork adapters --- components/model/ark/chat_completion_api.go | 18 ++++++--- components/model/claude/claude.go | 16 ++++++++ components/model/claude/claude_test.go | 23 +++++++++++ libs/acl/openai/chat_model.go | 32 ++++++++++------ libs/acl/openai/chat_model_test.go | 42 +++++++++++++-------- libs/acl/openai/tool.go | 1 + 6 files changed, 99 insertions(+), 33 deletions(-) diff --git a/components/model/ark/chat_completion_api.go b/components/model/ark/chat_completion_api.go index 3a37f0bec..95d501a99 100644 --- a/components/model/ark/chat_completion_api.go +++ b/components/model/ark/chat_completion_api.go @@ -70,6 +70,7 @@ type functionDefinition struct { Description string `json:"description,omitempty"` Parameters *jsonschema.Schema `json:"parameters"` Examples []string `json:"examples"` + Strict *bool `json:"strict,omitempty"` } func (cm *completionAPIChatModel) Generate(ctx context.Context, in []*schema.Message, opts ...fmodel.Option) ( @@ -538,13 +539,18 @@ func (cm *completionAPIChatModel) toTools(tls []*schema.ToolInfo) ([]tool, error return nil, fmt.Errorf("failed to convert tool parameters to JSONSchema: %w", err) } - tools[i] = tool{ - Function: &functionDefinition{ - Name: ti.Name, - Description: ti.Desc, - Parameters: paramsJSONSchema, - }, + fd := &functionDefinition{ + Name: ti.Name, + Description: ti.Desc, + Parameters: paramsJSONSchema, + } + // Support strict tool use via ToolInfo.Extra["strict"] = true. + // When enabled, the API guarantees that tool call inputs conform + // to the declared JSON Schema exactly (OpenAI-compatible strict mode). + if strict, ok := ti.Extra["strict"].(bool); ok && strict { + fd.Strict = &strict } + tools[i] = tool{Function: fd} } return tools, nil diff --git a/components/model/claude/claude.go b/components/model/claude/claude.go index 24d5bdac6..59af4b5a6 100644 --- a/components/model/claude/claude.go +++ b/components/model/claude/claude.go @@ -495,6 +495,15 @@ func toAnthropicToolParam(tools []*schema.ToolInfo) ([]anthropic.ToolUnionParam, Properties: s.Properties, Required: s.Required, } + // Pass through additionalProperties from the JSON Schema if present. + // This is required for strict tool use mode on some providers (e.g. Bedrock). + if s.AdditionalProperties != nil { + apBytes, _ := json.Marshal(s.AdditionalProperties) + var ap any + if json.Unmarshal(apBytes, &ap) == nil { + inputSchema.ExtraFields = map[string]any{"additionalProperties": ap} + } + } } toolParam := &anthropic.ToolParam{ @@ -503,6 +512,13 @@ func toAnthropicToolParam(tools []*schema.ToolInfo) ([]anthropic.ToolUnionParam, InputSchema: inputSchema, } + // Support strict tool use via ToolInfo.Extra["strict"] = true. + // When enabled, the API guarantees that tool call inputs conform + // to the declared JSON Schema exactly (no type coercion). + if strict, ok := tool.Extra["strict"].(bool); ok && strict { + toolParam.Strict = param.NewOpt(true) + } + if isBreakpointTool(tool) { toolParam.CacheControl = newCacheControlParam(getToolBreakpointCacheControl(tool)) } diff --git a/components/model/claude/claude_test.go b/components/model/claude/claude_test.go index d5f10fb86..e7f475d30 100644 --- a/components/model/claude/claude_test.go +++ b/components/model/claude/claude_test.go @@ -389,6 +389,29 @@ func TestWithTools(t *testing.T) { assert.Equal(t, "test tool name", ncm.(*ChatModel).origTools[0].Name) } +func TestToAnthropicToolParam_Strict(t *testing.T) { + tools := []*schema.ToolInfo{ + { + Name: "strict_tool", + Desc: "a tool with strict mode", + Extra: map[string]any{"strict": true}, + }, + { + Name: "normal_tool", + Desc: "a tool without strict mode", + }, + } + result, err := toAnthropicToolParam(tools) + assert.NoError(t, err) + assert.Len(t, result, 2) + + // First tool should have strict=true + assert.True(t, result[0].OfTool.Strict.Value) + + // Second tool should not have strict set + assert.False(t, result[1].OfTool.Strict.Valid()) +} + func TestPopulateContentBlockBreakPoint(t *testing.T) { block := anthropic.NewTextBlock("input") populateContentBlockBreakPoint(block, nil) diff --git a/libs/acl/openai/chat_model.go b/libs/acl/openai/chat_model.go index 9c48a7c8e..c2fa666c1 100644 --- a/libs/acl/openai/chat_model.go +++ b/libs/acl/openai/chat_model.go @@ -657,6 +657,7 @@ func (c *Client) genRequest(ctx context.Context, in []*schema.Message, opts ...m Function: &openai.FunctionDefinition{ Name: t.Function.Name, Description: t.Function.Description, + Strict: dereferenceOrZero(t.Function.Strict), Parameters: t.Function.Parameters, }, } @@ -1050,12 +1051,14 @@ func populateToolChoice(req *openai.ChatCompletionRequest, tc *schema.ToolChoice } if onlyOneToolName != "" { - req.ToolChoice = openai.ToolChoice{ - Type: openai.ToolTypeFunction, - Function: openai.ToolFunction{ - Name: onlyOneToolName, - }, - } + // Some OpenAI-compatible gateways reject the object form + // {"type":"function","function":{"name":"..."}} + // with "unknown parameter: tool_choice.function", while still + // supporting tool calling with the string form "required". + // + // In the single-tool case, "required" is semantically equivalent + // to forcing that one tool, so prefer the more compatible wire form. + req.ToolChoice = toolChoiceRequired } else if len(allowedToolNames) > 1 { req.ToolChoice = map[string]any{ "type": "allowed_tools", @@ -1291,13 +1294,18 @@ func toTools(tis []*schema.ToolInfo) ([]tool, error) { sortArrayFields(paramsJSONSchema) - tools[i] = tool{ - Function: &functionDefinition{ - Name: ti.Name, - Description: ti.Desc, - Parameters: paramsJSONSchema, - }, + fd := &functionDefinition{ + Name: ti.Name, + Description: ti.Desc, + Parameters: paramsJSONSchema, + } + // Support strict tool use via ToolInfo.Extra["strict"] = true. + // When enabled, the API guarantees that tool call inputs conform + // to the declared JSON Schema exactly (OpenAI strict mode). + if strict, ok := ti.Extra["strict"].(bool); ok && strict { + fd.Strict = &strict } + tools[i] = tool{Function: fd} } return tools, nil diff --git a/libs/acl/openai/chat_model_test.go b/libs/acl/openai/chat_model_test.go index 9c169909d..9267ed271 100644 --- a/libs/acl/openai/chat_model_test.go +++ b/libs/acl/openai/chat_model_test.go @@ -373,6 +373,31 @@ func TestToTools(t *testing.T) { }) } +func TestGenRequest_PreservesStrictTools(t *testing.T) { + c := &Client{config: &Config{Model: "test-model"}} + + req, _, _, _, err := c.genRequest(t.Context(), + []*schema.Message{{Role: schema.User, Content: "hello"}}, + model.WithTools([]*schema.ToolInfo{ + { + Name: "submit_memory", + Desc: "Save extracted people memory", + Extra: map[string]any{"strict": true}, + ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{ + "people_memory": { + Type: schema.Object, + Required: true, + }, + }), + }, + }), + ) + + assert.NoError(t, err) + assert.Len(t, req.Tools, 1) + assert.True(t, req.Tools[0].Function.Strict) +} + func TestBuildMessages(t *testing.T) { t.Run("buildMessageFromAssistantGenMultiContent", func(t *testing.T) { t.Run("success with audio", func(t *testing.T) { @@ -983,13 +1008,7 @@ func TestPopulateToolChoice(t *testing.T) { } err := populateToolChoice(req, options.ToolChoice, options.AllowedToolNames) assert.NoError(t, err) - expected := openai.ToolChoice{ - Type: openai.ToolTypeFunction, - Function: openai.ToolFunction{ - Name: "test-tool", - }, - } - assert.Equal(t, expected, req.ToolChoice) + assert.Equal(t, "required", req.ToolChoice) }) t.Run("tool choice forced with multiple tools", func(t *testing.T) { @@ -1040,14 +1059,7 @@ func TestPopulateToolChoice(t *testing.T) { } err := populateToolChoice(req, options.ToolChoice, options.AllowedToolNames) assert.NoError(t, err) - - expected := openai.ToolChoice{ - Type: openai.ToolTypeFunction, - Function: openai.ToolFunction{ - Name: "test-tool-1", - }, - } - assert.Equal(t, expected, req.ToolChoice) + assert.Equal(t, "required", req.ToolChoice) }) t.Run("tool choice forced with invalid allowed tool", func(t *testing.T) { diff --git a/libs/acl/openai/tool.go b/libs/acl/openai/tool.go index 38b9dc9b2..58561a00c 100644 --- a/libs/acl/openai/tool.go +++ b/libs/acl/openai/tool.go @@ -28,4 +28,5 @@ type functionDefinition struct { Name string `json:"name"` Description string `json:"description,omitempty"` Parameters *jsonschema.Schema `json:"parameters"` + Strict *bool `json:"strict,omitempty"` } From 7ba58b14a9fe2773a78d6e1d6f477147d380f7ed Mon Sep 17 00:00:00 2001 From: wangjunwei Date: Tue, 2 Jun 2026 17:18:14 +0800 Subject: [PATCH 2/3] fix(openai): preserve allowed tool choice restriction --- libs/acl/openai/chat_model.go | 6 ++---- libs/acl/openai/chat_model_test.go | 14 +++++++++++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/libs/acl/openai/chat_model.go b/libs/acl/openai/chat_model.go index 3e36f9d6a..c2f930ddb 100644 --- a/libs/acl/openai/chat_model.go +++ b/libs/acl/openai/chat_model.go @@ -1067,9 +1067,7 @@ func populateToolChoice(req *openai.ChatCompletionRequest, tc *schema.ToolChoice } var onlyOneToolName string - if len(allowedToolNames) == 1 { - onlyOneToolName = allowedToolNames[0] - } else if len(req.Tools) == 1 { + if len(allowedToolNames) == 0 && len(req.Tools) == 1 { onlyOneToolName = req.Tools[0].Function.Name } @@ -1082,7 +1080,7 @@ func populateToolChoice(req *openai.ChatCompletionRequest, tc *schema.ToolChoice // In the single-tool case, "required" is semantically equivalent // to forcing that one tool, so prefer the more compatible wire form. req.ToolChoice = toolChoiceRequired - } else if len(allowedToolNames) > 1 { + } else if len(allowedToolNames) > 0 { req.ToolChoice = map[string]any{ "type": "allowed_tools", "allowed_tools": allowedTools{ diff --git a/libs/acl/openai/chat_model_test.go b/libs/acl/openai/chat_model_test.go index 69a17bff6..91a771a4a 100644 --- a/libs/acl/openai/chat_model_test.go +++ b/libs/acl/openai/chat_model_test.go @@ -1071,7 +1071,19 @@ func TestPopulateToolChoice(t *testing.T) { } err := populateToolChoice(req, options.ToolChoice, options.AllowedToolNames) assert.NoError(t, err) - assert.Equal(t, "required", req.ToolChoice) + expected := allowedTools{ + Mode: "required", + Tools: []openai.ToolChoice{ + { + Type: openai.ToolTypeFunction, + Function: openai.ToolFunction{ + Name: "test-tool-1", + }, + }, + }, + } + assert.Equal(t, "allowed_tools", req.ToolChoice.(map[string]any)["type"]) + assert.Equal(t, expected, req.ToolChoice.(map[string]any)["allowed_tools"]) }) t.Run("tool choice forced with invalid allowed tool", func(t *testing.T) { From 67fdfca61736b5dfa06dcb72148eaf4b7f688f80 Mon Sep 17 00:00:00 2001 From: wangjunwei Date: Wed, 3 Jun 2026 10:44:24 +0800 Subject: [PATCH 3/3] chore(ark): drop no-op strict tool change --- components/model/ark/chat_completion_api.go | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/components/model/ark/chat_completion_api.go b/components/model/ark/chat_completion_api.go index 6d2d4d481..ce1a02c3b 100644 --- a/components/model/ark/chat_completion_api.go +++ b/components/model/ark/chat_completion_api.go @@ -71,7 +71,6 @@ type functionDefinition struct { Description string `json:"description,omitempty"` Parameters *jsonschema.Schema `json:"parameters"` Examples []string `json:"examples"` - Strict *bool `json:"strict,omitempty"` } func (cm *completionAPIChatModel) Generate(ctx context.Context, in []*schema.Message, opts ...fmodel.Option) ( @@ -586,18 +585,13 @@ func (cm *completionAPIChatModel) toTools(tls []*schema.ToolInfo) ([]tool, error return nil, fmt.Errorf("failed to convert tool parameters to JSONSchema: %w", err) } - fd := &functionDefinition{ - Name: ti.Name, - Description: ti.Desc, - Parameters: paramsJSONSchema, - } - // Support strict tool use via ToolInfo.Extra["strict"] = true. - // When enabled, the API guarantees that tool call inputs conform - // to the declared JSON Schema exactly (OpenAI-compatible strict mode). - if strict, ok := ti.Extra["strict"].(bool); ok && strict { - fd.Strict = &strict + tools[i] = tool{ + Function: &functionDefinition{ + Name: ti.Name, + Description: ti.Desc, + Parameters: paramsJSONSchema, + }, } - tools[i] = tool{Function: fd} } return tools, nil