diff --git a/Anthropic.SDK.Tests/SkillsTests.cs b/Anthropic.SDK.Tests/SkillsTests.cs new file mode 100644 index 0000000..05b2cff --- /dev/null +++ b/Anthropic.SDK.Tests/SkillsTests.cs @@ -0,0 +1,339 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Anthropic.SDK.Common; +using Anthropic.SDK.Constants; +using Anthropic.SDK.Messaging; + +namespace Anthropic.SDK.Tests +{ + [TestClass] + public class SkillsTests + { + [TestMethod] + public void TestContainerSerialization() + { + // Test that Container serializes correctly + var container = new Container + { + Skills = new List + { + new Skill + { + Type = "anthropic", + SkillId = "pptx", + Version = "latest" + } + } + }; + + var parameters = new MessageParameters + { + Model = AnthropicModels.Claude4Sonnet, + MaxTokens = 4096, + Messages = new List + { + new Message(RoleType.User, "Create a presentation about renewable energy") + }, + Container = container, + Tools = new List + { + new Function("code_execution", "code_execution_20250825", new Dictionary + { + { "name", "code_execution" } + }) + } + }; + + var json = JsonSerializer.Serialize(parameters, new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }); + + // Verify container is in the JSON + Assert.IsTrue(json.Contains("\"container\"")); + Assert.IsTrue(json.Contains("\"skills\"")); + Assert.IsTrue(json.Contains("\"pptx\"")); + Assert.IsTrue(json.Contains("\"anthropic\"")); + } + + [TestMethod] + public void TestContainerWithId() + { + // Test that Container with ID serializes correctly for container reuse + var container = new Container + { + Id = "container_abc123", + Skills = new List + { + new Skill + { + Type = "anthropic", + SkillId = "xlsx", + Version = "latest" + } + } + }; + + var parameters = new MessageParameters + { + Model = AnthropicModels.Claude4Sonnet, + MaxTokens = 4096, + Messages = new List + { + new Message(RoleType.User, "Continue with the spreadsheet") + }, + Container = container + }; + + var json = JsonSerializer.Serialize(parameters, new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }); + + // Verify container ID is in the JSON + Assert.IsTrue(json.Contains("\"id\"")); + Assert.IsTrue(json.Contains("\"container_abc123\"")); + } + + [TestMethod] + public void TestMultipleSkills() + { + // Test multiple skills (up to 8 allowed) + var container = new Container + { + Skills = new List + { + new Skill { Type = "anthropic", SkillId = "pptx", Version = "latest" }, + new Skill { Type = "anthropic", SkillId = "xlsx", Version = "latest" }, + new Skill { Type = "anthropic", SkillId = "docx", Version = "latest" }, + new Skill { Type = "anthropic", SkillId = "pdf", Version = "latest" } + } + }; + + var parameters = new MessageParameters + { + Model = AnthropicModels.Claude4Sonnet, + MaxTokens = 4096, + Messages = new List + { + new Message(RoleType.User, "Test multiple skills") + }, + Container = container + }; + + var json = JsonSerializer.Serialize(parameters, new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }); + + // Verify all skills are present + Assert.IsTrue(json.Contains("\"pptx\"")); + Assert.IsTrue(json.Contains("\"xlsx\"")); + Assert.IsTrue(json.Contains("\"docx\"")); + Assert.IsTrue(json.Contains("\"pdf\"")); + } + + [TestMethod] + public void TestCustomSkill() + { + // Test custom skill type + var container = new Container + { + Skills = new List + { + new Skill + { + Type = "custom", + SkillId = "my-custom-skill-id-123", + Version = "1.0.0" + } + } + }; + + var parameters = new MessageParameters + { + Model = AnthropicModels.Claude4Sonnet, + MaxTokens = 4096, + Messages = new List + { + new Message(RoleType.User, "Use my custom skill") + }, + Container = container + }; + + var json = JsonSerializer.Serialize(parameters, new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }); + + // Verify custom skill is present + Assert.IsTrue(json.Contains("\"custom\"")); + Assert.IsTrue(json.Contains("\"my-custom-skill-id-123\"")); + } + + [TestMethod] + public void TestBashCodeExecutionContentTypes() + { + // Test that bash code execution content types can be created + var bashOutput = new BashCodeExecutionOutputContent + { + FileId = "file_123abc" + }; + + Assert.AreEqual(ContentType.bash_code_execution_output, bashOutput.Type); + Assert.AreEqual("file_123abc", bashOutput.FileId); + + var bashResult = new BashCodeExecutionResultContent + { + Stdout = "Success output", + Stderr = "", + ReturnCode = 0, + Content = new List { bashOutput } + }; + + Assert.AreEqual(ContentType.bash_code_execution_result, bashResult.Type); + Assert.AreEqual("Success output", bashResult.Stdout); + Assert.AreEqual(0, bashResult.ReturnCode); + Assert.AreEqual(1, bashResult.Content.Count); + + var bashToolResult = new BashCodeExecutionToolResultContent + { + ToolUseId = "tool_use_123", + Content = bashResult + }; + + Assert.AreEqual(ContentType.bash_code_execution_tool_result, bashToolResult.Type); + Assert.AreEqual("tool_use_123", bashToolResult.ToolUseId); + + var bashError = new BashCodeExecutionToolResultErrorContent + { + ErrorCode = "execution_time_exceeded" + }; + + Assert.AreEqual(ContentType.bash_code_execution_tool_result_error, bashError.Type); + Assert.AreEqual("execution_time_exceeded", bashError.ErrorCode); + } + + [TestMethod] + public void TestContainerResponseDeserialization() + { + // Test that ContainerResponse can be deserialized from JSON + var json = @"{ + ""id"": ""msg_123"", + ""type"": ""message"", + ""role"": ""assistant"", + ""content"": [{""type"": ""text"", ""text"": ""Hello""}], + ""model"": ""claude-sonnet-4-5-20250929"", + ""stop_reason"": ""end_turn"", + ""usage"": { + ""input_tokens"": 10, + ""output_tokens"": 5 + }, + ""container"": { + ""id"": ""container_abc123"" + } + }"; + + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + Converters = { Extensions.ContentConverter.Instance } + }; + + var response = JsonSerializer.Deserialize(json, options); + + Assert.IsNotNull(response); + Assert.IsNotNull(response.Container); + Assert.AreEqual("container_abc123", response.Container.Id); + } + + [TestMethod] + public void TestBashCodeExecutionSerialization() + { + // Test that bash code execution content serializes correctly + var bashOutput = new BashCodeExecutionOutputContent + { + FileId = "file_xyz789" + }; + + var json = JsonSerializer.Serialize(bashOutput, new JsonSerializerOptions + { + WriteIndented = false, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }); + + Assert.IsTrue(json.Contains("\"type\":\"bash_code_execution_output\"")); + Assert.IsTrue(json.Contains("\"file_id\":\"file_xyz789\"")); + } + + [TestMethod] + public void TestBashCodeExecutionDeserialization() + { + // Test that bash code execution content deserializes correctly + var json = @"{ + ""type"": ""bash_code_execution_output"", + ""file_id"": ""file_test123"" + }"; + + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + Converters = { Extensions.ContentConverter.Instance } + }; + + var content = JsonSerializer.Deserialize(json, options); + + Assert.IsNotNull(content); + Assert.IsInstanceOfType(content, typeof(BashCodeExecutionOutputContent)); + var bashOutput = content as BashCodeExecutionOutputContent; + Assert.AreEqual("file_test123", bashOutput.FileId); + } + + [TestMethod] + public async Task TestSkillUseMessage() + { + var client = new AnthropicClient(); + var messages = new List(); + messages.Add(new Message(RoleType.User, "Please create a one-page PowerPoint presentation with the title 'Testing Skills' and a bullet point list with the items 'Skill 1', 'Skill 2', and 'Skill 3'.")); + + var container = new Container + { + Skills = new List + { + new Skill + { + Type = "anthropic", + SkillId = "pptx", // Built-in PowerPoint skill + Version = "latest" + } + } + }; + + var parameters = new MessageParameters() + { + Messages = messages, + MaxTokens = 4096, + Model = AnthropicModels.Claude4Sonnet, + Stream = false, + Temperature = 1.0m, + Container = container, + Tools = new List + { + new Function("code_execution", "code_execution_20250825", + new Dictionary { { "name", "code_execution" } }) + } + }; + var res = await client.Messages.GetClaudeMessageAsync(parameters); + Assert.IsNotNull(res.Message.ToString()); + Assert.IsNotNull(res.Content.LastOrDefault(c => + c is BashCodeExecutionToolResultContent bashCodeExecutionToolResultContent && + bashCodeExecutionToolResultContent.Content is BashCodeExecutionOutputContent bashCodeExecutionOutputContent && + !string.IsNullOrEmpty(bashCodeExecutionOutputContent.FileId)) != null); + } + } +} diff --git a/Anthropic.SDK.Tests/TextEditorCodeExecutionTests.cs b/Anthropic.SDK.Tests/TextEditorCodeExecutionTests.cs new file mode 100644 index 0000000..523fcbe --- /dev/null +++ b/Anthropic.SDK.Tests/TextEditorCodeExecutionTests.cs @@ -0,0 +1,460 @@ +using System.Text.Json; +using Anthropic.SDK.Extensions; +using Anthropic.SDK.Messaging; + +namespace Anthropic.SDK.Tests; + +[TestClass] +public class TextEditorCodeExecutionTests +{ + [TestMethod] + public void TestTextEditorToolUseDeserialization() + { + var json = @"{ + ""type"": ""server_tool_use"", + ""id"": ""srvtoolu_01E6F7G8H9I0J1K2L3M4N5O6"", + ""name"": ""text_editor_code_execution"", + ""input"": { + ""command"": ""str_replace"", + ""path"": ""config.json"", + ""old_str"": ""\""debug\"": true"", + ""new_str"": ""\""debug\"": false"" + } +}"; + + var options = new JsonSerializerOptions + { + Converters = { ContentConverter.Instance } + }; + + var content = JsonSerializer.Deserialize(json, options); + + Assert.IsNotNull(content); + Assert.AreEqual(ContentType.server_tool_use, content.Type); + Assert.AreEqual("srvtoolu_01E6F7G8H9I0J1K2L3M4N5O6", content.Id); + Assert.AreEqual("text_editor_code_execution", content.Name); + Assert.IsNotNull(content.Input); + Assert.AreEqual("str_replace", content.Input.Command); + Assert.AreEqual("config.json", content.Input.Path); + Assert.AreEqual("\"debug\": true", content.Input.OldStr); + Assert.AreEqual("\"debug\": false", content.Input.NewStr); + } + + [TestMethod] + public void TestTextEditorToolResultWithDiffDeserialization() + { + var json = @"{ + ""type"": ""text_editor_code_execution_tool_result"", + ""tool_use_id"": ""srvtoolu_01E6F7G8H9I0J1K2L3M4N5O6"", + ""content"": { + ""type"": ""text_editor_code_execution_result"", + ""oldStart"": 3, + ""oldLines"": 1, + ""newStart"": 3, + ""newLines"": 1, + ""lines"": [""- \""debug\"": true"", ""+ \""debug\"": false""] + } +}"; + + var options = new JsonSerializerOptions + { + Converters = { ContentConverter.Instance } + }; + + var content = JsonSerializer.Deserialize(json, options); + + Assert.IsNotNull(content); + Assert.AreEqual(ContentType.text_editor_code_execution_tool_result, content.Type); + Assert.AreEqual("srvtoolu_01E6F7G8H9I0J1K2L3M4N5O6", content.ToolUseId); + Assert.IsNotNull(content.Content); + + var result = content.Content as TextEditorCodeExecutionResultContent; + Assert.IsNotNull(result); + Assert.AreEqual(ContentType.text_editor_code_execution_result, result.Type); + Assert.AreEqual(3, result.OldStart); + Assert.AreEqual(1, result.OldLines); + Assert.AreEqual(3, result.NewStart); + Assert.AreEqual(1, result.NewLines); + Assert.IsNotNull(result.Lines); + Assert.AreEqual(2, result.Lines.Count); + Assert.AreEqual("- \"debug\": true", result.Lines[0]); + Assert.AreEqual("+ \"debug\": false", result.Lines[1]); + } + + [TestMethod] + public void TestTextEditorCreateToolResultDeserialization() + { + var json = @"{ + ""type"": ""text_editor_code_execution_tool_result"", + ""tool_use_id"": ""srvtoolu_01D5E6F7G8H9I0J1K2L3M4N5"", + ""content"": { + ""type"": ""text_editor_code_execution_result"", + ""is_file_update"": false + } +}"; + + var options = new JsonSerializerOptions + { + Converters = { ContentConverter.Instance } + }; + + var content = JsonSerializer.Deserialize(json, options); + + Assert.IsNotNull(content); + Assert.AreEqual(ContentType.text_editor_code_execution_tool_result, content.Type); + Assert.AreEqual("srvtoolu_01D5E6F7G8H9I0J1K2L3M4N5", content.ToolUseId); + Assert.IsNotNull(content.Content); + + var result = content.Content as TextEditorCodeExecutionResultContent; + Assert.IsNotNull(result); + Assert.AreEqual(ContentType.text_editor_code_execution_result, result.Type); + Assert.AreEqual(false, result.IsFileUpdate); + } + + [TestMethod] + public void TestTextEditorToolResultErrorDeserialization() + { + var json = @"{ + ""type"": ""text_editor_code_execution_tool_result"", + ""tool_use_id"": ""srvtoolu_01VfmxgZ46TiHbmXgy928hQR"", + ""content"": { + ""type"": ""text_editor_code_execution_tool_result_error"", + ""error_code"": ""unavailable"" + } +}"; + + var options = new JsonSerializerOptions + { + Converters = { ContentConverter.Instance } + }; + + var content = JsonSerializer.Deserialize(json, options); + + Assert.IsNotNull(content); + Assert.AreEqual(ContentType.text_editor_code_execution_tool_result, content.Type); + Assert.AreEqual("srvtoolu_01VfmxgZ46TiHbmXgy928hQR", content.ToolUseId); + Assert.IsNotNull(content.Content); + + var error = content.Content as TextEditorCodeExecutionToolResultErrorContent; + Assert.IsNotNull(error); + Assert.AreEqual(ContentType.text_editor_code_execution_tool_result_error, error.Type); + Assert.AreEqual("unavailable", error.ErrorCode); + } + + [TestMethod] + public void TestPauseTurnStopReason() + { + var json = @"{ + ""id"": ""msg_01XFDUDYJgAACzvnptvVoYEL"", + ""type"": ""message"", + ""role"": ""assistant"", + ""content"": [{""type"": ""text"", ""text"": ""Processing...""}], + ""model"": ""claude-3-5-sonnet-20241022"", + ""stop_reason"": ""pause_turn"", + ""stop_sequence"": null, + ""usage"": { + ""input_tokens"": 10, + ""output_tokens"": 20 + } +}"; + + var options = new JsonSerializerOptions + { + Converters = { ContentConverter.Instance } + }; + + var response = JsonSerializer.Deserialize(json, options); + + Assert.IsNotNull(response); + Assert.AreEqual("pause_turn", response.StopReason); + Assert.AreEqual("msg_01XFDUDYJgAACzvnptvVoYEL", response.Id); + Assert.AreEqual("assistant", response.Role.ToString().ToLower()); + } + + [TestMethod] + public void TestTextEditorViewResultDeserialization() + { + var json = @"{ + ""type"": ""text_editor_code_execution_view_result"", + ""content"": ""function example() {\n return 'Hello, World!';\n}"", + ""file_type"": ""text"", + ""num_lines"": 3, + ""start_line"": 1, + ""total_lines"": 100 +}"; + + var options = new JsonSerializerOptions + { + Converters = { ContentConverter.Instance } + }; + + var content = JsonSerializer.Deserialize(json, options); + + Assert.IsNotNull(content); + Assert.AreEqual(ContentType.text_editor_code_execution_view_result, content.Type); + Assert.AreEqual("function example() {\n return 'Hello, World!';\n}", content.Content); + Assert.AreEqual("text", content.FileType); + Assert.AreEqual(3, content.NumLines); + Assert.AreEqual(1, content.StartLine); + Assert.AreEqual(100, content.TotalLines); + } + + [TestMethod] + public void TestTextEditorViewResultWithNullableFieldsDeserialization() + { + var json = @"{ + ""type"": ""text_editor_code_execution_view_result"", + ""content"": ""Full file content"", + ""file_type"": ""pdf"" +}"; + + var options = new JsonSerializerOptions + { + Converters = { ContentConverter.Instance } + }; + + var content = JsonSerializer.Deserialize(json, options); + + Assert.IsNotNull(content); + Assert.AreEqual(ContentType.text_editor_code_execution_view_result, content.Type); + Assert.AreEqual("Full file content", content.Content); + Assert.AreEqual("pdf", content.FileType); + Assert.IsNull(content.NumLines); + Assert.IsNull(content.StartLine); + Assert.IsNull(content.TotalLines); + } + + [TestMethod] + public void TestTextEditorCreateResultDeserialization() + { + var json = @"{ + ""type"": ""text_editor_code_execution_create_result"", + ""is_file_update"": false +}"; + + var options = new JsonSerializerOptions + { + Converters = { ContentConverter.Instance } + }; + + var content = JsonSerializer.Deserialize(json, options); + + Assert.IsNotNull(content); + Assert.AreEqual(ContentType.text_editor_code_execution_create_result, content.Type); + Assert.AreEqual(false, content.IsFileUpdate); + } + + [TestMethod] + public void TestTextEditorCreateResultFileUpdateDeserialization() + { + var json = @"{ + ""type"": ""text_editor_code_execution_create_result"", + ""is_file_update"": true +}"; + + var options = new JsonSerializerOptions + { + Converters = { ContentConverter.Instance } + }; + + var content = JsonSerializer.Deserialize(json, options); + + Assert.IsNotNull(content); + Assert.AreEqual(ContentType.text_editor_code_execution_create_result, content.Type); + Assert.AreEqual(true, content.IsFileUpdate); + } + + [TestMethod] + public void TestTextEditorStrReplaceResultDeserialization() + { + var json = @"{ + ""type"": ""text_editor_code_execution_str_replace_result"", + ""old_start"": 5, + ""old_lines"": 2, + ""new_start"": 5, + ""new_lines"": 3, + ""lines"": [ + ""- old line 1"", + ""- old line 2"", + ""+ new line 1"", + ""+ new line 2"", + ""+ new line 3"" + ] +}"; + + var options = new JsonSerializerOptions + { + Converters = { ContentConverter.Instance } + }; + + var content = JsonSerializer.Deserialize(json, options); + + Assert.IsNotNull(content); + Assert.AreEqual(ContentType.text_editor_code_execution_str_replace_result, content.Type); + Assert.AreEqual(5, content.OldStart); + Assert.AreEqual(2, content.OldLines); + Assert.AreEqual(5, content.NewStart); + Assert.AreEqual(3, content.NewLines); + Assert.IsNotNull(content.Lines); + Assert.AreEqual(5, content.Lines.Count); + Assert.AreEqual("- old line 1", content.Lines[0]); + Assert.AreEqual("+ new line 3", content.Lines[4]); + } + + [TestMethod] + public void TestTextEditorStrReplaceResultWithNullsDeserialization() + { + var json = @"{ + ""type"": ""text_editor_code_execution_str_replace_result"", + ""old_start"": null, + ""old_lines"": null, + ""new_start"": null, + ""new_lines"": null, + ""lines"": null +}"; + + var options = new JsonSerializerOptions + { + Converters = { ContentConverter.Instance } + }; + + var content = JsonSerializer.Deserialize(json, options); + + Assert.IsNotNull(content); + Assert.AreEqual(ContentType.text_editor_code_execution_str_replace_result, content.Type); + Assert.IsNull(content.OldStart); + Assert.IsNull(content.OldLines); + Assert.IsNull(content.NewStart); + Assert.IsNull(content.NewLines); + Assert.IsNull(content.Lines); + } + + [TestMethod] + public void TestTextEditorToolResultErrorWithMessageDeserialization() + { + var json = @"{ + ""type"": ""text_editor_code_execution_tool_result_error"", + ""error_code"": ""file_not_found"", + ""error_message"": ""The specified file does not exist"" +}"; + + var options = new JsonSerializerOptions + { + Converters = { ContentConverter.Instance } + }; + + var content = JsonSerializer.Deserialize(json, options); + + Assert.IsNotNull(content); + Assert.AreEqual(ContentType.text_editor_code_execution_tool_result_error, content.Type); + Assert.AreEqual("file_not_found", content.ErrorCode); + Assert.AreEqual("The specified file does not exist", content.ErrorMessage); + } + + [TestMethod] + public void TestTextEditorToolResultWithCreateResultDeserialization() + { + var json = @"{ + ""type"": ""text_editor_code_execution_tool_result"", + ""tool_use_id"": ""srvtoolu_01ABC123"", + ""content"": { + ""type"": ""text_editor_code_execution_create_result"", + ""is_file_update"": false + } +}"; + + var options = new JsonSerializerOptions + { + Converters = { ContentConverter.Instance } + }; + + var content = JsonSerializer.Deserialize(json, options); + + Assert.IsNotNull(content); + Assert.AreEqual(ContentType.text_editor_code_execution_tool_result, content.Type); + Assert.AreEqual("srvtoolu_01ABC123", content.ToolUseId); + Assert.IsNotNull(content.Content); + + var createResult = content.Content as TextEditorCodeExecutionCreateResultContent; + Assert.IsNotNull(createResult); + Assert.AreEqual(ContentType.text_editor_code_execution_create_result, createResult.Type); + Assert.AreEqual(false, createResult.IsFileUpdate); + } + + [TestMethod] + public void TestTextEditorToolResultWithStrReplaceResultDeserialization() + { + var json = @"{ + ""type"": ""text_editor_code_execution_tool_result"", + ""tool_use_id"": ""srvtoolu_01XYZ789"", + ""content"": { + ""type"": ""text_editor_code_execution_str_replace_result"", + ""old_start"": 10, + ""old_lines"": 1, + ""new_start"": 10, + ""new_lines"": 1, + ""lines"": [""- old content"", ""+ new content""] + } +}"; + + var options = new JsonSerializerOptions + { + Converters = { ContentConverter.Instance } + }; + + var content = JsonSerializer.Deserialize(json, options); + + Assert.IsNotNull(content); + Assert.AreEqual(ContentType.text_editor_code_execution_tool_result, content.Type); + Assert.AreEqual("srvtoolu_01XYZ789", content.ToolUseId); + Assert.IsNotNull(content.Content); + + var strReplaceResult = content.Content as TextEditorCodeExecutionStrReplaceResultContent; + Assert.IsNotNull(strReplaceResult); + Assert.AreEqual(ContentType.text_editor_code_execution_str_replace_result, strReplaceResult.Type); + Assert.AreEqual(10, strReplaceResult.OldStart); + Assert.AreEqual(1, strReplaceResult.OldLines); + Assert.AreEqual(10, strReplaceResult.NewStart); + Assert.AreEqual(1, strReplaceResult.NewLines); + Assert.IsNotNull(strReplaceResult.Lines); + Assert.AreEqual(2, strReplaceResult.Lines.Count); + } + + [TestMethod] + public void TestTextEditorToolResultWithViewResultDeserialization() + { + var json = @"{ + ""type"": ""text_editor_code_execution_tool_result"", + ""tool_use_id"": ""srvtoolu_01VIEW123"", + ""content"": { + ""type"": ""text_editor_code_execution_view_result"", + ""content"": ""line 1\nline 2\nline 3"", + ""file_type"": ""text"", + ""num_lines"": 3, + ""start_line"": 1, + ""total_lines"": 100 + } +}"; + + var options = new JsonSerializerOptions + { + Converters = { ContentConverter.Instance } + }; + + var content = JsonSerializer.Deserialize(json, options); + + Assert.IsNotNull(content); + Assert.AreEqual(ContentType.text_editor_code_execution_tool_result, content.Type); + Assert.AreEqual("srvtoolu_01VIEW123", content.ToolUseId); + Assert.IsNotNull(content.Content); + + var viewResult = content.Content as TextEditorCodeExecutionViewResultContent; + Assert.IsNotNull(viewResult); + Assert.AreEqual(ContentType.text_editor_code_execution_view_result, viewResult.Type); + Assert.AreEqual("line 1\nline 2\nline 3", viewResult.Content); + Assert.AreEqual("text", viewResult.FileType); + Assert.AreEqual(3, viewResult.NumLines); + Assert.AreEqual(1, viewResult.StartLine); + Assert.AreEqual(100, viewResult.TotalLines); + } +} diff --git a/Anthropic.SDK/AnthropicClient.cs b/Anthropic.SDK/AnthropicClient.cs index fc2d66a..a6e54c3 100644 --- a/Anthropic.SDK/AnthropicClient.cs +++ b/Anthropic.SDK/AnthropicClient.cs @@ -7,6 +7,8 @@ using System.Text.Unicode; using Anthropic.SDK.Batches; using Anthropic.SDK.Models; +using Anthropic.SDK.Files; +using Anthropic.SDK.Skills; namespace Anthropic.SDK { @@ -33,7 +35,7 @@ public class AnthropicClient : IDisposable /// /// Version of the Anthropic Beta API /// - public string AnthropicBetaVersion { get; set; } = "prompt-caching-2024-07-31,message-batches-2024-09-24,computer-use-2024-10-22,pdfs-2024-09-25,output-128k-2025-02-19,mcp-client-2025-04-04"; + public string AnthropicBetaVersion { get; set; } = "prompt-caching-2024-07-31,message-batches-2024-09-24,computer-use-2024-10-22,pdfs-2024-09-25,output-128k-2025-02-19,mcp-client-2025-04-04,code-execution-2025-08-25,skills-2025-10-02,files-api-2025-04-14"; /// /// The API authentication information to use for API calls @@ -67,6 +69,8 @@ public AnthropicClient(APIAuthentication apiKeys = null, HttpClient client = nul Messages = new MessagesEndpoint(this); Batches = new BatchesEndpoint(this); Models = new ModelsEndpoint(this); + Files = new FilesEndpoint(this); + Skills = new SkillsEndpoint(this); } internal static JsonSerializerOptions JsonSerializationOptions { get; } = new() @@ -116,6 +120,16 @@ private HttpClient SetupClient(HttpClient client) /// public ModelsEndpoint Models { get; } + /// + /// Files API allows you to download, list, and manage Claude-generated files. + /// + public FilesEndpoint Files { get; } + + /// + /// Skills API allows you to create, list, retrieve, and delete custom skills that extend Claude's capabilities. + /// + public SkillsEndpoint Skills { get; } + #region IDisposable private bool isDisposed; diff --git a/Anthropic.SDK/Extensions/ContentConverter.cs b/Anthropic.SDK/Extensions/ContentConverter.cs index 03ce4e8..9115583 100644 --- a/Anthropic.SDK/Extensions/ContentConverter.cs +++ b/Anthropic.SDK/Extensions/ContentConverter.cs @@ -48,6 +48,26 @@ public override ContentBase Read(ref Utf8JsonReader reader, Type typeToConvert, return JsonSerializer.Deserialize(root.GetRawText(), options); case "mcp_tool_result": return JsonSerializer.Deserialize(root.GetRawText(), options); + case "bash_code_execution_tool_result": + return JsonSerializer.Deserialize(root.GetRawText(), options); + case "bash_code_execution_result": + return JsonSerializer.Deserialize(root.GetRawText(), options); + case "bash_code_execution_output": + return JsonSerializer.Deserialize(root.GetRawText(), options); + case "bash_code_execution_tool_result_error": + return JsonSerializer.Deserialize(root.GetRawText(), options); + case "text_editor_code_execution_tool_result": + return JsonSerializer.Deserialize(root.GetRawText(), options); + case "text_editor_code_execution_result": + return JsonSerializer.Deserialize(root.GetRawText(), options); + case "text_editor_code_execution_tool_result_error": + return JsonSerializer.Deserialize(root.GetRawText(), options); + case "text_editor_code_execution_view_result": + return JsonSerializer.Deserialize(root.GetRawText(), options); + case "text_editor_code_execution_create_result": + return JsonSerializer.Deserialize(root.GetRawText(), options); + case "text_editor_code_execution_str_replace_result": + return JsonSerializer.Deserialize(root.GetRawText(), options); // Add cases for other types as necessary default: throw new JsonException($"Unknown type {type}"); diff --git a/Anthropic.SDK/Extensions/MessageResponseExtensions.cs b/Anthropic.SDK/Extensions/MessageResponseExtensions.cs new file mode 100644 index 0000000..f734a82 --- /dev/null +++ b/Anthropic.SDK/Extensions/MessageResponseExtensions.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Anthropic.SDK.Messaging; + +namespace Anthropic.SDK.Extensions +{ + /// + /// Extension methods for MessageResponse to simplify common operations. + /// + public static class MessageResponseExtensions + { + /// + /// Downloads all file outputs from bash code execution results to the specified directory path. + /// This is a convenience method that automatically iterates through response content, + /// identifies file outputs, and downloads them using the Files API. + /// + /// The message response containing potential file outputs. + /// The AnthropicClient instance to use for downloading files. + /// The directory path where files should be downloaded. If the directory doesn't exist, it will be created. + /// Optional cancellation token. + /// A list of full file paths for all downloaded files. + /// Thrown when response, client, or outputPath is null or empty. + /// Thrown when the output directory cannot be created. + /// + /// + /// var response = await client.Messages.GetClaudeMessageAsync(parameters); + /// var downloadedFiles = await response.DownloadFilesAsync(client, "C:\\Downloads\\SkillOutputs"); + /// foreach (var filePath in downloadedFiles) + /// { + /// Console.WriteLine($"Downloaded: {filePath}"); + /// } + /// + /// + public static async Task> DownloadFilesAsync( + this MessageResponse response, + AnthropicClient client, + string outputPath, + CancellationToken cancellationToken = default) + { + if (response == null) + { + throw new ArgumentNullException(nameof(response)); + } + + if (client == null) + { + throw new ArgumentNullException(nameof(client)); + } + + if (string.IsNullOrWhiteSpace(outputPath)) + { + throw new ArgumentNullException(nameof(outputPath)); + } + + // Ensure output directory exists + if (!Directory.Exists(outputPath)) + { + Directory.CreateDirectory(outputPath); + } + + var downloadedFiles = new List(); + + // Iterate through content looking for bash code execution results + foreach (var content in response.Content) + { + if (content is BashCodeExecutionToolResultContent bashResult) + { + if (bashResult.Content is BashCodeExecutionResultContent result) + { + // Process all file outputs + foreach (var output in result.Content) + { + if (!string.IsNullOrWhiteSpace(output.FileId)) + { + try + { + // Get file metadata to retrieve the original filename + var metadata = await client.Files.GetFileMetadataAsync( + output.FileId, + cancellationToken); + + // Construct the full output file path + var fileName = metadata?.Filename ?? $"file_{output.FileId}"; + var fullPath = Path.Combine(outputPath, fileName); + + // Download the file + await client.Files.DownloadFileAsync( + output.FileId, + fullPath, + cancellationToken); + + downloadedFiles.Add(fullPath); + } + catch (Exception ex) + { + // Log the error but continue with other files + // Consider adding logging here or rethrowing based on your needs + Console.Error.WriteLine( + $"Failed to download file {output.FileId}: {ex.Message}"); + } + } + } + } + } + } + + return downloadedFiles; + } + + /// + /// Gets all file IDs from bash code execution results in the response. + /// This is useful if you want to handle file downloads manually or perform other operations with the file IDs. + /// + /// The message response to extract file IDs from. + /// A list of file IDs found in the response. + /// + /// + /// var response = await client.Messages.GetClaudeMessageAsync(parameters); + /// var fileIds = response.GetFileIds(); + /// foreach (var fileId in fileIds) + /// { + /// var metadata = await client.Files.GetFileMetadataAsync(fileId); + /// Console.WriteLine($"File: {metadata.Filename} ({metadata.SizeBytes} bytes)"); + /// } + /// + /// + public static List GetFileIds(this MessageResponse response) + { + if (response == null) + { + return new List(); + } + + var fileIds = new List(); + + foreach (var content in response.Content) + { + if (content is BashCodeExecutionToolResultContent bashResult) + { + if (bashResult.Content is BashCodeExecutionResultContent result) + { + fileIds.AddRange(result.Content + .Where(o => !string.IsNullOrWhiteSpace(o.FileId)) + .Select(o => o.FileId)); + } + } + } + + return fileIds; + } + } +} diff --git a/Anthropic.SDK/Files/FileDeleteResponse.cs b/Anthropic.SDK/Files/FileDeleteResponse.cs new file mode 100644 index 0000000..e6ba15b --- /dev/null +++ b/Anthropic.SDK/Files/FileDeleteResponse.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Anthropic.SDK.Files +{ + /// + /// Response returned when a file is successfully deleted. + /// + public class FileDeleteResponse + { + /// + /// ID of the deleted file. + /// + [JsonPropertyName("id")] + public string Id { get; set; } + + /// + /// Deleted object type. For file deletion, this is always "file_deleted". + /// + [JsonPropertyName("type")] + public string Type { get; set; } + } +} diff --git a/Anthropic.SDK/Files/FileListResponse.cs b/Anthropic.SDK/Files/FileListResponse.cs new file mode 100644 index 0000000..81785f8 --- /dev/null +++ b/Anthropic.SDK/Files/FileListResponse.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Anthropic.SDK.Files +{ + /// + /// Response containing a paginated list of files. + /// + public class FileListResponse + { + /// + /// List of file metadata objects. + /// + [JsonPropertyName("data")] + public List Data { get; set; } + + /// + /// Whether there are more results available. + /// + [JsonPropertyName("has_more")] + public bool HasMore { get; set; } + + /// + /// ID of the first file in this page of results. + /// + [JsonPropertyName("first_id")] + public string FirstId { get; set; } + + /// + /// ID of the last file in this page of results. + /// + [JsonPropertyName("last_id")] + public string LastId { get; set; } + } +} diff --git a/Anthropic.SDK/Files/FileMetadata.cs b/Anthropic.SDK/Files/FileMetadata.cs new file mode 100644 index 0000000..70cbf23 --- /dev/null +++ b/Anthropic.SDK/Files/FileMetadata.cs @@ -0,0 +1,53 @@ +using System; +using System.Text.Json.Serialization; + +namespace Anthropic.SDK.Files +{ + /// + /// Metadata for a file uploaded to Claude. + /// + public class FileMetadata + { + /// + /// Unique object identifier. The format and length of IDs may change over time. + /// + [JsonPropertyName("id")] + public string Id { get; set; } + + /// + /// Object type. For files, this is always "file". + /// + [JsonPropertyName("type")] + public string Type { get; set; } + + /// + /// Original filename of the uploaded file. + /// + [JsonPropertyName("filename")] + public string Filename { get; set; } + + /// + /// MIME type of the file. + /// + [JsonPropertyName("mime_type")] + public string MimeType { get; set; } + + /// + /// Size of the file in bytes. + /// + [JsonPropertyName("size_bytes")] + public long SizeBytes { get; set; } + + /// + /// RFC 3339 datetime string representing when the file was created. + /// + [JsonPropertyName("created_at")] + public DateTime CreatedAt { get; set; } + + /// + /// Whether the file can be downloaded. + /// + [JsonPropertyName("downloadable")] + public bool Downloadable { get; set; } + } +} diff --git a/Anthropic.SDK/Files/FilesEndpoint.cs b/Anthropic.SDK/Files/FilesEndpoint.cs new file mode 100644 index 0000000..5a0917d --- /dev/null +++ b/Anthropic.SDK/Files/FilesEndpoint.cs @@ -0,0 +1,314 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; + +namespace Anthropic.SDK.Files +{ + /// + /// Files endpoint for managing Claude-generated files. + /// The Files API allows you to download, list, and manage files generated by Claude during conversations. + /// + public class FilesEndpoint : EndpointBase + { + /// + /// Constructor of the api endpoint. Rather than instantiating this yourself, access it through an instance of as . + /// + /// + internal FilesEndpoint(AnthropicClient client) : base(client) { } + + protected override string Endpoint => "files"; + + /// + /// Lists all files in the organization. Supports pagination using before_id/after_id cursors. + /// + /// Only return files with IDs alphabetically before this file ID. + /// Only return files with IDs alphabetically after this file ID. + /// Number of files to return per page (1-1000, default 20). + /// Optional cancellation token. + /// A paginated list of file metadata objects. + public async Task ListFilesAsync( + string beforeId = null, + string afterId = null, + int limit = 20, + CancellationToken cancellationToken = default) + { + if (limit < 1 || limit > 1000) + { + throw new ArgumentOutOfRangeException(nameof(limit), "Limit must be between 1 and 1000."); + } + + var queryParams = new List { $"limit={limit}" }; + if (!string.IsNullOrEmpty(beforeId)) + queryParams.Add($"before_id={beforeId}"); + if (!string.IsNullOrEmpty(afterId)) + queryParams.Add($"after_id={afterId}"); + + var queryString = "?" + string.Join("&", queryParams); + return await HttpRequestSimple($"{Endpoint}{queryString}", HttpMethod.Get, null, cancellationToken); + } + + /// + /// Retrieves metadata for a specific file by its ID. + /// + /// The ID of the file to retrieve metadata for. + /// Optional cancellation token. + /// The file metadata object. + /// Thrown when fileId is null or empty. + public async Task GetFileMetadataAsync( + string fileId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(fileId)) + { + throw new ArgumentNullException(nameof(fileId), "File ID cannot be null or empty."); + } + + return await HttpRequestSimple($"{Endpoint}/{fileId}", HttpMethod.Get, null, cancellationToken); + } + + /// + /// Deletes a file, making it inaccessible through the API. + /// + /// The ID of the file to delete. + /// Optional cancellation token. + /// A response confirming the file deletion. + /// Thrown when fileId is null or empty. + public async Task DeleteFileAsync( + string fileId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(fileId)) + { + throw new ArgumentNullException(nameof(fileId), "File ID cannot be null or empty."); + } + + return await HttpRequestSimple($"{Endpoint}/{fileId}", HttpMethod.Delete, null, cancellationToken); + } + + /// + /// Uploads a file to Claude for use in conversations. + /// + /// Path to the file to upload. + /// Optional MIME type of the file. If not provided, will be inferred from the file extension. + /// Cancellation token. + /// Metadata about the uploaded file. + /// Thrown when filePath is null, empty, or the file doesn't exist. + public async Task UploadFileAsync(string filePath, string mimeType = null, CancellationToken ctx = default) + { + if (string.IsNullOrWhiteSpace(filePath)) + { + throw new ArgumentException("File path cannot be null or empty.", nameof(filePath)); + } + + if (!File.Exists(filePath)) + { + throw new ArgumentException($"File not found: {filePath}", nameof(filePath)); + } + + var fileName = Path.GetFileName(filePath); +#if NET6_0_OR_GREATER + var fileBytes = await File.ReadAllBytesAsync(filePath, ctx).ConfigureAwait(false); +#else + var fileBytes = File.ReadAllBytes(filePath); +#endif + + // Infer MIME type if not provided + if (string.IsNullOrWhiteSpace(mimeType)) + { + mimeType = GetMimeType(filePath); + } + + return await UploadFileBytesAsync(fileBytes, fileName, mimeType, ctx).ConfigureAwait(false); + } + + /// + /// Uploads a file from a byte array to Claude for use in conversations. + /// + /// The file content as a byte array. + /// The name of the file. + /// MIME type of the file. + /// Cancellation token. + /// Metadata about the uploaded file. + /// Thrown when parameters are invalid. + /// Thrown when fileBytes is null. + public async Task UploadFileBytesAsync(byte[] fileBytes, string fileName, string mimeType, CancellationToken ctx = default) + { + if (fileBytes == null) + { + throw new ArgumentNullException(nameof(fileBytes)); + } + + if (string.IsNullOrWhiteSpace(fileName)) + { + throw new ArgumentException("File name cannot be null or empty.", nameof(fileName)); + } + + if (string.IsNullOrWhiteSpace(mimeType)) + { + throw new ArgumentException("MIME type cannot be null or empty.", nameof(mimeType)); + } + + using var content = new MultipartFormDataContent(); + var fileContent = new ByteArrayContent(fileBytes); + fileContent.Headers.ContentType = new MediaTypeHeaderValue(mimeType); + content.Add(fileContent, "file", fileName); + + var result = await HttpRequestSimple(Url, HttpMethod.Post, content, ctx).ConfigureAwait(false); + + return result; + } + + /// + /// Uploads a file from a stream to Claude for use in conversations. + /// + /// The file content as a stream. + /// The name of the file. + /// MIME type of the file. + /// Cancellation token. + /// Metadata about the uploaded file. + /// Thrown when parameters are invalid. + /// Thrown when fileStream is null. + public async Task UploadFileStreamAsync(Stream fileStream, string fileName, string mimeType, CancellationToken ctx = default) + { + if (fileStream == null) + { + throw new ArgumentNullException(nameof(fileStream)); + } + + if (string.IsNullOrWhiteSpace(fileName)) + { + throw new ArgumentException("File name cannot be null or empty.", nameof(fileName)); + } + + if (string.IsNullOrWhiteSpace(mimeType)) + { + throw new ArgumentException("MIME type cannot be null or empty.", nameof(mimeType)); + } + + using var content = new MultipartFormDataContent(); + var streamContent = new StreamContent(fileStream); + streamContent.Headers.ContentType = new MediaTypeHeaderValue(mimeType); + content.Add(streamContent, "file", fileName); + + var result = await HttpRequestSimple(Url, HttpMethod.Post, content, ctx).ConfigureAwait(false); + + return result; + } + + /// + /// Downloads the contents of a Claude-generated file. + /// + /// ID of the file to download. + /// Optional path to save the file. If not provided, returns the file content as a byte array. + /// Cancellation token. + /// The file content as a byte array if outputPath is not provided. + /// Thrown when fileId is null or empty. + public async Task DownloadFileAsync(string fileId, string outputPath = null, CancellationToken ctx = default) + { + if (string.IsNullOrWhiteSpace(fileId)) + { + throw new ArgumentException("File ID cannot be null or empty.", nameof(fileId)); + } + + var url = $"{Url}/{fileId}/content"; + var response = await HttpRequestRaw(url, HttpMethod.Get, null, streaming: false, ctx).ConfigureAwait(false); + +#if NET6_0_OR_GREATER + var content = await response.Content.ReadAsByteArrayAsync(ctx).ConfigureAwait(false); +#else + var content = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false); +#endif + + if (!string.IsNullOrWhiteSpace(outputPath)) + { + // Ensure the directory exists + var directory = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + +#if NET6_0_OR_GREATER + await File.WriteAllBytesAsync(outputPath, content, ctx).ConfigureAwait(false); +#else + File.WriteAllBytes(outputPath, content); +#endif + } + + return content; + } + + /// + /// Downloads the contents of a Claude-generated file and writes it to a stream. + /// + /// ID of the file to download. + /// The stream to write the file content to. + /// Cancellation token. + /// Thrown when fileId is null or empty. + /// Thrown when outputStream is null. + public async Task DownloadFileToStreamAsync(string fileId, Stream outputStream, CancellationToken ctx = default) + { + if (string.IsNullOrWhiteSpace(fileId)) + { + throw new ArgumentException("File ID cannot be null or empty.", nameof(fileId)); + } + + if (outputStream == null) + { + throw new ArgumentNullException(nameof(outputStream)); + } + + var url = $"{Url}/{fileId}/content"; + var response = await HttpRequestRaw(url, HttpMethod.Get, null, streaming: true, ctx).ConfigureAwait(false); + +#if NET6_0_OR_GREATER + await using var stream = await response.Content.ReadAsStreamAsync(ctx).ConfigureAwait(false); + await stream.CopyToAsync(outputStream, 81920, ctx).ConfigureAwait(false); +#else + using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await stream.CopyToAsync(outputStream, 81920, ctx).ConfigureAwait(false); +#endif + } + + /// + /// Gets the MIME type based on file extension. + /// + private static string GetMimeType(string filePath) + { + var extension = Path.GetExtension(filePath).ToLowerInvariant(); + return extension switch + { + ".pdf" => "application/pdf", + ".txt" => "text/plain", + ".html" => "text/html", + ".htm" => "text/html", + ".json" => "application/json", + ".xml" => "application/xml", + ".csv" => "text/csv", + ".doc" => "application/msword", + ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xls" => "application/vnd.ms-excel", + ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".ppt" => "application/vnd.ms-powerpoint", + ".pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".jpg" => "image/jpeg", + ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".gif" => "image/gif", + ".bmp" => "image/bmp", + ".svg" => "image/svg+xml", + ".webp" => "image/webp", + ".zip" => "application/zip", + ".gz" => "application/gzip", + ".tar" => "application/x-tar", + ".md" => "text/markdown", + ".rtf" => "application/rtf", + _ => "application/octet-stream" + }; + } + } +} diff --git a/Anthropic.SDK/Messaging/Container.cs b/Anthropic.SDK/Messaging/Container.cs new file mode 100644 index 0000000..51714df --- /dev/null +++ b/Anthropic.SDK/Messaging/Container.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Anthropic.SDK.Messaging +{ + /// + /// Container configuration for Skills API + /// + public class Container + { + /// + /// Optional container ID to reuse an existing container from a previous request + /// + [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Id { get; set; } + + /// + /// List of skills to enable in the container (max 8 skills) + /// + [JsonPropertyName("skills")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List Skills { get; set; } + } + + /// + /// Skill definition for use in containers + /// + public class Skill + { + /// + /// Type of skill - either "anthropic" for built-in skills or "custom" for custom skills + /// + [JsonPropertyName("type")] + public string Type { get; set; } + + /// + /// Unique identifier for the skill + /// For anthropic type: "pptx", "xlsx", "docx", "pdf" + /// For custom type: a custom string identifier + /// + [JsonPropertyName("skill_id")] + public string SkillId { get; set; } + + /// + /// Optional version to pin to a specific version (e.g., "latest") + /// + [JsonPropertyName("version")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Version { get; set; } + } + + /// + /// Container information returned in the response + /// + public class ContainerResponse + { + /// + /// The container ID that can be reused in subsequent requests + /// + [JsonPropertyName("id")] + public string Id { get; set; } + } +} diff --git a/Anthropic.SDK/Messaging/Content.cs b/Anthropic.SDK/Messaging/Content.cs index b90021e..96fda0c 100644 --- a/Anthropic.SDK/Messaging/Content.cs +++ b/Anthropic.SDK/Messaging/Content.cs @@ -50,8 +50,41 @@ public class ServerToolUseContent : ContentBase public class ServerToolInput { + /// + /// Query for web_search tool + /// [JsonPropertyName("query")] public string Query { get; set; } + + /// + /// Command for text_editor_code_execution or bash_code_execution tools + /// + [JsonPropertyName("command")] + public string Command { get; set; } + + /// + /// File path for text_editor_code_execution tool + /// + [JsonPropertyName("path")] + public string Path { get; set; } + + /// + /// Old string to replace (for str_replace command) + /// + [JsonPropertyName("old_str")] + public string OldStr { get; set; } + + /// + /// New string to replace with (for str_replace command) + /// + [JsonPropertyName("new_str")] + public string NewStr { get; set; } + + /// + /// File text content (for create command) + /// + [JsonPropertyName("file_text")] + public string FileText { get; set; } } @@ -493,4 +526,328 @@ public class WebSearchResultContent : ContentBase [JsonPropertyName("page_age")] public string PageAge { get; set; } } + + /// + /// Bash Code Execution Tool Result Content + /// + public class BashCodeExecutionToolResultContent : ContentBase + { + /// + /// Type of Content (bash_code_execution_tool_result, pre-set) + /// + [JsonPropertyName("type")] + public override ContentType Type => ContentType.bash_code_execution_tool_result; + + /// + /// Tool Use Id + /// + [JsonPropertyName("tool_use_id")] + public string ToolUseId { get; set; } + + /// + /// Content - can be either BashCodeExecutionResultContent or BashCodeExecutionToolResultErrorContent + /// + [JsonPropertyName("content")] + public ContentBase Content { get; set; } + } + + /// + /// Bash Code Execution Result Content + /// + public class BashCodeExecutionResultContent : ContentBase + { + /// + /// Type of Content (bash_code_execution_result, pre-set) + /// + [JsonPropertyName("type")] + public override ContentType Type => ContentType.bash_code_execution_result; + + /// + /// Standard output from the bash execution + /// + [JsonPropertyName("stdout")] + public string Stdout { get; set; } + + /// + /// Standard error from the bash execution + /// + [JsonPropertyName("stderr")] + public string Stderr { get; set; } + + /// + /// Return code from the bash execution + /// + [JsonPropertyName("return_code")] + public int ReturnCode { get; set; } + + /// + /// Array of output content blocks (files) + /// + [JsonPropertyName("content")] + public List Content { get; set; } + } + + /// + /// Bash Code Execution Output Content (represents a file) + /// + public class BashCodeExecutionOutputContent : ContentBase + { + /// + /// Type of Content (bash_code_execution_output, pre-set) + /// + [JsonPropertyName("type")] + public override ContentType Type => ContentType.bash_code_execution_output; + + /// + /// File ID that can be used to download the file + /// + [JsonPropertyName("file_id")] + public string FileId { get; set; } + } + + /// + /// Bash Code Execution Tool Result Error Content + /// + public class BashCodeExecutionToolResultErrorContent : ContentBase + { + /// + /// Type of Content (bash_code_execution_tool_result_error, pre-set) + /// + [JsonPropertyName("type")] + public override ContentType Type => ContentType.bash_code_execution_tool_result_error; + + /// + /// Error code describing the failure + /// + [JsonPropertyName("error_code")] + public string ErrorCode { get; set; } + } + + /// + /// Text Editor Code Execution Tool Result Content + /// + public class TextEditorCodeExecutionToolResultContent : ContentBase + { + /// + /// Type of Content (text_editor_code_execution_tool_result, pre-set) + /// + [JsonPropertyName("type")] + public override ContentType Type => ContentType.text_editor_code_execution_tool_result; + + /// + /// Tool Use Id + /// + [JsonPropertyName("tool_use_id")] + public string ToolUseId { get; set; } + + /// + /// Content - can be either TextEditorCodeExecutionResultContent or TextEditorCodeExecutionToolResultErrorContent + /// + [JsonPropertyName("content")] + public ContentBase Content { get; set; } + } + + /// + /// Text Editor Code Execution Result Content + /// + public class TextEditorCodeExecutionResultContent : ContentBase + { + /// + /// Type of Content (text_editor_code_execution_result, pre-set) + /// + [JsonPropertyName("type")] + public override ContentType Type => ContentType.text_editor_code_execution_result; + + /// + /// Whether file already existed (for create operations) + /// + [JsonPropertyName("is_file_update")] + public bool? IsFileUpdate { get; set; } + + /// + /// File type (for view operations) + /// + [JsonPropertyName("file_type")] + public string FileType { get; set; } + + /// + /// Content of the file (for view operations) + /// + [JsonPropertyName("content")] + public string Content { get; set; } + + /// + /// Number of lines (for view operations) + /// + [JsonPropertyName("numLines")] + public int? NumLines { get; set; } + + /// + /// Start line number (for view operations) + /// + [JsonPropertyName("startLine")] + public int? StartLine { get; set; } + + /// + /// Total lines in file (for view operations) + /// + [JsonPropertyName("totalLines")] + public int? TotalLines { get; set; } + + /// + /// Old start line (for edit operations) + /// + [JsonPropertyName("oldStart")] + public int? OldStart { get; set; } + + /// + /// Old number of lines (for edit operations) + /// + [JsonPropertyName("oldLines")] + public int? OldLines { get; set; } + + /// + /// New start line (for edit operations) + /// + [JsonPropertyName("newStart")] + public int? NewStart { get; set; } + + /// + /// New number of lines (for edit operations) + /// + [JsonPropertyName("newLines")] + public int? NewLines { get; set; } + + /// + /// Diff lines (for edit operations) + /// + [JsonPropertyName("lines")] + public List Lines { get; set; } + } + + /// + /// Text Editor Code Execution Tool Result Error Content + /// + public class TextEditorCodeExecutionToolResultErrorContent : ContentBase + { + /// + /// Type of Content (text_editor_code_execution_tool_result_error, pre-set) + /// + [JsonPropertyName("type")] + public override ContentType Type => ContentType.text_editor_code_execution_tool_result_error; + + /// + /// Error code describing the failure + /// + [JsonPropertyName("error_code")] + public string ErrorCode { get; set; } + + /// + /// Error message providing additional details (nullable) + /// + [JsonPropertyName("error_message")] + public string ErrorMessage { get; set; } + } + + /// + /// Text Editor Code Execution View Result Content + /// + public class TextEditorCodeExecutionViewResultContent : ContentBase + { + /// + /// Type of Content (text_editor_code_execution_view_result, pre-set) + /// + [JsonPropertyName("type")] + public override ContentType Type => ContentType.text_editor_code_execution_view_result; + + /// + /// Content of the file + /// + [JsonPropertyName("content")] + public string Content { get; set; } + + /// + /// File type ('text', 'image', or 'pdf') + /// + [JsonPropertyName("file_type")] + public string FileType { get; set; } + + /// + /// Number of lines displayed (nullable) + /// + [JsonPropertyName("num_lines")] + public int? NumLines { get; set; } + + /// + /// Starting line number (nullable) + /// + [JsonPropertyName("start_line")] + public int? StartLine { get; set; } + + /// + /// Total lines in the file (nullable) + /// + [JsonPropertyName("total_lines")] + public int? TotalLines { get; set; } + } + + /// + /// Text Editor Code Execution Create Result Content + /// + public class TextEditorCodeExecutionCreateResultContent : ContentBase + { + /// + /// Type of Content (text_editor_code_execution_create_result, pre-set) + /// + [JsonPropertyName("type")] + public override ContentType Type => ContentType.text_editor_code_execution_create_result; + + /// + /// Whether file already existed (true if update, false if new file) + /// + [JsonPropertyName("is_file_update")] + public bool IsFileUpdate { get; set; } + } + + /// + /// Text Editor Code Execution String Replace Result Content + /// + public class TextEditorCodeExecutionStrReplaceResultContent : ContentBase + { + /// + /// Type of Content (text_editor_code_execution_str_replace_result, pre-set) + /// + [JsonPropertyName("type")] + public override ContentType Type => ContentType.text_editor_code_execution_str_replace_result; + + /// + /// Diff lines showing the changes (nullable) + /// + [JsonPropertyName("lines")] + public List Lines { get; set; } + + /// + /// New number of lines (nullable) + /// + [JsonPropertyName("new_lines")] + public int? NewLines { get; set; } + + /// + /// New start line (nullable) + /// + [JsonPropertyName("new_start")] + public int? NewStart { get; set; } + + /// + /// Old number of lines (nullable) + /// + [JsonPropertyName("old_lines")] + public int? OldLines { get; set; } + + /// + /// Old start line (nullable) + /// + [JsonPropertyName("old_start")] + public int? OldStart { get; set; } + } } diff --git a/Anthropic.SDK/Messaging/ContentType.cs b/Anthropic.SDK/Messaging/ContentType.cs index 44cf3fe..56006c0 100644 --- a/Anthropic.SDK/Messaging/ContentType.cs +++ b/Anthropic.SDK/Messaging/ContentType.cs @@ -37,6 +37,26 @@ public enum ContentType mcp_tool_use, - mcp_tool_result + mcp_tool_result, + + bash_code_execution_tool_result, + + bash_code_execution_result, + + bash_code_execution_output, + + bash_code_execution_tool_result_error, + + text_editor_code_execution_tool_result, + + text_editor_code_execution_result, + + text_editor_code_execution_tool_result_error, + + text_editor_code_execution_view_result, + + text_editor_code_execution_create_result, + + text_editor_code_execution_str_replace_result } } diff --git a/Anthropic.SDK/Messaging/MessageParameters.cs b/Anthropic.SDK/Messaging/MessageParameters.cs index 2acc14b..e0a9dd3 100644 --- a/Anthropic.SDK/Messaging/MessageParameters.cs +++ b/Anthropic.SDK/Messaging/MessageParameters.cs @@ -46,6 +46,9 @@ public class MessageParameters : MessageCountTokenParameters [JsonPropertyName("mcp_servers")] public List MCPServers { get; set; } + [JsonPropertyName("container")] + public Container Container { get; set; } + /// /// Prompt Cache Type Definitions. Designed to be used as a bitwise assignment if you want to cache multiple types and are caching enough context. /// diff --git a/Anthropic.SDK/Messaging/MessageResponse.cs b/Anthropic.SDK/Messaging/MessageResponse.cs index d616b4c..bf01d9b 100644 --- a/Anthropic.SDK/Messaging/MessageResponse.cs +++ b/Anthropic.SDK/Messaging/MessageResponse.cs @@ -53,6 +53,9 @@ public class MessageResponse [JsonIgnore] public RateLimits RateLimits { get; set; } + + [JsonPropertyName("container")] + public ContainerResponse Container { get; set; } } public class StreamMessage diff --git a/Anthropic.SDK/Skills/SkillDeleteResponse.cs b/Anthropic.SDK/Skills/SkillDeleteResponse.cs new file mode 100644 index 0000000..336f8e8 --- /dev/null +++ b/Anthropic.SDK/Skills/SkillDeleteResponse.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Anthropic.SDK.Skills +{ + /// + /// Response confirming skill deletion. + /// + public class SkillDeleteResponse + { + /// + /// The ID of the deleted skill. + /// + [JsonPropertyName("id")] + public string Id { get; set; } + + /// + /// Deleted object type. For Skills, this is always "skill_deleted". + /// + [JsonPropertyName("type")] + public string Type { get; set; } = "skill_deleted"; + } +} diff --git a/Anthropic.SDK/Skills/SkillListResponse.cs b/Anthropic.SDK/Skills/SkillListResponse.cs new file mode 100644 index 0000000..3641b20 --- /dev/null +++ b/Anthropic.SDK/Skills/SkillListResponse.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Anthropic.SDK.Skills +{ + /// + /// Response containing a paginated list of skills. + /// + public class SkillListResponse + { + /// + /// List of skill objects. + /// + [JsonPropertyName("data")] + public List Data { get; set; } + + /// + /// Whether there are more results available. + /// If true, there are additional results that can be fetched using the next_page token. + /// + [JsonPropertyName("has_more")] + public bool HasMore { get; set; } + + /// + /// Token for fetching the next page of results. + /// If null, there are no more results available. Pass this value to the page parameter in the next request to get the next page. + /// + [JsonPropertyName("next_page")] + public string NextPage { get; set; } + } +} diff --git a/Anthropic.SDK/Skills/SkillResponse.cs b/Anthropic.SDK/Skills/SkillResponse.cs new file mode 100644 index 0000000..85b7004 --- /dev/null +++ b/Anthropic.SDK/Skills/SkillResponse.cs @@ -0,0 +1,58 @@ +using System.Text.Json.Serialization; + +namespace Anthropic.SDK.Skills +{ + /// + /// Response from creating or retrieving a skill. + /// + public class SkillResponse + { + /// + /// Unique identifier for the skill. + /// The format and length of IDs may change over time. + /// + [JsonPropertyName("id")] + public string Id { get; set; } + + /// + /// Object type. For Skills, this is always "skill". + /// + [JsonPropertyName("type")] + public string Type { get; set; } = "skill"; + + /// + /// Display title for the skill. + /// This is a human-readable label that is not included in the prompt sent to the model. + /// + [JsonPropertyName("display_title")] + public string DisplayTitle { get; set; } + + /// + /// Source of the skill. + /// This may be one of the following values: + /// * "custom": the skill was created by a user + /// * "anthropic": the skill was created by Anthropic + /// + [JsonPropertyName("source")] + public string Source { get; set; } + + /// + /// The latest version identifier for the skill. + /// This represents the most recent version of the skill that has been created. + /// + [JsonPropertyName("latest_version")] + public string LatestVersion { get; set; } + + /// + /// ISO 8601 timestamp of when the skill was created. + /// + [JsonPropertyName("created_at")] + public string CreatedAt { get; set; } + + /// + /// ISO 8601 timestamp of when the skill was last updated. + /// + [JsonPropertyName("updated_at")] + public string UpdatedAt { get; set; } + } +} diff --git a/Anthropic.SDK/Skills/SkillVersionDeleteResponse.cs b/Anthropic.SDK/Skills/SkillVersionDeleteResponse.cs new file mode 100644 index 0000000..7e6360d --- /dev/null +++ b/Anthropic.SDK/Skills/SkillVersionDeleteResponse.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; + +namespace Anthropic.SDK.Skills +{ + /// + /// Response confirming skill version deletion. + /// + public class SkillVersionDeleteResponse + { + /// + /// Version identifier for the skill. + /// Each version is identified by a Unix epoch timestamp (e.g., "1759178010641129"). + /// + [JsonPropertyName("id")] + public string Id { get; set; } + + /// + /// Deleted object type. For Skill Versions, this is always "skill_version_deleted". + /// + [JsonPropertyName("type")] + public string Type { get; set; } = "skill_version_deleted"; + } +} diff --git a/Anthropic.SDK/Skills/SkillVersionListResponse.cs b/Anthropic.SDK/Skills/SkillVersionListResponse.cs new file mode 100644 index 0000000..9db4089 --- /dev/null +++ b/Anthropic.SDK/Skills/SkillVersionListResponse.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Anthropic.SDK.Skills +{ + /// + /// Response containing a paginated list of skill versions. + /// + public class SkillVersionListResponse + { + /// + /// List of skill versions. + /// + [JsonPropertyName("data")] + public List Data { get; set; } + + /// + /// Indicates if there are more results in the requested page direction. + /// + [JsonPropertyName("has_more")] + public bool HasMore { get; set; } + + /// + /// Token to provide as 'page' in the subsequent request to retrieve the next page of data. + /// + [JsonPropertyName("next_page")] + public string NextPage { get; set; } + } +} diff --git a/Anthropic.SDK/Skills/SkillVersionResponse.cs b/Anthropic.SDK/Skills/SkillVersionResponse.cs new file mode 100644 index 0000000..7122430 --- /dev/null +++ b/Anthropic.SDK/Skills/SkillVersionResponse.cs @@ -0,0 +1,63 @@ +using System.Text.Json.Serialization; + +namespace Anthropic.SDK.Skills +{ + /// + /// Response from creating or retrieving a skill version. + /// + public class SkillVersionResponse + { + /// + /// Unique identifier for the skill version. + /// The format and length of IDs may change over time. + /// + [JsonPropertyName("id")] + public string Id { get; set; } + + /// + /// Object type. For Skill Versions, this is always "skill_version". + /// + [JsonPropertyName("type")] + public string Type { get; set; } = "skill_version"; + + /// + /// Identifier for the skill that this version belongs to. + /// + [JsonPropertyName("skill_id")] + public string SkillId { get; set; } + + /// + /// Version identifier for the skill. + /// Each version is identified by a Unix epoch timestamp (e.g., "1759178010641129"). + /// + [JsonPropertyName("version")] + public string Version { get; set; } + + /// + /// Human-readable name of the skill version. + /// This is extracted from the SKILL.md file in the skill upload. + /// + [JsonPropertyName("name")] + public string Name { get; set; } + + /// + /// Description of the skill version. + /// This is extracted from the SKILL.md file in the skill upload. + /// + [JsonPropertyName("description")] + public string Description { get; set; } + + /// + /// Directory name of the skill version. + /// This is the top-level directory name that was extracted from the uploaded files. + /// + [JsonPropertyName("directory")] + public string Directory { get; set; } + + /// + /// ISO 8601 timestamp of when the skill version was created. + /// + [JsonPropertyName("created_at")] + public string CreatedAt { get; set; } + } +} diff --git a/Anthropic.SDK/Skills/SkillsEndpoint.cs b/Anthropic.SDK/Skills/SkillsEndpoint.cs new file mode 100644 index 0000000..9ba738f --- /dev/null +++ b/Anthropic.SDK/Skills/SkillsEndpoint.cs @@ -0,0 +1,555 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; + +namespace Anthropic.SDK.Skills +{ + /// + /// Skills endpoint for managing custom skills. + /// The Skills API allows you to create, list, retrieve, and delete custom skills that extend Claude's capabilities. + /// + public class SkillsEndpoint : EndpointBase + { + /// + /// Constructor of the api endpoint. Rather than instantiating this yourself, access it through an instance of as . + /// + /// + internal SkillsEndpoint(AnthropicClient client) : base(client) { } + + protected override string Endpoint => "skills"; + + /// + /// Creates a new custom skill by uploading skill files. + /// All files must be in the same top-level directory and must include a SKILL.md file at the root of that directory. + /// + /// Display title for the skill. This is a human-readable label that is not included in the prompt sent to the model. + /// Path to the directory containing skill files. Must include a SKILL.md file. + /// Optional cancellation token. + /// The created skill response. + /// Thrown when parameters are invalid or SKILL.md is not found. + public async Task CreateSkillAsync( + string displayTitle, + string skillDirectoryPath, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(skillDirectoryPath)) + { + throw new ArgumentException("Skill directory path cannot be null or empty.", nameof(skillDirectoryPath)); + } + + if (!Directory.Exists(skillDirectoryPath)) + { + throw new ArgumentException($"Skill directory not found: {skillDirectoryPath}", nameof(skillDirectoryPath)); + } + + // Verify SKILL.md exists + var skillMdPath = Path.Combine(skillDirectoryPath, "SKILL.md"); + if (!File.Exists(skillMdPath)) + { + throw new ArgumentException("SKILL.md file must exist in the skill directory.", nameof(skillDirectoryPath)); + } + + // Get all files in the directory + var files = Directory.GetFiles(skillDirectoryPath, "*", SearchOption.AllDirectories); + + using var content = new MultipartFormDataContent(); + + // Add display_title if provided + if (!string.IsNullOrWhiteSpace(displayTitle)) + { + content.Add(new StringContent(displayTitle), "display_title"); + } + + // Add all files + foreach (var filePath in files) + { + // Get relative path (compatible with .NET Standard 2.0) + var relativePath = GetRelativePath(skillDirectoryPath, filePath); + // Normalize path separators to forward slashes for consistency + relativePath = relativePath.Replace(Path.DirectorySeparatorChar, '/'); + +#if NET6_0_OR_GREATER + var fileBytes = await File.ReadAllBytesAsync(filePath, cancellationToken).ConfigureAwait(false); +#else + var fileBytes = File.ReadAllBytes(filePath); +#endif + var fileContent = new ByteArrayContent(fileBytes); + var mimeType = GetMimeType(filePath); + fileContent.Headers.ContentType = new MediaTypeHeaderValue(mimeType); + + // Use the relative path as the filename in the multipart form + content.Add(fileContent, "files[]", relativePath); + } + + return await HttpRequestSimple(Url, HttpMethod.Post, content, cancellationToken).ConfigureAwait(false); + } + + /// + /// Creates a new custom skill by uploading a zip file. + /// The zip file must contain a SKILL.md file at its root. + /// + /// Display title for the skill. This is a human-readable label that is not included in the prompt sent to the model. + /// Path to the zip file containing skill files. Must include a SKILL.md file. + /// Optional cancellation token. + /// The created skill response. + /// Thrown when parameters are invalid. + public async Task CreateSkillFromZipAsync( + string displayTitle, + string zipFilePath, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(zipFilePath)) + { + throw new ArgumentException("Zip file path cannot be null or empty.", nameof(zipFilePath)); + } + + if (!File.Exists(zipFilePath)) + { + throw new ArgumentException($"Zip file not found: {zipFilePath}", nameof(zipFilePath)); + } + +#if NET6_0_OR_GREATER + var fileBytes = await File.ReadAllBytesAsync(zipFilePath, cancellationToken).ConfigureAwait(false); +#else + var fileBytes = File.ReadAllBytes(zipFilePath); +#endif + + using var content = new MultipartFormDataContent(); + + // Add display_title if provided + if (!string.IsNullOrWhiteSpace(displayTitle)) + { + content.Add(new StringContent(displayTitle), "display_title"); + } + + // Add zip file + var fileContent = new ByteArrayContent(fileBytes); + fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/zip"); + content.Add(fileContent, "files[]", Path.GetFileName(zipFilePath)); + + return await HttpRequestSimple(Url, HttpMethod.Post, content, cancellationToken).ConfigureAwait(false); + } + + /// + /// Creates a new custom skill by uploading file streams. + /// Files must include a SKILL.md file. + /// + /// Display title for the skill. This is a human-readable label that is not included in the prompt sent to the model. + /// List of tuples containing (filename, stream, mimeType) for each file to upload. + /// Optional cancellation token. + /// The created skill response. + /// Thrown when parameters are invalid. + /// Thrown when files is null or empty. + public async Task CreateSkillFromStreamsAsync( + string displayTitle, + List<(string filename, Stream stream, string mimeType)> files, + CancellationToken cancellationToken = default) + { + if (files == null || !files.Any()) + { + throw new ArgumentNullException(nameof(files), "Files list cannot be null or empty."); + } + + // Verify SKILL.md is present + if (!files.Any(f => f.filename.EndsWith("SKILL.md", StringComparison.OrdinalIgnoreCase))) + { + throw new ArgumentException("Files must include a SKILL.md file.", nameof(files)); + } + + using var content = new MultipartFormDataContent(); + + // Add display_title if provided + if (!string.IsNullOrWhiteSpace(displayTitle)) + { + content.Add(new StringContent(displayTitle), "display_title"); + } + + // Add all files + foreach (var (filename, stream, mimeType) in files) + { + var streamContent = new StreamContent(stream); + streamContent.Headers.ContentType = new MediaTypeHeaderValue(mimeType); + content.Add(streamContent, "files[]", filename); + } + + return await HttpRequestSimple(Url, HttpMethod.Post, content, cancellationToken).ConfigureAwait(false); + } + + /// + /// Creates a new version of an existing skill by uploading skill files. + /// All files must be in the same top-level directory and must include a SKILL.md file at the root of that directory. + /// + /// The ID of the skill to create a version for. + /// Path to the directory containing skill files. Must include a SKILL.md file. + /// Optional cancellation token. + /// The created skill version response. + /// Thrown when parameters are invalid or SKILL.md is not found. + public async Task CreateSkillVersionAsync( + string skillId, + string skillDirectoryPath, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(skillId)) + { + throw new ArgumentNullException(nameof(skillId), "Skill ID cannot be null or empty."); + } + + if (string.IsNullOrWhiteSpace(skillDirectoryPath)) + { + throw new ArgumentException("Skill directory path cannot be null or empty.", nameof(skillDirectoryPath)); + } + + if (!Directory.Exists(skillDirectoryPath)) + { + throw new ArgumentException($"Skill directory not found: {skillDirectoryPath}", nameof(skillDirectoryPath)); + } + + // Verify SKILL.md exists + var skillMdPath = Path.Combine(skillDirectoryPath, "SKILL.md"); + if (!File.Exists(skillMdPath)) + { + throw new ArgumentException("SKILL.md file must exist in the skill directory.", nameof(skillDirectoryPath)); + } + + // Get all files in the directory + var files = Directory.GetFiles(skillDirectoryPath, "*", SearchOption.AllDirectories); + + using var content = new MultipartFormDataContent(); + + // Add all files + foreach (var filePath in files) + { + // Get relative path (compatible with .NET Standard 2.0) + var relativePath = GetRelativePath(skillDirectoryPath, filePath); + // Normalize path separators to forward slashes for consistency + relativePath = relativePath.Replace(Path.DirectorySeparatorChar, '/'); + +#if NET6_0_OR_GREATER + var fileBytes = await File.ReadAllBytesAsync(filePath, cancellationToken).ConfigureAwait(false); +#else + var fileBytes = File.ReadAllBytes(filePath); +#endif + var fileContent = new ByteArrayContent(fileBytes); + var mimeType = GetMimeType(filePath); + fileContent.Headers.ContentType = new MediaTypeHeaderValue(mimeType); + + // Use the relative path as the filename in the multipart form + content.Add(fileContent, "files[]", relativePath); + } + + return await HttpRequestSimple($"{Endpoint}/{skillId}/versions", HttpMethod.Post, content, cancellationToken).ConfigureAwait(false); + } + + /// + /// Creates a new version of an existing skill by uploading a zip file. + /// The zip file must contain a SKILL.md file at its root. + /// + /// The ID of the skill to create a version for. + /// Path to the zip file containing skill files. Must include a SKILL.md file. + /// Optional cancellation token. + /// The created skill version response. + /// Thrown when parameters are invalid. + public async Task CreateSkillVersionFromZipAsync( + string skillId, + string zipFilePath, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(skillId)) + { + throw new ArgumentNullException(nameof(skillId), "Skill ID cannot be null or empty."); + } + + if (string.IsNullOrWhiteSpace(zipFilePath)) + { + throw new ArgumentException("Zip file path cannot be null or empty.", nameof(zipFilePath)); + } + + if (!File.Exists(zipFilePath)) + { + throw new ArgumentException($"Zip file not found: {zipFilePath}", nameof(zipFilePath)); + } + +#if NET6_0_OR_GREATER + var fileBytes = await File.ReadAllBytesAsync(zipFilePath, cancellationToken).ConfigureAwait(false); +#else + var fileBytes = File.ReadAllBytes(zipFilePath); +#endif + + using var content = new MultipartFormDataContent(); + + // Add zip file + var fileContent = new ByteArrayContent(fileBytes); + fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/zip"); + content.Add(fileContent, "files[]", Path.GetFileName(zipFilePath)); + + return await HttpRequestSimple($"{Endpoint}/{skillId}/versions", HttpMethod.Post, content, cancellationToken).ConfigureAwait(false); + } + + /// + /// Creates a new version of an existing skill by uploading file streams. + /// Files must include a SKILL.md file. + /// + /// The ID of the skill to create a version for. + /// List of tuples containing (filename, stream, mimeType) for each file to upload. + /// Optional cancellation token. + /// The created skill version response. + /// Thrown when parameters are invalid. + /// Thrown when files is null or empty. + public async Task CreateSkillVersionFromStreamsAsync( + string skillId, + List<(string filename, Stream stream, string mimeType)> files, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(skillId)) + { + throw new ArgumentNullException(nameof(skillId), "Skill ID cannot be null or empty."); + } + + if (files == null || !files.Any()) + { + throw new ArgumentNullException(nameof(files), "Files list cannot be null or empty."); + } + + // Verify SKILL.md is present + if (!files.Any(f => f.filename.EndsWith("SKILL.md", StringComparison.OrdinalIgnoreCase))) + { + throw new ArgumentException("Files must include a SKILL.md file.", nameof(files)); + } + + using var content = new MultipartFormDataContent(); + + // Add all files + foreach (var (filename, stream, mimeType) in files) + { + var streamContent = new StreamContent(stream); + streamContent.Headers.ContentType = new MediaTypeHeaderValue(mimeType); + content.Add(streamContent, "files[]", filename); + } + + return await HttpRequestSimple($"{Endpoint}/{skillId}/versions", HttpMethod.Post, content, cancellationToken).ConfigureAwait(false); + } + + /// + /// Lists all versions of a specific skill. Supports pagination using page tokens. + /// + /// The ID of the skill to list versions for. + /// Pagination token for fetching a specific page of results. Optionally set to the next_page token from the previous response. + /// Number of items to return per page. Defaults to 20. Ranges from 1 to 1000. + /// Optional cancellation token. + /// A paginated list of skill version objects. + /// Thrown when skillId is null or empty. + /// Thrown when limit is outside the valid range. + public async Task ListSkillVersionsAsync( + string skillId, + string page = null, + int limit = 20, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(skillId)) + { + throw new ArgumentNullException(nameof(skillId), "Skill ID cannot be null or empty."); + } + + if (limit < 1 || limit > 1000) + { + throw new ArgumentOutOfRangeException(nameof(limit), "Limit must be between 1 and 1000."); + } + + var queryParams = new List { $"limit={limit}" }; + if (!string.IsNullOrEmpty(page)) + queryParams.Add($"page={page}"); + + var queryString = "?" + string.Join("&", queryParams); + return await HttpRequestSimple($"{Endpoint}/{skillId}/versions{queryString}", HttpMethod.Get, null, cancellationToken); + } + + /// + /// Retrieves details for a specific version of a skill. + /// + /// The ID of the skill. + /// Version identifier for the skill. Each version is identified by a Unix epoch timestamp (e.g., "1759178010641129"). + /// Optional cancellation token. + /// The skill version response. + /// Thrown when skillId or version is null or empty. + public async Task GetSkillVersionAsync( + string skillId, + string version, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(skillId)) + { + throw new ArgumentNullException(nameof(skillId), "Skill ID cannot be null or empty."); + } + + if (string.IsNullOrWhiteSpace(version)) + { + throw new ArgumentNullException(nameof(version), "Version cannot be null or empty."); + } + + return await HttpRequestSimple($"{Endpoint}/{skillId}/versions/{version}", HttpMethod.Get, null, cancellationToken); + } + + /// + /// Deletes a specific version of a skill, making it inaccessible through the API. + /// + /// The ID of the skill. + /// Version identifier for the skill. Each version is identified by a Unix epoch timestamp (e.g., "1759178010641129"). + /// Optional cancellation token. + /// A response confirming the skill version deletion. + /// Thrown when skillId or version is null or empty. + public async Task DeleteSkillVersionAsync( + string skillId, + string version, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(skillId)) + { + throw new ArgumentNullException(nameof(skillId), "Skill ID cannot be null or empty."); + } + + if (string.IsNullOrWhiteSpace(version)) + { + throw new ArgumentNullException(nameof(version), "Version cannot be null or empty."); + } + + return await HttpRequestSimple($"{Endpoint}/{skillId}/versions/{version}", HttpMethod.Delete, null, cancellationToken); + } + + /// + /// Lists all skills in the organization. Supports pagination using page tokens. + /// + /// Pagination token for fetching a specific page of results. Pass the value from a previous response's next_page field to get the next page of results. + /// Number of results to return per page. Maximum value is 100. Defaults to 20. + /// Filter skills by source. If provided, only skills from the specified source will be returned: "custom" for user-created skills, "anthropic" for Anthropic-created skills. + /// Optional cancellation token. + /// A paginated list of skill objects. + public async Task ListSkillsAsync( + string page = null, + int limit = 20, + string source = null, + CancellationToken cancellationToken = default) + { + if (limit < 1 || limit > 100) + { + throw new ArgumentOutOfRangeException(nameof(limit), "Limit must be between 1 and 100."); + } + + var queryParams = new List { $"limit={limit}" }; + if (!string.IsNullOrEmpty(page)) + queryParams.Add($"page={page}"); + if (!string.IsNullOrEmpty(source)) + queryParams.Add($"source={source}"); + + var queryString = "?" + string.Join("&", queryParams); + return await HttpRequestSimple($"{Endpoint}{queryString}", HttpMethod.Get, null, cancellationToken); + } + + /// + /// Retrieves details for a specific skill by its ID. + /// + /// The ID of the skill to retrieve. + /// Optional cancellation token. + /// The skill response. + /// Thrown when skillId is null or empty. + public async Task GetSkillAsync( + string skillId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(skillId)) + { + throw new ArgumentNullException(nameof(skillId), "Skill ID cannot be null or empty."); + } + + return await HttpRequestSimple($"{Endpoint}/{skillId}", HttpMethod.Get, null, cancellationToken); + } + + /// + /// Deletes a skill, making it inaccessible through the API. + /// + /// The ID of the skill to delete. + /// Optional cancellation token. + /// A response confirming the skill deletion. + /// Thrown when skillId is null or empty. + public async Task DeleteSkillAsync( + string skillId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(skillId)) + { + throw new ArgumentNullException(nameof(skillId), "Skill ID cannot be null or empty."); + } + + return await HttpRequestSimple($"{Endpoint}/{skillId}", HttpMethod.Delete, null, cancellationToken); + } + + /// + /// Gets the MIME type based on file extension. + /// + private static string GetMimeType(string filePath) + { + var extension = Path.GetExtension(filePath).ToLowerInvariant(); + return extension switch + { + ".md" => "text/markdown", + ".py" => "text/x-python", + ".js" => "text/javascript", + ".ts" => "text/typescript", + ".json" => "application/json", + ".txt" => "text/plain", + ".html" => "text/html", + ".htm" => "text/html", + ".xml" => "application/xml", + ".csv" => "text/csv", + ".yaml" => "text/yaml", + ".yml" => "text/yaml", + ".sh" => "text/x-shellscript", + ".bash" => "text/x-shellscript", + ".java" => "text/x-java", + ".c" => "text/x-c", + ".cpp" => "text/x-c++", + ".h" => "text/x-c", + ".hpp" => "text/x-c++", + ".cs" => "text/x-csharp", + ".rb" => "text/x-ruby", + ".go" => "text/x-go", + ".rs" => "text/x-rust", + ".php" => "text/x-php", + ".sql" => "text/x-sql", + _ => "application/octet-stream" + }; + } + + /// + /// Gets the relative path from base path to target path. + /// Compatible with .NET Standard 2.0 which doesn't have Path.GetRelativePath. + /// + private static string GetRelativePath(string basePath, string targetPath) + { + // Ensure paths are absolute + basePath = Path.GetFullPath(basePath); + targetPath = Path.GetFullPath(targetPath); + + // Add trailing separator to base path if not present + if (!basePath.EndsWith(Path.DirectorySeparatorChar.ToString()) && + !basePath.EndsWith(Path.AltDirectorySeparatorChar.ToString())) + { + basePath += Path.DirectorySeparatorChar; + } + + var baseUri = new Uri(basePath); + var targetUri = new Uri(targetPath); + + var relativeUri = baseUri.MakeRelativeUri(targetUri); + var relativePath = Uri.UnescapeDataString(relativeUri.ToString()); + + // Convert forward slashes to platform-specific separators + return relativePath.Replace('/', Path.DirectorySeparatorChar); + } + } +} diff --git a/README.md b/README.md index 06d4806..22fc8e7 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Anthropic.SDK is an unofficial C# client designed for interacting with the Claud - [List Models](#list-models) - [Batching](#batching) - [Tools](#tools) + - [Skills](#skills) - [Computer Use](#computer-use) - [Vertex AI Support](#vertex-ai-support) - [Authentication](#authentication) @@ -1168,6 +1169,189 @@ Output From Json Mode } ``` +### Skills + +The `AnthropicClient` supports the Skills API, which allows Claude to use tools like PowerPoint, Excel, Word, and PDF generation through code execution in a containerized environment. Skills can be used with the `container` parameter in message requests. + +#### Basic Skills Usage + +```csharp +var client = new AnthropicClient(); + +// Create a container with skills +var container = new Container +{ + Skills = new List + { + new Skill + { + Type = "anthropic", + SkillId = "pptx", // Built-in PowerPoint skill + Version = "latest" + } + } +}; + +var parameters = new MessageParameters +{ + Model = AnthropicModels.Claude4Sonnet, + MaxTokens = 4096, + Messages = new List + { + new Message(RoleType.User, "Create a presentation about renewable energy") + }, + Container = container, + Tools = new List + { + new Function("code_execution", "code_execution_20250825", + new Dictionary { { "name", "code_execution" } }) + } +}; + +var response = await client.Messages.GetClaudeMessageAsync(parameters); + +// The response will include the container ID for reuse +var containerId = response.Container?.Id; +``` + +#### Reusing Containers + +Containers can be reused across multiple requests to maintain state: + +```csharp +// First request creates the container +var response1 = await client.Messages.GetClaudeMessageAsync(parameters); + +// Subsequent requests can reuse the same container +var containerWithId = new Container +{ + Id = response1.Container.Id, // Reuse the container + Skills = new List + { + new Skill { Type = "anthropic", SkillId = "xlsx", Version = "latest" } + } +}; + +var messages = new List +{ + new Message(RoleType.User, "Analyze this sales data"), + response1.Message, + new Message(RoleType.User, "What was the total revenue?") +}; + +var response2 = await client.Messages.GetClaudeMessageAsync(new MessageParameters +{ + Model = AnthropicModels.Claude4Sonnet, + MaxTokens = 4096, + Messages = messages, + Container = containerWithId +}); +``` + +#### Built-in Skills + +The following built-in Anthropic skills are available: +- **pptx**: Create and edit PowerPoint presentations +- **xlsx**: Create and analyze Excel spreadsheets +- **docx**: Create and edit Word documents +- **pdf**: Generate PDF documents + +#### Multiple Skills + +You can include up to 8 skills per request: + +```csharp +var container = new Container +{ + Skills = new List + { + new Skill { Type = "anthropic", SkillId = "pptx", Version = "latest" }, + new Skill { Type = "anthropic", SkillId = "xlsx", Version = "latest" }, + new Skill { Type = "anthropic", SkillId = "docx", Version = "latest" }, + new Skill { Type = "anthropic", SkillId = "pdf", Version = "latest" } + } +}; +``` + +#### Custom Skills + +Custom skills can also be used by specifying `Type = "custom"`: + +```csharp +var container = new Container +{ + Skills = new List + { + new Skill + { + Type = "custom", + SkillId = "my-custom-skill-id", + Version = "1.0.0" + } + } +}; +``` + +#### Working with Skill Results + +Skills execution results are returned as bash code execution content blocks: + +```csharp +foreach (var content in response.Content) +{ + if (content is BashCodeExecutionToolResultContent bashResult) + { + if (bashResult.Content is BashCodeExecutionResultContent result) + { + Console.WriteLine($"Return Code: {result.ReturnCode}"); + Console.WriteLine($"Stdout: {result.Stdout}"); + Console.WriteLine($"Stderr: {result.Stderr}"); + + // Access any file outputs + foreach (var output in result.Content) + { + Console.WriteLine($"File ID: {output.FileId}"); + // Use Files API to download the file + } + } + } +} +``` + +#### Downloading Skill Output Files + +For convenience, you can use the `DownloadFilesAsync` extension method to automatically download all file outputs from skill execution: + +```csharp +using Anthropic.SDK.Extensions; + +var response = await client.Messages.GetClaudeMessageAsync(parameters); + +// Download all file outputs to a specified directory +var downloadedFiles = await response.DownloadFilesAsync( + client, + @"C:\Output\SkillFiles" +); + +foreach (var filePath in downloadedFiles) +{ + Console.WriteLine($"Downloaded: {filePath}"); +} + +// Or just get the file IDs if you want to handle downloads manually +var fileIds = response.GetFileIds(); +foreach (var fileId in fileIds) +{ + var metadata = await client.Files.GetFileMetadataAsync(fileId); + Console.WriteLine($"File: {metadata.Filename} ({metadata.SizeBytes} bytes)"); +} +``` + +**Note:** Skills require the following beta headers: +- `code-execution-2025-08-25` - Enables code execution (required for Skills) +- `skills-2025-10-02` - Enables Skills API +- `files-api-2025-04-14` - For uploading/downloading files (when working with file outputs) + ### Computer Use The `AnthropicClient` supports calling computer use functionality, and in this repository is a demonstration application that should work reasonably well on Windows and mirrors in many ways the example application provided by Anthropic.