From b8f809d2d6d1a4680d44ace55c27dcef4fe78581 Mon Sep 17 00:00:00 2001 From: Simon Ferquel's Clanker Date: Tue, 27 Oct 2026 14:20:17 +0000 Subject: [PATCH 1/3] openai: express nullable arrays and objects as anyOf in strict tool schemas makeAllRequired made optional properties nullable by rewriting "type": "array" to ["array", "null"]. OpenAI's Responses API with strict: true rejects that form with an opaque "Invalid schema for function" error when the property lives in an object reached through a $defs entry that itself contains a $ref or an anyOf of $refs (for example the Notion MCP server's query-sessions tool). Express nullable array and object properties, both newly nullable and already declared as a type pair, as {"anyOf": [, {"type": "null"}]}, the form already used for $ref properties. Scalars keep the ["T", "null"] form. fixSchemaArrayItems now also looks inside the anyOf variants so arrays without items keep getting a default. Signed-off-by: Simon Ferquel's Clanker --- .../cassettes/TestExec_Mistral_ToolCall.yaml | 4 +- .../TestExec_OpenAI_HideToolCalls.yaml | 4 +- .../cassettes/TestExec_OpenAI_ToolCall.yaml | 4 +- pkg/model/provider/openai/schema.go | 114 +++++++++++--- pkg/model/provider/openai/schema_test.go | 144 +++++++++++++++++- pkg/model/provider/schema_test.go | 15 +- 6 files changed, 248 insertions(+), 37 deletions(-) diff --git a/e2e/testdata/cassettes/TestExec_Mistral_ToolCall.yaml b/e2e/testdata/cassettes/TestExec_Mistral_ToolCall.yaml index 1f83e28301..5fb6d0a984 100644 --- a/e2e/testdata/cassettes/TestExec_Mistral_ToolCall.yaml +++ b/e2e/testdata/cassettes/TestExec_Mistral_ToolCall.yaml @@ -8,7 +8,7 @@ interactions: proto_minor: 1 content_length: 0 host: api.mistral.ai - body: '{"messages":[{"content":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.\n","role":"system"},{"content":"## Filesystem Tools\n\n- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output","role":"system"},{"content":"How many files in testdata/working_dir? Only output the number.","role":"user"}],"model":"mistral-small","max_tokens":32000,"stream_options":{"include_usage":true},"tools":[{"function":{"name":"directory_tree","description":"Get a recursive tree view of files and directories as a JSON structure.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to traverse","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"edit_file","description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to edit","type":"string"},"edits":{"description":"Edits to apply","items":{"additionalProperties":false,"properties":{"oldText":{"description":"Exact text to replace","type":"string"},"newText":{"description":"Replacement text","type":"string"}},"required":["oldText","newText"],"type":"object"},"type":"array"}},"required":["path","edits"],"type":"object"}},"type":"function"},{"function":{"name":"list_directory","description":"Get a detailed listing of all files and directories in a specified path.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to list","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"read_file","description":"Read the contents of a file from the file system. By default the complete file is returned; for text files the optional line (1-based start line) and limit (maximum number of lines) arguments select a line range. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to read","type":"string"},"line":{"description":"1-based line number to start reading from (text files only; defaults to the first line)","type":["null","integer"]},"limit":{"description":"Maximum number of lines to read (text files only; defaults to reading through the end of the file)","type":["null","integer"]}},"required":["path","line","limit"],"type":"object"}},"type":"function"},{"function":{"name":"read_multiple_files","description":"Read the contents of multiple files simultaneously.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Files to read","items":{"type":"string"},"type":"array"},"json":{"description":"Return result as JSON","type":["boolean","null"]}},"required":["paths","json"],"type":"object"}},"type":"function"},{"function":{"name":"search_files_content","description":"Searches for text or regex patterns in the content of files matching a GLOB pattern.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Starting directory","type":"string"},"query":{"description":"Text or regex to search","type":"string"},"is_regex":{"description":"Treat query as regex","type":["boolean","null"]},"excludePatterns":{"description":"Patterns to exclude","items":{"type":"string"},"type":["null","array"]}},"required":["path","query","is_regex","excludePatterns"],"type":"object"}},"type":"function"},{"function":{"name":"write_file","description":"Create a new file or completely overwrite an existing file with new content.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to write","type":"string"},"content":{"description":"File content","type":"string"}},"required":["path","content"],"type":"object"}},"type":"function"},{"function":{"name":"create_directory","description":"Create one or more new directories or nested directory structures.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"},{"function":{"name":"remove_directory","description":"Remove one or more empty directories.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"}],"stream":true}' + body: '{"messages":[{"content":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.\n","role":"system"},{"content":"## Filesystem Tools\n\n- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output","role":"system"},{"content":"How many files in testdata/working_dir? Only output the number.","role":"user"}],"model":"mistral-small","max_tokens":32000,"stream_options":{"include_usage":true},"tools":[{"function":{"name":"directory_tree","description":"Get a recursive tree view of files and directories as a JSON structure.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to traverse","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"edit_file","description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to edit","type":"string"},"edits":{"description":"Edits to apply","items":{"additionalProperties":false,"properties":{"oldText":{"description":"Exact text to replace","type":"string"},"newText":{"description":"Replacement text","type":"string"}},"required":["oldText","newText"],"type":"object"},"type":"array"}},"required":["path","edits"],"type":"object"}},"type":"function"},{"function":{"name":"list_directory","description":"Get a detailed listing of all files and directories in a specified path.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to list","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"read_file","description":"Read the contents of a file from the file system. By default the complete file is returned; for text files the optional line (1-based start line) and limit (maximum number of lines) arguments select a line range. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to read","type":"string"},"line":{"description":"1-based line number to start reading from (text files only; defaults to the first line)","type":["null","integer"]},"limit":{"description":"Maximum number of lines to read (text files only; defaults to reading through the end of the file)","type":["null","integer"]}},"required":["path","line","limit"],"type":"object"}},"type":"function"},{"function":{"name":"read_multiple_files","description":"Read the contents of multiple files simultaneously.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Files to read","items":{"type":"string"},"type":"array"},"json":{"description":"Return result as JSON","type":["boolean","null"]}},"required":["paths","json"],"type":"object"}},"type":"function"},{"function":{"name":"search_files_content","description":"Searches for text or regex patterns in the content of files matching a GLOB pattern.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Starting directory","type":"string"},"query":{"description":"Text or regex to search","type":"string"},"is_regex":{"description":"Treat query as regex","type":["boolean","null"]},"excludePatterns":{"anyOf":[{"description":"Patterns to exclude","items":{"type":"string"},"type":"array"},{"type":"null"}]}},"required":["path","query","is_regex","excludePatterns"],"type":"object"}},"type":"function"},{"function":{"name":"write_file","description":"Create a new file or completely overwrite an existing file with new content.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to write","type":"string"},"content":{"description":"File content","type":"string"}},"required":["path","content"],"type":"object"}},"type":"function"},{"function":{"name":"create_directory","description":"Create one or more new directories or nested directory structures.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"},{"function":{"name":"remove_directory","description":"Remove one or more empty directories.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"}],"stream":true}' url: https://api.mistral.ai/v1/chat/completions method: POST response: @@ -34,7 +34,7 @@ interactions: proto_minor: 1 content_length: 0 host: api.mistral.ai - body: '{"messages":[{"content":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.\n","role":"system"},{"content":"## Filesystem Tools\n\n- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output","role":"system"},{"content":"How many files in testdata/working_dir? Only output the number.","role":"user"},{"tool_calls":[{"id":"D9WYdiHxV","function":{"arguments":"{\"path\": \"testdata/working_dir\"}","name":"list_directory"},"type":"function"}],"role":"assistant"},{"content":"FILE README.me\n","tool_call_id":"D9WYdiHxV","role":"tool"}],"model":"mistral-small","max_tokens":32000,"stream_options":{"include_usage":true},"tools":[{"function":{"name":"directory_tree","description":"Get a recursive tree view of files and directories as a JSON structure.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to traverse","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"edit_file","description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to edit","type":"string"},"edits":{"description":"Edits to apply","items":{"additionalProperties":false,"properties":{"oldText":{"description":"Exact text to replace","type":"string"},"newText":{"description":"Replacement text","type":"string"}},"required":["oldText","newText"],"type":"object"},"type":"array"}},"required":["path","edits"],"type":"object"}},"type":"function"},{"function":{"name":"list_directory","description":"Get a detailed listing of all files and directories in a specified path.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to list","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"read_file","description":"Read the contents of a file from the file system. By default the complete file is returned; for text files the optional line (1-based start line) and limit (maximum number of lines) arguments select a line range. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to read","type":"string"},"line":{"description":"1-based line number to start reading from (text files only; defaults to the first line)","type":["null","integer"]},"limit":{"description":"Maximum number of lines to read (text files only; defaults to reading through the end of the file)","type":["null","integer"]}},"required":["path","line","limit"],"type":"object"}},"type":"function"},{"function":{"name":"read_multiple_files","description":"Read the contents of multiple files simultaneously.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Files to read","items":{"type":"string"},"type":"array"},"json":{"description":"Return result as JSON","type":["boolean","null"]}},"required":["paths","json"],"type":"object"}},"type":"function"},{"function":{"name":"search_files_content","description":"Searches for text or regex patterns in the content of files matching a GLOB pattern.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Starting directory","type":"string"},"query":{"description":"Text or regex to search","type":"string"},"is_regex":{"description":"Treat query as regex","type":["boolean","null"]},"excludePatterns":{"description":"Patterns to exclude","items":{"type":"string"},"type":["null","array"]}},"required":["path","query","is_regex","excludePatterns"],"type":"object"}},"type":"function"},{"function":{"name":"write_file","description":"Create a new file or completely overwrite an existing file with new content.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to write","type":"string"},"content":{"description":"File content","type":"string"}},"required":["path","content"],"type":"object"}},"type":"function"},{"function":{"name":"create_directory","description":"Create one or more new directories or nested directory structures.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"},{"function":{"name":"remove_directory","description":"Remove one or more empty directories.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"}],"stream":true}' + body: '{"messages":[{"content":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.\n","role":"system"},{"content":"## Filesystem Tools\n\n- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output","role":"system"},{"content":"How many files in testdata/working_dir? Only output the number.","role":"user"},{"tool_calls":[{"id":"D9WYdiHxV","function":{"arguments":"{\"path\": \"testdata/working_dir\"}","name":"list_directory"},"type":"function"}],"role":"assistant"},{"content":"FILE README.me\n","tool_call_id":"D9WYdiHxV","role":"tool"}],"model":"mistral-small","max_tokens":32000,"stream_options":{"include_usage":true},"tools":[{"function":{"name":"directory_tree","description":"Get a recursive tree view of files and directories as a JSON structure.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to traverse","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"edit_file","description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to edit","type":"string"},"edits":{"description":"Edits to apply","items":{"additionalProperties":false,"properties":{"oldText":{"description":"Exact text to replace","type":"string"},"newText":{"description":"Replacement text","type":"string"}},"required":["oldText","newText"],"type":"object"},"type":"array"}},"required":["path","edits"],"type":"object"}},"type":"function"},{"function":{"name":"list_directory","description":"Get a detailed listing of all files and directories in a specified path.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to list","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"read_file","description":"Read the contents of a file from the file system. By default the complete file is returned; for text files the optional line (1-based start line) and limit (maximum number of lines) arguments select a line range. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to read","type":"string"},"line":{"description":"1-based line number to start reading from (text files only; defaults to the first line)","type":["null","integer"]},"limit":{"description":"Maximum number of lines to read (text files only; defaults to reading through the end of the file)","type":["null","integer"]}},"required":["path","line","limit"],"type":"object"}},"type":"function"},{"function":{"name":"read_multiple_files","description":"Read the contents of multiple files simultaneously.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Files to read","items":{"type":"string"},"type":"array"},"json":{"description":"Return result as JSON","type":["boolean","null"]}},"required":["paths","json"],"type":"object"}},"type":"function"},{"function":{"name":"search_files_content","description":"Searches for text or regex patterns in the content of files matching a GLOB pattern.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Starting directory","type":"string"},"query":{"description":"Text or regex to search","type":"string"},"is_regex":{"description":"Treat query as regex","type":["boolean","null"]},"excludePatterns":{"anyOf":[{"description":"Patterns to exclude","items":{"type":"string"},"type":"array"},{"type":"null"}]}},"required":["path","query","is_regex","excludePatterns"],"type":"object"}},"type":"function"},{"function":{"name":"write_file","description":"Create a new file or completely overwrite an existing file with new content.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to write","type":"string"},"content":{"description":"File content","type":"string"}},"required":["path","content"],"type":"object"}},"type":"function"},{"function":{"name":"create_directory","description":"Create one or more new directories or nested directory structures.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"},{"function":{"name":"remove_directory","description":"Remove one or more empty directories.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"}],"stream":true}' url: https://api.mistral.ai/v1/chat/completions method: POST response: diff --git a/e2e/testdata/cassettes/TestExec_OpenAI_HideToolCalls.yaml b/e2e/testdata/cassettes/TestExec_OpenAI_HideToolCalls.yaml index f936dca652..a512d7be64 100644 --- a/e2e/testdata/cassettes/TestExec_OpenAI_HideToolCalls.yaml +++ b/e2e/testdata/cassettes/TestExec_OpenAI_HideToolCalls.yaml @@ -8,7 +8,7 @@ interactions: proto_minor: 1 content_length: 0 host: api.openai.com - body: '{"messages":[{"content":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.\n","role":"system"},{"content":"## Filesystem Tools\n\n- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output","role":"system"},{"content":"How many files in testdata/working_dir? Only output the number.","role":"user"}],"model":"gpt-4o","max_tokens":16384,"stream_options":{"include_usage":true},"tools":[{"function":{"name":"directory_tree","description":"Get a recursive tree view of files and directories as a JSON structure.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to traverse","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"edit_file","description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to edit","type":"string"},"edits":{"description":"Edits to apply","items":{"additionalProperties":false,"properties":{"oldText":{"description":"Exact text to replace","type":"string"},"newText":{"description":"Replacement text","type":"string"}},"required":["oldText","newText"],"type":"object"},"type":"array"}},"required":["path","edits"],"type":"object"}},"type":"function"},{"function":{"name":"list_directory","description":"Get a detailed listing of all files and directories in a specified path.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to list","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"read_file","description":"Read the contents of a file from the file system. By default the complete file is returned; for text files the optional line (1-based start line) and limit (maximum number of lines) arguments select a line range. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to read","type":"string"},"line":{"description":"1-based line number to start reading from (text files only; defaults to the first line)","type":["null","integer"]},"limit":{"description":"Maximum number of lines to read (text files only; defaults to reading through the end of the file)","type":["null","integer"]}},"required":["path","line","limit"],"type":"object"}},"type":"function"},{"function":{"name":"read_multiple_files","description":"Read the contents of multiple files simultaneously.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Files to read","items":{"type":"string"},"type":"array"},"json":{"description":"Return result as JSON","type":["boolean","null"]}},"required":["paths","json"],"type":"object"}},"type":"function"},{"function":{"name":"search_files_content","description":"Searches for text or regex patterns in the content of files matching a GLOB pattern.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Starting directory","type":"string"},"query":{"description":"Text or regex to search","type":"string"},"is_regex":{"description":"Treat query as regex","type":["boolean","null"]},"excludePatterns":{"description":"Patterns to exclude","items":{"type":"string"},"type":["null","array"]}},"required":["path","query","is_regex","excludePatterns"],"type":"object"}},"type":"function"},{"function":{"name":"write_file","description":"Create a new file or completely overwrite an existing file with new content.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to write","type":"string"},"content":{"description":"File content","type":"string"}},"required":["path","content"],"type":"object"}},"type":"function"},{"function":{"name":"create_directory","description":"Create one or more new directories or nested directory structures.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"},{"function":{"name":"remove_directory","description":"Remove one or more empty directories.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"}],"stream":true}' + body: '{"messages":[{"content":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.\n","role":"system"},{"content":"## Filesystem Tools\n\n- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output","role":"system"},{"content":"How many files in testdata/working_dir? Only output the number.","role":"user"}],"model":"gpt-4o","max_tokens":16384,"stream_options":{"include_usage":true},"tools":[{"function":{"name":"directory_tree","description":"Get a recursive tree view of files and directories as a JSON structure.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to traverse","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"edit_file","description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to edit","type":"string"},"edits":{"description":"Edits to apply","items":{"additionalProperties":false,"properties":{"oldText":{"description":"Exact text to replace","type":"string"},"newText":{"description":"Replacement text","type":"string"}},"required":["oldText","newText"],"type":"object"},"type":"array"}},"required":["path","edits"],"type":"object"}},"type":"function"},{"function":{"name":"list_directory","description":"Get a detailed listing of all files and directories in a specified path.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to list","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"read_file","description":"Read the contents of a file from the file system. By default the complete file is returned; for text files the optional line (1-based start line) and limit (maximum number of lines) arguments select a line range. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to read","type":"string"},"line":{"description":"1-based line number to start reading from (text files only; defaults to the first line)","type":["null","integer"]},"limit":{"description":"Maximum number of lines to read (text files only; defaults to reading through the end of the file)","type":["null","integer"]}},"required":["path","line","limit"],"type":"object"}},"type":"function"},{"function":{"name":"read_multiple_files","description":"Read the contents of multiple files simultaneously.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Files to read","items":{"type":"string"},"type":"array"},"json":{"description":"Return result as JSON","type":["boolean","null"]}},"required":["paths","json"],"type":"object"}},"type":"function"},{"function":{"name":"search_files_content","description":"Searches for text or regex patterns in the content of files matching a GLOB pattern.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Starting directory","type":"string"},"query":{"description":"Text or regex to search","type":"string"},"is_regex":{"description":"Treat query as regex","type":["boolean","null"]},"excludePatterns":{"anyOf":[{"description":"Patterns to exclude","items":{"type":"string"},"type":"array"},{"type":"null"}]}},"required":["path","query","is_regex","excludePatterns"],"type":"object"}},"type":"function"},{"function":{"name":"write_file","description":"Create a new file or completely overwrite an existing file with new content.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to write","type":"string"},"content":{"description":"File content","type":"string"}},"required":["path","content"],"type":"object"}},"type":"function"},{"function":{"name":"create_directory","description":"Create one or more new directories or nested directory structures.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"},{"function":{"name":"remove_directory","description":"Remove one or more empty directories.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"}],"stream":true}' url: https://api.openai.com/v1/chat/completions method: POST response: @@ -54,7 +54,7 @@ interactions: proto_minor: 1 content_length: 0 host: api.openai.com - body: '{"messages":[{"content":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.\n","role":"system"},{"content":"## Filesystem Tools\n\n- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output","role":"system"},{"content":"How many files in testdata/working_dir? Only output the number.","role":"user"},{"tool_calls":[{"id":"call_dsl9jWekN0H1do1ClfeyR1iA","function":{"arguments":"{\"path\":\"testdata/working_dir\"}","name":"list_directory"},"type":"function"}],"role":"assistant"},{"content":"FILE README.me\n","tool_call_id":"call_dsl9jWekN0H1do1ClfeyR1iA","role":"tool"}],"model":"gpt-4o","max_tokens":16384,"stream_options":{"include_usage":true},"tools":[{"function":{"name":"directory_tree","description":"Get a recursive tree view of files and directories as a JSON structure.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to traverse","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"edit_file","description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to edit","type":"string"},"edits":{"description":"Edits to apply","items":{"additionalProperties":false,"properties":{"oldText":{"description":"Exact text to replace","type":"string"},"newText":{"description":"Replacement text","type":"string"}},"required":["oldText","newText"],"type":"object"},"type":"array"}},"required":["path","edits"],"type":"object"}},"type":"function"},{"function":{"name":"list_directory","description":"Get a detailed listing of all files and directories in a specified path.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to list","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"read_file","description":"Read the contents of a file from the file system. By default the complete file is returned; for text files the optional line (1-based start line) and limit (maximum number of lines) arguments select a line range. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to read","type":"string"},"line":{"description":"1-based line number to start reading from (text files only; defaults to the first line)","type":["null","integer"]},"limit":{"description":"Maximum number of lines to read (text files only; defaults to reading through the end of the file)","type":["null","integer"]}},"required":["path","line","limit"],"type":"object"}},"type":"function"},{"function":{"name":"read_multiple_files","description":"Read the contents of multiple files simultaneously.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Files to read","items":{"type":"string"},"type":"array"},"json":{"description":"Return result as JSON","type":["boolean","null"]}},"required":["paths","json"],"type":"object"}},"type":"function"},{"function":{"name":"search_files_content","description":"Searches for text or regex patterns in the content of files matching a GLOB pattern.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Starting directory","type":"string"},"query":{"description":"Text or regex to search","type":"string"},"is_regex":{"description":"Treat query as regex","type":["boolean","null"]},"excludePatterns":{"description":"Patterns to exclude","items":{"type":"string"},"type":["null","array"]}},"required":["path","query","is_regex","excludePatterns"],"type":"object"}},"type":"function"},{"function":{"name":"write_file","description":"Create a new file or completely overwrite an existing file with new content.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to write","type":"string"},"content":{"description":"File content","type":"string"}},"required":["path","content"],"type":"object"}},"type":"function"},{"function":{"name":"create_directory","description":"Create one or more new directories or nested directory structures.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"},{"function":{"name":"remove_directory","description":"Remove one or more empty directories.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"}],"stream":true}' + body: '{"messages":[{"content":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.\n","role":"system"},{"content":"## Filesystem Tools\n\n- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output","role":"system"},{"content":"How many files in testdata/working_dir? Only output the number.","role":"user"},{"tool_calls":[{"id":"call_dsl9jWekN0H1do1ClfeyR1iA","function":{"arguments":"{\"path\":\"testdata/working_dir\"}","name":"list_directory"},"type":"function"}],"role":"assistant"},{"content":"FILE README.me\n","tool_call_id":"call_dsl9jWekN0H1do1ClfeyR1iA","role":"tool"}],"model":"gpt-4o","max_tokens":16384,"stream_options":{"include_usage":true},"tools":[{"function":{"name":"directory_tree","description":"Get a recursive tree view of files and directories as a JSON structure.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to traverse","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"edit_file","description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to edit","type":"string"},"edits":{"description":"Edits to apply","items":{"additionalProperties":false,"properties":{"oldText":{"description":"Exact text to replace","type":"string"},"newText":{"description":"Replacement text","type":"string"}},"required":["oldText","newText"],"type":"object"},"type":"array"}},"required":["path","edits"],"type":"object"}},"type":"function"},{"function":{"name":"list_directory","description":"Get a detailed listing of all files and directories in a specified path.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to list","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"read_file","description":"Read the contents of a file from the file system. By default the complete file is returned; for text files the optional line (1-based start line) and limit (maximum number of lines) arguments select a line range. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to read","type":"string"},"line":{"description":"1-based line number to start reading from (text files only; defaults to the first line)","type":["null","integer"]},"limit":{"description":"Maximum number of lines to read (text files only; defaults to reading through the end of the file)","type":["null","integer"]}},"required":["path","line","limit"],"type":"object"}},"type":"function"},{"function":{"name":"read_multiple_files","description":"Read the contents of multiple files simultaneously.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Files to read","items":{"type":"string"},"type":"array"},"json":{"description":"Return result as JSON","type":["boolean","null"]}},"required":["paths","json"],"type":"object"}},"type":"function"},{"function":{"name":"search_files_content","description":"Searches for text or regex patterns in the content of files matching a GLOB pattern.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Starting directory","type":"string"},"query":{"description":"Text or regex to search","type":"string"},"is_regex":{"description":"Treat query as regex","type":["boolean","null"]},"excludePatterns":{"anyOf":[{"description":"Patterns to exclude","items":{"type":"string"},"type":"array"},{"type":"null"}]}},"required":["path","query","is_regex","excludePatterns"],"type":"object"}},"type":"function"},{"function":{"name":"write_file","description":"Create a new file or completely overwrite an existing file with new content.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to write","type":"string"},"content":{"description":"File content","type":"string"}},"required":["path","content"],"type":"object"}},"type":"function"},{"function":{"name":"create_directory","description":"Create one or more new directories or nested directory structures.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"},{"function":{"name":"remove_directory","description":"Remove one or more empty directories.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"}],"stream":true}' url: https://api.openai.com/v1/chat/completions method: POST response: diff --git a/e2e/testdata/cassettes/TestExec_OpenAI_ToolCall.yaml b/e2e/testdata/cassettes/TestExec_OpenAI_ToolCall.yaml index cf64bad024..8a32bb232c 100644 --- a/e2e/testdata/cassettes/TestExec_OpenAI_ToolCall.yaml +++ b/e2e/testdata/cassettes/TestExec_OpenAI_ToolCall.yaml @@ -8,7 +8,7 @@ interactions: proto_minor: 1 content_length: 0 host: api.openai.com - body: '{"messages":[{"content":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.\n","role":"system"},{"content":"## Filesystem Tools\n\n- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output","role":"system"},{"content":"How many files in testdata/working_dir? Only output the number.","role":"user"}],"model":"gpt-4o","max_tokens":16384,"stream_options":{"include_usage":true},"tools":[{"function":{"name":"directory_tree","description":"Get a recursive tree view of files and directories as a JSON structure.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to traverse","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"edit_file","description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to edit","type":"string"},"edits":{"description":"Edits to apply","items":{"additionalProperties":false,"properties":{"oldText":{"description":"Exact text to replace","type":"string"},"newText":{"description":"Replacement text","type":"string"}},"required":["oldText","newText"],"type":"object"},"type":"array"}},"required":["path","edits"],"type":"object"}},"type":"function"},{"function":{"name":"list_directory","description":"Get a detailed listing of all files and directories in a specified path.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to list","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"read_file","description":"Read the contents of a file from the file system. By default the complete file is returned; for text files the optional line (1-based start line) and limit (maximum number of lines) arguments select a line range. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to read","type":"string"},"line":{"description":"1-based line number to start reading from (text files only; defaults to the first line)","type":["null","integer"]},"limit":{"description":"Maximum number of lines to read (text files only; defaults to reading through the end of the file)","type":["null","integer"]}},"required":["path","line","limit"],"type":"object"}},"type":"function"},{"function":{"name":"read_multiple_files","description":"Read the contents of multiple files simultaneously.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Files to read","items":{"type":"string"},"type":"array"},"json":{"description":"Return result as JSON","type":["boolean","null"]}},"required":["paths","json"],"type":"object"}},"type":"function"},{"function":{"name":"search_files_content","description":"Searches for text or regex patterns in the content of files matching a GLOB pattern.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Starting directory","type":"string"},"query":{"description":"Text or regex to search","type":"string"},"is_regex":{"description":"Treat query as regex","type":["boolean","null"]},"excludePatterns":{"description":"Patterns to exclude","items":{"type":"string"},"type":["null","array"]}},"required":["path","query","is_regex","excludePatterns"],"type":"object"}},"type":"function"},{"function":{"name":"write_file","description":"Create a new file or completely overwrite an existing file with new content.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to write","type":"string"},"content":{"description":"File content","type":"string"}},"required":["path","content"],"type":"object"}},"type":"function"},{"function":{"name":"create_directory","description":"Create one or more new directories or nested directory structures.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"},{"function":{"name":"remove_directory","description":"Remove one or more empty directories.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"}],"stream":true}' + body: '{"messages":[{"content":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.\n","role":"system"},{"content":"## Filesystem Tools\n\n- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output","role":"system"},{"content":"How many files in testdata/working_dir? Only output the number.","role":"user"}],"model":"gpt-4o","max_tokens":16384,"stream_options":{"include_usage":true},"tools":[{"function":{"name":"directory_tree","description":"Get a recursive tree view of files and directories as a JSON structure.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to traverse","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"edit_file","description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to edit","type":"string"},"edits":{"description":"Edits to apply","items":{"additionalProperties":false,"properties":{"oldText":{"description":"Exact text to replace","type":"string"},"newText":{"description":"Replacement text","type":"string"}},"required":["oldText","newText"],"type":"object"},"type":"array"}},"required":["path","edits"],"type":"object"}},"type":"function"},{"function":{"name":"list_directory","description":"Get a detailed listing of all files and directories in a specified path.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to list","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"read_file","description":"Read the contents of a file from the file system. By default the complete file is returned; for text files the optional line (1-based start line) and limit (maximum number of lines) arguments select a line range. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to read","type":"string"},"line":{"description":"1-based line number to start reading from (text files only; defaults to the first line)","type":["null","integer"]},"limit":{"description":"Maximum number of lines to read (text files only; defaults to reading through the end of the file)","type":["null","integer"]}},"required":["path","line","limit"],"type":"object"}},"type":"function"},{"function":{"name":"read_multiple_files","description":"Read the contents of multiple files simultaneously.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Files to read","items":{"type":"string"},"type":"array"},"json":{"description":"Return result as JSON","type":["boolean","null"]}},"required":["paths","json"],"type":"object"}},"type":"function"},{"function":{"name":"search_files_content","description":"Searches for text or regex patterns in the content of files matching a GLOB pattern.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Starting directory","type":"string"},"query":{"description":"Text or regex to search","type":"string"},"is_regex":{"description":"Treat query as regex","type":["boolean","null"]},"excludePatterns":{"anyOf":[{"description":"Patterns to exclude","items":{"type":"string"},"type":"array"},{"type":"null"}]}},"required":["path","query","is_regex","excludePatterns"],"type":"object"}},"type":"function"},{"function":{"name":"write_file","description":"Create a new file or completely overwrite an existing file with new content.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to write","type":"string"},"content":{"description":"File content","type":"string"}},"required":["path","content"],"type":"object"}},"type":"function"},{"function":{"name":"create_directory","description":"Create one or more new directories or nested directory structures.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"},{"function":{"name":"remove_directory","description":"Remove one or more empty directories.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"}],"stream":true}' url: https://api.openai.com/v1/chat/completions method: POST response: @@ -54,7 +54,7 @@ interactions: proto_minor: 1 content_length: 0 host: api.openai.com - body: '{"messages":[{"content":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.\n","role":"system"},{"content":"## Filesystem Tools\n\n- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output","role":"system"},{"content":"How many files in testdata/working_dir? Only output the number.","role":"user"},{"tool_calls":[{"id":"call_I1tmAsYKD7bveEpXORJwVFgs","function":{"arguments":"{\"path\":\"testdata/working_dir\"}","name":"list_directory"},"type":"function"}],"role":"assistant"},{"content":"FILE README.me\n","tool_call_id":"call_I1tmAsYKD7bveEpXORJwVFgs","role":"tool"}],"model":"gpt-4o","max_tokens":16384,"stream_options":{"include_usage":true},"tools":[{"function":{"name":"directory_tree","description":"Get a recursive tree view of files and directories as a JSON structure.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to traverse","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"edit_file","description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to edit","type":"string"},"edits":{"description":"Edits to apply","items":{"additionalProperties":false,"properties":{"oldText":{"description":"Exact text to replace","type":"string"},"newText":{"description":"Replacement text","type":"string"}},"required":["oldText","newText"],"type":"object"},"type":"array"}},"required":["path","edits"],"type":"object"}},"type":"function"},{"function":{"name":"list_directory","description":"Get a detailed listing of all files and directories in a specified path.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to list","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"read_file","description":"Read the contents of a file from the file system. By default the complete file is returned; for text files the optional line (1-based start line) and limit (maximum number of lines) arguments select a line range. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to read","type":"string"},"line":{"description":"1-based line number to start reading from (text files only; defaults to the first line)","type":["null","integer"]},"limit":{"description":"Maximum number of lines to read (text files only; defaults to reading through the end of the file)","type":["null","integer"]}},"required":["path","line","limit"],"type":"object"}},"type":"function"},{"function":{"name":"read_multiple_files","description":"Read the contents of multiple files simultaneously.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Files to read","items":{"type":"string"},"type":"array"},"json":{"description":"Return result as JSON","type":["boolean","null"]}},"required":["paths","json"],"type":"object"}},"type":"function"},{"function":{"name":"search_files_content","description":"Searches for text or regex patterns in the content of files matching a GLOB pattern.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Starting directory","type":"string"},"query":{"description":"Text or regex to search","type":"string"},"is_regex":{"description":"Treat query as regex","type":["boolean","null"]},"excludePatterns":{"description":"Patterns to exclude","items":{"type":"string"},"type":["null","array"]}},"required":["path","query","is_regex","excludePatterns"],"type":"object"}},"type":"function"},{"function":{"name":"write_file","description":"Create a new file or completely overwrite an existing file with new content.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to write","type":"string"},"content":{"description":"File content","type":"string"}},"required":["path","content"],"type":"object"}},"type":"function"},{"function":{"name":"create_directory","description":"Create one or more new directories or nested directory structures.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"},{"function":{"name":"remove_directory","description":"Remove one or more empty directories.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"}],"stream":true}' + body: '{"messages":[{"content":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.\n","role":"system"},{"content":"## Filesystem Tools\n\n- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output","role":"system"},{"content":"How many files in testdata/working_dir? Only output the number.","role":"user"},{"tool_calls":[{"id":"call_I1tmAsYKD7bveEpXORJwVFgs","function":{"arguments":"{\"path\":\"testdata/working_dir\"}","name":"list_directory"},"type":"function"}],"role":"assistant"},{"content":"FILE README.me\n","tool_call_id":"call_I1tmAsYKD7bveEpXORJwVFgs","role":"tool"}],"model":"gpt-4o","max_tokens":16384,"stream_options":{"include_usage":true},"tools":[{"function":{"name":"directory_tree","description":"Get a recursive tree view of files and directories as a JSON structure.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to traverse","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"edit_file","description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to edit","type":"string"},"edits":{"description":"Edits to apply","items":{"additionalProperties":false,"properties":{"oldText":{"description":"Exact text to replace","type":"string"},"newText":{"description":"Replacement text","type":"string"}},"required":["oldText","newText"],"type":"object"},"type":"array"}},"required":["path","edits"],"type":"object"}},"type":"function"},{"function":{"name":"list_directory","description":"Get a detailed listing of all files and directories in a specified path.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Directory to list","type":"string"}},"required":["path"],"type":"object"}},"type":"function"},{"function":{"name":"read_file","description":"Read the contents of a file from the file system. By default the complete file is returned; for text files the optional line (1-based start line) and limit (maximum number of lines) arguments select a line range. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to read","type":"string"},"line":{"description":"1-based line number to start reading from (text files only; defaults to the first line)","type":["null","integer"]},"limit":{"description":"Maximum number of lines to read (text files only; defaults to reading through the end of the file)","type":["null","integer"]}},"required":["path","line","limit"],"type":"object"}},"type":"function"},{"function":{"name":"read_multiple_files","description":"Read the contents of multiple files simultaneously.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Files to read","items":{"type":"string"},"type":"array"},"json":{"description":"Return result as JSON","type":["boolean","null"]}},"required":["paths","json"],"type":"object"}},"type":"function"},{"function":{"name":"search_files_content","description":"Searches for text or regex patterns in the content of files matching a GLOB pattern.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"Starting directory","type":"string"},"query":{"description":"Text or regex to search","type":"string"},"is_regex":{"description":"Treat query as regex","type":["boolean","null"]},"excludePatterns":{"anyOf":[{"description":"Patterns to exclude","items":{"type":"string"},"type":"array"},{"type":"null"}]}},"required":["path","query","is_regex","excludePatterns"],"type":"object"}},"type":"function"},{"function":{"name":"write_file","description":"Create a new file or completely overwrite an existing file with new content.","parameters":{"additionalProperties":false,"properties":{"path":{"description":"File to write","type":"string"},"content":{"description":"File content","type":"string"}},"required":["path","content"],"type":"object"}},"type":"function"},{"function":{"name":"create_directory","description":"Create one or more new directories or nested directory structures.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"},{"function":{"name":"remove_directory","description":"Remove one or more empty directories.","parameters":{"additionalProperties":false,"properties":{"paths":{"description":"Directories to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},"type":"function"}],"stream":true}' url: https://api.openai.com/v1/chat/completions method: POST response: diff --git a/pkg/model/provider/openai/schema.go b/pkg/model/provider/openai/schema.go index 3d534178d0..0c7b8d70be 100644 --- a/pkg/model/provider/openai/schema.go +++ b/pkg/model/provider/openai/schema.go @@ -202,7 +202,11 @@ func walkSchema(schema map[string]any, fn func(map[string]any)) { // $ref nodes are left untouched (no additionalProperties/type injection): a // newly-required $ref property is instead wrapped as // {"anyOf": [<$ref>, {"type": "null"}]}, OpenAI's documented pattern for an -// optional reference, so the model isn't forced to always emit it. +// optional reference, so the model isn't forced to always emit it. Nullable +// array and object properties use the same anyOf form, whether they were +// newly made nullable or already declared as a ["array", "null"] type pair: +// strict mode rejects the type-pair form when the object lives behind a +// $ref/$defs indirection. Scalar properties keep the ["T", "null"] form. func makeAllRequired(schema shared.FunctionParameters) shared.FunctionParameters { if schema == nil { schema = map[string]any{"type": "object", "properties": map[string]any{}} @@ -255,16 +259,29 @@ func makeAllRequired(schema shared.FunctionParameters) shared.FunctionParameters propertyOrder := tools.SchemaPropertyNames(node) for _, propName := range propertyOrder { newRequired = append(newRequired, propName) - if !originallyRequired[propName] { - if propMap, ok := properties[propName].(map[string]any); ok { - if _, isRef := propMap["$ref"]; isRef { - properties[propName] = map[string]any{ - "anyOf": []any{propMap, map[string]any{"type": "null"}}, - } - } else if t, ok := propMap["type"].(string); ok { - propMap["type"] = []string{t, "null"} - } - } + + propMap, ok := properties[propName].(map[string]any) + if !ok { + continue + } + + if containerType, ok := nullableContainerType(propMap); ok { + propMap["type"] = containerType + properties[propName] = nullableAnyOf(propMap) + continue + } + + if originallyRequired[propName] { + continue + } + + _, isRef := propMap["$ref"] + t, isScalarType := propMap["type"].(string) + switch { + case isRef || t == "array" || t == "object": + properties[propName] = nullableAnyOf(propMap) + case isScalarType: + propMap["type"] = []string{t, "null"} } } @@ -274,6 +291,44 @@ func makeAllRequired(schema shared.FunctionParameters) shared.FunctionParameters return schema } +// nullableContainerType reports the container type ("array" or "object") of a +// schema whose "type" is exactly that container type plus "null", in either +// order and in any of the slice representations produced by schema generators. +func nullableContainerType(schema map[string]any) (string, bool) { + var types []string + switch t := schema["type"].(type) { + case []string: + types = t + case []any: + for _, v := range t { + s, ok := v.(string) + if !ok { + return "", false + } + types = append(types, s) + } + default: + return "", false + } + + if len(types) != 2 { + return "", false + } + for i, t := range types { + other := types[1-i] + if t == "null" && (other == "array" || other == "object") { + return other, true + } + } + return "", false +} + +func nullableAnyOf(schema map[string]any) map[string]any { + return map[string]any{ + "anyOf": []any{schema, map[string]any{"type": "null"}}, + } +} + // isCompositionNode reports whether node describes its shape via $ref or a // composition keyword (anyOf/oneOf/allOf) rather than its own "type". A // "type" sibling combines with these via AND, so injecting one is only safe @@ -346,21 +401,32 @@ func fixSchemaArrayItems(schema shared.FunctionParameters) shared.FunctionParame continue } - checkForMissingItems := false - switch t := prop["type"].(type) { - case string: - checkForMissingItems = t == "array" - case []string: - checkForMissingItems = slices.Contains(t, "array") - } - if !checkForMissingItems { - continue - } - - if _, ok := prop["items"]; !ok { - prop["items"] = map[string]any{"type": "object"} + addMissingArrayItems(prop) + if variants, ok := prop["anyOf"].([]any); ok { + for _, variant := range variants { + if variantMap, ok := variant.(map[string]any); ok { + addMissingArrayItems(variantMap) + } + } } } return schema } + +func addMissingArrayItems(schema map[string]any) { + isArray := false + switch t := schema["type"].(type) { + case string: + isArray = t == "array" + case []string: + isArray = slices.Contains(t, "array") + } + if !isArray { + return + } + + if _, ok := schema["items"]; !ok { + schema["items"] = map[string]any{"type": "object"} + } +} diff --git a/pkg/model/provider/openai/schema_test.go b/pkg/model/provider/openai/schema_test.go index 95583d4afb..ed728c5428 100644 --- a/pkg/model/provider/openai/schema_test.go +++ b/pkg/model/provider/openai/schema_test.go @@ -401,8 +401,15 @@ func TestMakeAllRequired_TypeArrayWithObject(t *testing.T) { // Top-level should have additionalProperties: false assert.Equal(t, false, updated["additionalProperties"]) - // The schema property should also have additionalProperties: false - schemaProps := updated["properties"].(map[string]any)["schema"].(map[string]any) + // The nullable object is expressed as anyOf [object, null] + nullable := updated["properties"].(map[string]any)["schema"].(map[string]any) + variants := nullable["anyOf"].([]any) + require.Len(t, variants, 2) + assert.Equal(t, map[string]any{"type": "null"}, variants[1]) + + // The object variant should also have additionalProperties: false + schemaProps := variants[0].(map[string]any) + assert.Equal(t, "object", schemaProps["type"]) assert.Equal(t, false, schemaProps["additionalProperties"]) // All properties in schema should be required @@ -1191,3 +1198,136 @@ func TestFixSchemaArrayItems(t *testing.T) { "type": "object" }`, string(buf)) } + +func TestConvertParametersToSchema_NullableContainers(t *testing.T) { + t.Parallel() + + stringArray := func() map[string]any { + return map[string]any{"type": "array", "items": map[string]any{"type": "string"}} + } + nullVariant := map[string]any{"type": "null"} + rootWith := func(prop map[string]any, required ...any) map[string]any { + return map[string]any{ + "type": "object", + "additionalProperties": false, + "required": required, + "properties": map[string]any{"p": prop}, + } + } + nestedBehindDefs := func(prop map[string]any) map[string]any { + return map[string]any{ + "type": "object", + "additionalProperties": false, + "required": []any{"f"}, + "properties": map[string]any{"f": map[string]any{"$ref": "#/$defs/N"}}, + "$defs": map[string]any{ + "N": map[string]any{"$ref": "#/$defs/S"}, + "S": rootWith(prop, "p"), + }, + } + } + propertyOf := func(object map[string]any) map[string]any { + return object["properties"].(map[string]any)["p"].(map[string]any) + } + + tests := []struct { + name string + schema map[string]any + property func(out map[string]any) map[string]any + want map[string]any + wantStrict bool + }{ + { + name: "required nullable array behind $defs and $ref", + schema: nestedBehindDefs(map[string]any{"type": []any{"array", "null"}, "items": map[string]any{"type": "string"}}), + property: func(out map[string]any) map[string]any { + return propertyOf(out["$defs"].(map[string]any)["S"].(map[string]any)) + }, + want: map[string]any{"anyOf": []any{stringArray(), nullVariant}}, + wantStrict: true, + }, + { + name: "required nullable array with null first, as emitted by schema generators", + schema: nestedBehindDefs(map[string]any{"type": []any{"null", "array"}, "items": map[string]any{"type": "string"}}), + property: func(out map[string]any) map[string]any { + return propertyOf(out["$defs"].(map[string]any)["S"].(map[string]any)) + }, + want: map[string]any{"anyOf": []any{stringArray(), nullVariant}}, + wantStrict: true, + }, + { + name: "optional top-level array", + schema: rootWith(stringArray()), + property: propertyOf, + want: map[string]any{"anyOf": []any{stringArray(), nullVariant}}, + wantStrict: true, + }, + { + name: "optional top-level array without items gets default items", + schema: rootWith(map[string]any{"type": "array"}), + property: propertyOf, + want: map[string]any{"anyOf": []any{ + map[string]any{"type": "array", "items": map[string]any{"type": "object"}}, + nullVariant, + }}, + wantStrict: true, + }, + { + name: "optional top-level object", + schema: rootWith(map[string]any{ + "type": "object", + "properties": map[string]any{"a": map[string]any{"type": "string"}}, + }), + property: propertyOf, + want: map[string]any{"anyOf": []any{ + map[string]any{ + "type": "object", + "additionalProperties": false, + "required": []any{"a"}, + "properties": map[string]any{"a": map[string]any{"type": []string{"string", "null"}}}, + }, + nullVariant, + }}, + wantStrict: true, + }, + { + name: "optional scalar keeps the type pair", + schema: rootWith(map[string]any{"type": "string"}), + property: propertyOf, + want: map[string]any{"type": []string{"string", "null"}}, + wantStrict: true, + }, + { + name: "optional $ref keeps the anyOf wrapper", + schema: rootWith(map[string]any{"$ref": "#/$defs/thing"}), + property: propertyOf, + want: map[string]any{"anyOf": []any{map[string]any{"$ref": "#/$defs/thing"}, nullVariant}}, + wantStrict: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + out, strict, err := ConvertParametersToSchema(tt.schema) + require.NoError(t, err) + + assert.Equal(t, tt.want, tt.property(out)) + if tt.wantStrict { + assert.True(t, strict) + assert.True(t, isStrictCompatible(out)) + } + assertNoNullableContainerTypePairs(t, out) + }) + } +} + +func assertNoNullableContainerTypePairs(t *testing.T, schema map[string]any) { + t.Helper() + + walkSchema(schema, func(node map[string]any) { + container, isNullablePair := nullableContainerType(node) + assert.False(t, isNullablePair, "found nullable %s type pair: %v", container, node) + }) +} diff --git a/pkg/model/provider/schema_test.go b/pkg/model/provider/schema_test.go index 3d18f8f737..6217e76562 100644 --- a/pkg/model/provider/schema_test.go +++ b/pkg/model/provider/schema_test.go @@ -310,11 +310,16 @@ func TestSchemaForOpenai(t *testing.T) { "type": ["string", "null"] }, "labels": { - "description": "Filter", - "items": { - "type": "string" - }, - "type": ["array", "null"] + "anyOf": [ + { + "description": "Filter", + "items": { + "type": "string" + }, + "type": "array" + }, + {"type": "null"} + ] }, "perPage": { "description": "Results", From 1e8a2050c7b062e686ce64c871904fd79d7536bb Mon Sep 17 00:00:00 2001 From: Simon Ferquel's Clanker Date: Tue, 27 Oct 2026 14:30:26 +0000 Subject: [PATCH 2/3] openai: document nullable schema helpers Signed-off-by: Simon Ferquel's Clanker --- pkg/model/provider/openai/schema.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/model/provider/openai/schema.go b/pkg/model/provider/openai/schema.go index 0c7b8d70be..0c13215f6a 100644 --- a/pkg/model/provider/openai/schema.go +++ b/pkg/model/provider/openai/schema.go @@ -265,7 +265,7 @@ func makeAllRequired(schema shared.FunctionParameters) shared.FunctionParameters continue } - if containerType, ok := nullableContainerType(propMap); ok { + if containerType, isNullableContainer := nullableContainerType(propMap); isNullableContainer { propMap["type"] = containerType properties[propName] = nullableAnyOf(propMap) continue @@ -323,6 +323,8 @@ func nullableContainerType(schema map[string]any) (string, bool) { return "", false } +// nullableAnyOf wraps schema as {"anyOf": [schema, {"type": "null"}]}, the +// nullable form OpenAI strict mode accepts for non-scalar schemas. func nullableAnyOf(schema map[string]any) map[string]any { return map[string]any{ "anyOf": []any{schema, map[string]any{"type": "null"}}, @@ -414,6 +416,8 @@ func fixSchemaArrayItems(schema shared.FunctionParameters) shared.FunctionParame return schema } +// addMissingArrayItems gives an array schema without "items" a default +// {"type": "object"} items schema, which OpenAI requires. func addMissingArrayItems(schema map[string]any) { isArray := false switch t := schema["type"].(type) { From 7e161a611bec451d0516a47ec728e5857243b290 Mon Sep 17 00:00:00 2001 From: Simon Ferquel's Clanker Date: Tue, 27 Oct 2026 15:04:35 +0000 Subject: [PATCH 3/3] openai: keep local $refs valid when wrapping nullable containers Wrapping a property in anyOf relocates its schema to /anyOf/0, so local $refs pointing at or into that property are rewritten to the new location. Strict mode is also disabled for a tool whose normalized schema has a local $ref that no longer resolves. Signed-off-by: Simon Ferquel's Clanker --- pkg/model/provider/openai/schema.go | 133 ++++++++++++++++++++--- pkg/model/provider/openai/schema_test.go | 99 ++++++++++++++++- 2 files changed, 212 insertions(+), 20 deletions(-) diff --git a/pkg/model/provider/openai/schema.go b/pkg/model/provider/openai/schema.go index 0c13215f6a..67cd5ceca4 100644 --- a/pkg/model/provider/openai/schema.go +++ b/pkg/model/provider/openai/schema.go @@ -2,6 +2,7 @@ package openai import ( "iter" + "reflect" "slices" "strconv" "strings" @@ -26,7 +27,25 @@ func ConvertParametersToSchema(params any) (shared.FunctionParameters, bool, err } strict := isStrictCompatible(p) - return fixSchemaArrayItems(removeFormatFields(ensureTypeFields(makeAllRequired(p)))), strict, nil + normalized := fixSchemaArrayItems(removeFormatFields(ensureTypeFields(makeAllRequired(p)))) + return normalized, strict && !hasUnresolvedLocalRef(normalized), nil +} + +// hasUnresolvedLocalRef is the output-side counterpart of the $ref check in +// isStrictCompatible: normalization relocates schemas, and a strict schema +// must never be sent with a local $ref that no longer resolves. +func hasUnresolvedLocalRef(root map[string]any) bool { + unresolved := false + walkSchema(root, func(node map[string]any) { + ref, ok := node["$ref"].(string) + if !ok || (ref != "#" && !strings.HasPrefix(ref, "#/")) { + return + } + if !jsonPointerResolves(root, ref) { + unresolved = true + } + }) + return unresolved } // childSchemas yields every direct sub-schema of node. Both the strict @@ -133,25 +152,25 @@ func isStrictCompatibleRef(root, node map[string]any, ref any) bool { if refStr != "#" && !strings.HasPrefix(refStr, "#/") { return false } - if _, ok := resolveJSONPointer(root, refStr); !ok { + if !jsonPointerResolves(root, refStr) { return false } // No sibling keywords: the node must contain nothing but "$ref". return len(node) == 1 } -// resolveJSONPointer resolves a local JSON pointer (e.g. "#/$defs/thing" or -// "#") against root, per RFC 6901. It only descends through map[string]any +// jsonPointerResolves reports whether a local JSON pointer (e.g. "#/$defs/thing" or +// "#") resolves to a schema object within root, per RFC 6901. It only descends through map[string]any // and []any nodes; anything else, or a missing key/out-of-range index, fails // resolution rather than panicking. -func resolveJSONPointer(root map[string]any, pointer string) (map[string]any, bool) { +func jsonPointerResolves(root map[string]any, pointer string) bool { pointer = strings.TrimPrefix(pointer, "#") if pointer == "" { - return root, true + return true } rest, found := strings.CutPrefix(pointer, "/") if !found { - return nil, false + return false } var current any = root @@ -163,22 +182,22 @@ func resolveJSONPointer(root map[string]any, pointer string) (map[string]any, bo case map[string]any: v, ok := node[tok] if !ok { - return nil, false + return false } current = v case []any: idx, err := strconv.Atoi(tok) if err != nil || idx < 0 || idx >= len(node) { - return nil, false + return false } current = node[idx] default: - return nil, false + return false } } - m, ok := current.(map[string]any) - return m, ok + _, ok := current.(map[string]any) + return ok } // walkSchema calls fn on the given schema node, then recursively walks into @@ -212,6 +231,8 @@ func makeAllRequired(schema shared.FunctionParameters) shared.FunctionParameters schema = map[string]any{"type": "object", "properties": map[string]any{}} } + wrappers := nullableWrappers{} + walkSchema(schema, func(node map[string]any) { if _, ok := node["$ref"]; ok { return @@ -267,7 +288,7 @@ func makeAllRequired(schema shared.FunctionParameters) shared.FunctionParameters if containerType, isNullableContainer := nullableContainerType(propMap); isNullableContainer { propMap["type"] = containerType - properties[propName] = nullableAnyOf(propMap) + properties[propName] = wrappers.wrap(propMap, false) continue } @@ -279,7 +300,7 @@ func makeAllRequired(schema shared.FunctionParameters) shared.FunctionParameters t, isScalarType := propMap["type"].(string) switch { case isRef || t == "array" || t == "object": - properties[propName] = nullableAnyOf(propMap) + properties[propName] = wrappers.wrap(propMap, true) case isScalarType: propMap["type"] = []string{t, "null"} } @@ -288,6 +309,8 @@ func makeAllRequired(schema shared.FunctionParameters) shared.FunctionParameters node["required"] = newRequired }) + wrappers.retargetLocalRefs(schema) + return schema } @@ -323,12 +346,86 @@ func nullableContainerType(schema map[string]any) (string, bool) { return "", false } -// nullableAnyOf wraps schema as {"anyOf": [schema, {"type": "null"}]}, the -// nullable form OpenAI strict mode accepts for non-scalar schemas. -func nullableAnyOf(schema map[string]any) map[string]any { - return map[string]any{ +// nullableWrappers tracks the {"anyOf": [schema, {"type": "null"}]} wrappers +// created while making properties nullable. Wrapping relocates the original +// schema to /anyOf/0, so local $refs pointing at or into it have to be +// rewritten. The value says whether a $ref targeting the wrapped node itself +// should keep seeing the non-null variant: true for properties that were not +// nullable before, false for ones already declared as a nullable type pair. +type nullableWrappers map[uintptr]bool + +func (w nullableWrappers) wrap(schema map[string]any, referrersSeeNonNull bool) map[string]any { + wrapper := map[string]any{ "anyOf": []any{schema, map[string]any{"type": "null"}}, } + w[reflect.ValueOf(wrapper).Pointer()] = referrersSeeNonNull + return wrapper +} + +func (w nullableWrappers) variantOf(node any) (wrapped map[string]any, referrersSeeNonNull, isWrapper bool) { + wrapper, ok := node.(map[string]any) + if !ok { + return nil, false, false + } + referrersSeeNonNull, isWrapper = w[reflect.ValueOf(wrapper).Pointer()] + if !isWrapper { + return nil, false, false + } + wrapped = wrapper["anyOf"].([]any)[0].(map[string]any) + return wrapped, referrersSeeNonNull, true +} + +func (w nullableWrappers) retargetLocalRefs(root map[string]any) { + if len(w) == 0 { + return + } + walkSchema(root, func(node map[string]any) { + if ref, ok := node["$ref"].(string); ok { + node["$ref"] = w.retarget(root, ref) + } + }) +} + +// retarget returns ref rewritten so it addresses the same schema it addressed +// before wrapping. Refs that cannot be resolved are returned unchanged. +func (w nullableWrappers) retarget(root map[string]any, ref string) string { + rest, isLocalPointer := strings.CutPrefix(ref, "#/") + if !isLocalPointer { + return ref + } + + var current any = root + rewritten := []string{} + for token := range strings.SplitSeq(rest, "/") { + if wrapped, _, isWrapper := w.variantOf(current); isWrapper { + rewritten = append(rewritten, "anyOf", "0") + current = wrapped + } + + key := strings.ReplaceAll(strings.ReplaceAll(token, "~1", "/"), "~0", "~") + switch node := current.(type) { + case map[string]any: + child, ok := node[key] + if !ok { + return ref + } + current = child + case []any: + index, err := strconv.Atoi(key) + if err != nil || index < 0 || index >= len(node) { + return ref + } + current = node[index] + default: + return ref + } + rewritten = append(rewritten, token) + } + + if _, referrersSeeNonNull, isWrapper := w.variantOf(current); isWrapper && referrersSeeNonNull { + rewritten = append(rewritten, "anyOf", "0") + } + return "#/" + strings.Join(rewritten, "/") } // isCompositionNode reports whether node describes its shape via $ref or a diff --git a/pkg/model/provider/openai/schema_test.go b/pkg/model/provider/openai/schema_test.go index ed728c5428..7100ae07d4 100644 --- a/pkg/model/provider/openai/schema_test.go +++ b/pkg/model/provider/openai/schema_test.go @@ -2,6 +2,7 @@ package openai import ( "encoding/json" + "strings" "testing" "github.com/openai/openai-go/v3/shared" @@ -1226,6 +1227,10 @@ func TestConvertParametersToSchema_NullableContainers(t *testing.T) { }, } } + withDefs := func(schema map[string]any, name string) map[string]any { + schema["$defs"] = map[string]any{name: map[string]any{"type": "string"}} + return schema + } propertyOf := func(object map[string]any) map[string]any { return object["properties"].(map[string]any)["p"].(map[string]any) } @@ -1299,10 +1304,10 @@ func TestConvertParametersToSchema_NullableContainers(t *testing.T) { }, { name: "optional $ref keeps the anyOf wrapper", - schema: rootWith(map[string]any{"$ref": "#/$defs/thing"}), + schema: withDefs(rootWith(map[string]any{"$ref": "#/$defs/thing"}), "thing"), property: propertyOf, want: map[string]any{"anyOf": []any{map[string]any{"$ref": "#/$defs/thing"}, nullVariant}}, - wantStrict: false, + wantStrict: true, }, } @@ -1319,6 +1324,7 @@ func TestConvertParametersToSchema_NullableContainers(t *testing.T) { assert.True(t, isStrictCompatible(out)) } assertNoNullableContainerTypePairs(t, out) + assertEveryLocalRefResolves(t, out) }) } } @@ -1331,3 +1337,92 @@ func assertNoNullableContainerTypePairs(t *testing.T, schema map[string]any) { assert.False(t, isNullablePair, "found nullable %s type pair: %v", container, node) }) } + +func assertEveryLocalRefResolves(t *testing.T, schema map[string]any) { + t.Helper() + + walkSchema(schema, func(node map[string]any) { + ref, ok := node["$ref"].(string) + if !ok || !strings.HasPrefix(ref, "#") { + return + } + assert.True(t, jsonPointerResolves(schema, ref), "unresolved local $ref %q", ref) + }) +} + +func TestConvertParametersToSchema_LocalRefsFollowWrappedContainers(t *testing.T) { + t.Parallel() + + optionalConfig := func() map[string]any { + return map[string]any{ + "type": "object", + "required": []any{"values"}, + "properties": map[string]any{ + "values": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + }, + } + } + rootWithConfigAndRef := func(ref string) map[string]any { + return map[string]any{ + "type": "object", + "required": []any{"copy"}, + "properties": map[string]any{ + "cfg": optionalConfig(), + "copy": map[string]any{"$ref": ref}, + }, + "$defs": map[string]any{"thing": map[string]any{"type": "string"}}, + } + } + + tests := []struct { + name string + ref string + wantRef string + }{ + { + name: "ref into a descendant of a wrapped property", + ref: "#/properties/cfg/properties/values", + wantRef: "#/properties/cfg/anyOf/0/properties/values", + }, + { + name: "ref to the wrapped property itself", + ref: "#/properties/cfg", + wantRef: "#/properties/cfg/anyOf/0", + }, + { + name: "ref into $defs is untouched", + ref: "#/$defs/thing", + wantRef: "#/$defs/thing", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + out, strict, err := ConvertParametersToSchema(rootWithConfigAndRef(tt.ref)) + require.NoError(t, err) + + copyProp := out["properties"].(map[string]any)["copy"].(map[string]any) + assert.Equal(t, tt.wantRef, copyProp["$ref"]) + assertEveryLocalRefResolves(t, out) + assert.True(t, strict) + }) + } +} + +func TestConvertParametersToSchema_UnresolvableLocalRefIsNotStrict(t *testing.T) { + t.Parallel() + + schema := map[string]any{ + "type": "object", + "required": []any{"copy"}, + "properties": map[string]any{ + "copy": map[string]any{"$ref": "#/properties/missing"}, + }, + } + + _, strict, err := ConvertParametersToSchema(schema) + require.NoError(t, err) + assert.False(t, strict) +}