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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cmd/gomodel/docs/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions docs/openapi.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions docs/providers/anthropic.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,17 @@ the completion before parsing `message.content`:
Only `finish_reason: "stop"` with non-empty content is worth handing to a JSON
parser; treat anything else as an error rather than parsing it.

## Developer messages and strict tools

`developer` messages are OpenAI's newer name for `system`; GoModel sends them to
Anthropic as system content, following the same placement rules as `system`
messages.

A function tool with `"strict": true` is forwarded as an Anthropic strict tool.
Strict tool schemas are held to the same limits as structured output, so GoModel
adapts them with the same rules as in [Structured output](#structured-output).
Tools without `strict` keep their schema exactly as sent.

## Verbosity

OpenAI's `verbosity` (and `text.verbosity` on `/v1/responses`) has no Anthropic
Expand Down
28 changes: 28 additions & 0 deletions docs/providers/gemini.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,34 @@ and Cloud Storage URIs are sent as a file reference Gemini resolves itself. Any
other remote `https://...` URL is rejected rather than dropped — upload it
through the Gemini Files API first.

## Messages and tools

In native mode, OpenAI chat fields map onto `generateContent` like this:

| OpenAI field | Gemini `generateContent` |
| ------------ | ------------------------ |
| `system` and `developer` messages | `system_instruction` (Gemini has no separate developer role) |
| `tool_choice: "auto"` / `"required"` / `"none"` | `functionCallingConfig.mode` `AUTO` / `ANY` / `NONE` |
| `tool_choice` naming one function | `ANY` with `allowedFunctionNames: [name]` |
| `tool_choice: {"type": "allowed_tools", ...}` with mode `required` | `ANY` with `allowedFunctionNames` |
| `tool_choice: {"type": "allowed_tools", ...}` with mode `auto` | `VALIDATED` with `allowedFunctionNames` |
| `strict: true` on any function tool | `VALIDATED` instead of `AUTO`; `ANY` already enforces the schema |
| `parallel_tool_calls: false` | dropped; Gemini has no equivalent |

Gemini accepts `allowedFunctionNames` only with `ANY` or `VALIDATED`, which is
why an `auto` subset becomes `VALIDATED`: the model can still answer in text,
but any call it makes is limited to the listed tools. `VALIDATED` applies to
the whole request, so one strict tool makes every declared tool
schema-validated.

Gemini has no way to limit a turn to one function call, and naming a single
tool does not help: Gemini can call the same function more than once in a turn.
When `parallel_tool_calls: false` (or Anthropic's `disable_parallel_tool_use`)
matters, handle or reject the extra calls in your client.

An `allowed_tools` choice with an empty `tools` list is rejected with a 400, as
OpenAI does.

## Not yet integrated

- Automatic fetching of remote `image_url` values in native mode.
Expand Down
3 changes: 3 additions & 0 deletions internal/anthropicapi/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,9 @@ func convertTools(tools []Tool, lenient bool) ([]map[string]any, error) {
}
function["parameters"] = schema
}
if tool.Strict != nil {
function["strict"] = *tool.Strict
}
converted := map[string]any{"type": "function", "function": function}
raw, err := validatedCacheControlJSON(tool.CacheControl)
if err != nil {
Expand Down
20 changes: 20 additions & 0 deletions internal/anthropicapi/request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,26 @@ func TestToChatRequestTools(t *testing.T) {
require.Equal(t, "function", choice["type"], "tool_choice = %#v", chat.ToolChoice)
}

func TestToChatRequestToolStrict(t *testing.T) {
chat, err := ToChatRequest(mustDecode(t, `{
"model":"m","max_tokens":10,
"messages":[{"role":"user","content":"hi"}],
"tools":[
{"name":"strict_tool","strict":true,"input_schema":{"type":"object","properties":{}}},
{"name":"plain_tool","input_schema":{"type":"object","properties":{}}}
]
}`))
require.NoError(t, err)
require.Len(t, chat.Tools, 2)

strictFn, ok := chat.Tools[0]["function"].(map[string]any)
require.True(t, ok)
assert.Equal(t, true, strictFn["strict"])
plainFn, ok := chat.Tools[1]["function"].(map[string]any)
require.True(t, ok)
assert.NotContains(t, plainFn, "strict")
}

func TestToChatRequestRejectsServerTool(t *testing.T) {
_, err := ToChatRequest(mustDecode(t, `{
"model":"m","max_tokens":10,
Expand Down
1 change: 1 addition & 0 deletions internal/anthropicapi/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ type Tool struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema json.RawMessage `json:"input_schema,omitempty" swaggertype:"object"`
Strict *bool `json:"strict,omitempty"`
CacheControl json.RawMessage `json:"cache_control,omitempty" swaggertype:"object"`
}

Expand Down
65 changes: 65 additions & 0 deletions internal/providers/anthropic/anthropic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -950,6 +950,21 @@ func TestConvertToAnthropicRequest(t *testing.T) {
assert.Len(t, req.Messages, 1)
},
},
{
name: "developer message becomes system",
input: &core.ChatRequest{
Model: "claude-haiku-4-5-20251001",
Messages: []core.Message{
{Role: "developer", Content: "You are a helpful assistant"},
{Role: "user", Content: "Hello"},
},
},
checkFn: func(t *testing.T, req *anthropicRequest) {
assert.Equal(t, "You are a helpful assistant", req.System)
require.Len(t, req.Messages, 1)
assert.Equal(t, "user", req.Messages[0].Role)
},
},
{
name: "request with parameters",
input: &core.ChatRequest{
Expand Down Expand Up @@ -1679,6 +1694,56 @@ func TestConvertOpenAIToolsToAnthropic(t *testing.T) {
require.True(t, ok, "InputSchema.properties = %#v, want object map", tools[0].InputSchema["properties"])
},
},
{
name: "strict tool keeps strict and sanitizes schema",
tools: []map[string]any{
{
"type": "function",
"function": map[string]any{
"name": "lookup_weather",
"strict": true,
"parameters": map[string]any{
"type": "object",
"properties": map[string]any{
"city": map[string]any{"type": "string", "minLength": 1},
},
},
},
},
},
wantLen: 1,
checkFn: func(t *testing.T, tools []anthropicTool) {
assert.True(t, tools[0].Strict)
assert.Equal(t, false, tools[0].InputSchema["additionalProperties"])
properties, ok := tools[0].InputSchema["properties"].(map[string]any)
require.True(t, ok)
city, ok := properties["city"].(map[string]any)
require.True(t, ok)
assert.NotContains(t, city, "minLength")
},
},
{
name: "non strict tool keeps schema as sent",
tools: []map[string]any{
{
"type": "function",
"function": map[string]any{
"name": "lookup_weather",
"parameters": map[string]any{
"type": "object",
"properties": map[string]any{
"city": map[string]any{"type": "string", "minLength": 1},
},
},
},
},
},
wantLen: 1,
checkFn: func(t *testing.T, tools []anthropicTool) {
assert.False(t, tools[0].Strict)
assert.NotContains(t, tools[0].InputSchema, "additionalProperties")
},
},
{
name: "unsupported tool type returns error",
tools: []map[string]any{
Expand Down
13 changes: 11 additions & 2 deletions internal/providers/anthropic/request_translation.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,18 @@ func convertOpenAIToolsToAnthropic(tools []map[string]any) ([]anthropicTool, err
if err != nil {
return nil, err
}
// strict tools are held to the same schema subset as structured
// outputs, so the schema gets the same sanitizing.
strict, _ := function["strict"].(bool)
schema := inputSchema.(map[string]any)
if strict {
schema = sanitizeAnthropicSchema(schema)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
out = append(out, anthropicTool{
Name: name,
Description: description,
InputSchema: inputSchema.(map[string]any),
InputSchema: schema,
Strict: strict,
CacheControl: cacheControl,
})
}
Expand Down Expand Up @@ -445,7 +453,8 @@ func convertToAnthropicRequest(req *core.ChatRequest) (*anthropicRequest, error)

conversationStarted := false
for _, msg := range req.Messages {
if msg.Role == "system" {
// "developer" is OpenAI's newer name for "system"; Anthropic rejects it.
if msg.Role == "system" || msg.Role == "developer" {
systemContent, err := buildAnthropicSystemContent(msg.Content)
if err != nil {
return nil, err
Expand Down
1 change: 1 addition & 0 deletions internal/providers/anthropic/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ type anthropicTool struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema map[string]any `json:"input_schema"`
Strict bool `json:"strict,omitempty"`
CacheControl json.RawMessage `json:"cache_control,omitempty"`
}

Expand Down
67 changes: 62 additions & 5 deletions internal/providers/gemini/native.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,10 @@ func convertChatRequestToGemini(req *core.ChatRequest) (*geminiGenerateContentRe
return nil, err
}
out.Tools = tools
out.ToolConfig = geminiToolConfigFromOpenAI(req.ToolChoice)
out.ToolConfig, err = geminiToolConfigFromOpenAI(req.ToolChoice, hasStrictTool(req.Tools))
if err != nil {
return nil, err
}
out.GenerationConfig = geminiGenerationConfig(req)
out.SafetySettings = geminiSafetySettings(req)
out.CachedContent = geminiCachedContent(req)
Expand Down Expand Up @@ -642,7 +645,11 @@ func validateGeminiParametersJSONSchema(encoded json.RawMessage) (json.RawMessag
return stripped, nil
}

func geminiToolConfigFromOpenAI(choice any) *geminiToolConfig {
// geminiToolConfigFromOpenAI maps tool_choice onto functionCallingConfig. With
// strict set (any tool declared strict: true), the free-choice mode becomes
// VALIDATED, Gemini's AUTO with schema-adherent function calls; ANY already
// guarantees schema adherence.
func geminiToolConfigFromOpenAI(choice any, strict bool) (*geminiToolConfig, error) {
mode := ""
var allowed []string

Expand All @@ -658,23 +665,73 @@ func geminiToolConfigFromOpenAI(choice any) *geminiToolConfig {
}
case map[string]any:
choiceType, _ := value["type"].(string)
if strings.TrimSpace(choiceType) == "function" {
switch strings.TrimSpace(choiceType) {
case "function":
mode = "ANY"
if fn, ok := value["function"].(map[string]any); ok {
if name, _ := fn["name"].(string); name != "" {
allowed = []string{name}
}
}
case "allowed_tools":
var err error
mode, allowed, err = geminiAllowedToolsConfig(value)
if err != nil {
return nil, err
}
}
}

if strict && (mode == "" || mode == "AUTO") {
mode = "VALIDATED"
}
if mode == "" {
return nil
return nil, nil
}
return &geminiToolConfig{FunctionCallingConfig: geminiFunctionCallingConfig{
Mode: mode,
AllowedFunctionNames: allowed,
}}
}}, nil
}

// geminiAllowedToolsConfig maps an allowed_tools choice onto a mode and the
// allowedFunctionNames subset. Gemini accepts allowedFunctionNames only with
// ANY or VALIDATED, so "auto" becomes VALIDATED: the model may still answer in
// text, but any call is limited to the subset. An empty subset is rejected, as
// OpenAI does, rather than widened to every declared tool.
func geminiAllowedToolsConfig(choice map[string]any) (string, []string, error) {
spec, _ := choice["allowed_tools"].(map[string]any)
tools, _ := spec["tools"].([]any)
var names []string
for _, raw := range tools {
tool, _ := raw.(map[string]any)
fn, _ := tool["function"].(map[string]any)
if name, _ := fn["name"].(string); strings.TrimSpace(name) != "" {
names = append(names, name)
}
}

if len(names) == 0 {
return "", nil, core.NewInvalidRequestError("tool_choice.allowed_tools.tools must list at least one function", nil)
}

if mode, _ := spec["mode"].(string); strings.TrimSpace(mode) == "required" {
return "ANY", names, nil
}
return "VALIDATED", names, nil
}

// hasStrictTool reports whether any function tool asks for strict schema
// adherence.
func hasStrictTool(tools []map[string]any) bool {
for _, tool := range tools {
if fn, ok := tool["function"].(map[string]any); ok {
if strict, _ := fn["strict"].(bool); strict {
return true
}
}
}
return false
}

func geminiGenerationConfig(req *core.ChatRequest) map[string]any {
Expand Down
Loading
Loading