Files
VolcanicAgenticCore/tests/Volcanic.AgenticCore.Extensions.Tests/Anthropic/AnthropicRequestTests.cs

273 lines
13 KiB
C#

using System.Text.Json;
using System.Text.Json.Nodes;
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Extensions.Anthropic;
namespace Volcanic.AgenticCore.Extensions.Tests.Anthropic;
public class AnthropicRequestTests
{
private static readonly Model Sonnet = AnthropicModels.Sonnet45;
private static AssistantMessage Assistant(params ContentBlock[] content) => new()
{
Provider = "anthropic", Api = "anthropic-messages", Model = Sonnet.Id, Content = content, StopReason = StopReason.ToolUse,
};
[Fact]
public void Basics_model_max_tokens_stream_and_system_with_cache_control()
{
var body = AnthropicApi.BuildRequest(Sonnet, LlmContext.Simple("Be terse.", "hi"), StreamOptions.Default);
Assert.Equal("claude-sonnet-4-5", body["model"]!.GetValue<string>());
Assert.Equal(64_000, body["max_tokens"]!.GetValue<int>());
Assert.True(body["stream"]!.GetValue<bool>());
Assert.Null(body["temperature"]);
Assert.Null(body["thinking"]);
Assert.Null(body["tools"]);
var system = body["system"]!.AsArray();
var block = Assert.Single(system)!.AsObject();
Assert.Equal("text", block["type"]!.GetValue<string>());
Assert.Equal("Be terse.", block["text"]!.GetValue<string>());
Assert.Equal("ephemeral", block["cache_control"]!["type"]!.GetValue<string>());
var message = Assert.Single(body["messages"]!.AsArray())!.AsObject();
Assert.Equal("user", message["role"]!.GetValue<string>());
var text = Assert.Single(message["content"]!.AsArray())!.AsObject();
Assert.Equal("hi", text["text"]!.GetValue<string>());
Assert.Equal("ephemeral", text["cache_control"]!["type"]!.GetValue<string>());
}
[Fact]
public void Options_override_max_tokens_and_temperature_and_system_is_omitted_when_empty()
{
var options = new StreamOptions { MaxTokens = 1234, Temperature = 0.2 };
var body = AnthropicApi.BuildRequest(Sonnet, LlmContext.Simple(null, "hi"), options);
Assert.Equal(1234, body["max_tokens"]!.GetValue<int>());
Assert.Equal(0.2, body["temperature"]!.GetValue<double>());
Assert.Null(body["system"]);
}
[Fact]
public void CacheRetention_none_omits_cache_control_everywhere()
{
var options = new StreamOptions { CacheRetention = CacheRetention.None };
var body = AnthropicApi.BuildRequest(Sonnet, LlmContext.Simple("sys", "hi"), options);
Assert.DoesNotContain("cache_control", body.ToJsonString());
}
[Fact]
public void CacheRetention_long_uses_one_hour_ttl()
{
var options = new StreamOptions { CacheRetention = CacheRetention.Long };
var body = AnthropicApi.BuildRequest(Sonnet, LlmContext.Simple("sys", "hi"), options);
Assert.Equal("1h", body["system"]![0]!["cache_control"]!["ttl"]!.GetValue<string>());
}
[Fact]
public void Maps_user_assistant_and_tool_results_and_merges_consecutive_same_role_messages()
{
var args = VolcanicJson.Parse("""{"command":"ls"}""");
var messages = new List<Message>
{
UserMessage.From("look", [new ImageContent("AAAA", "image/png")]),
Assistant(
new ThinkingContent("plan", "sig-1"),
new ThinkingContent("unsigned thinking is dropped"),
new ThinkingContent("", "redacted-blob", Redacted: true),
new TextContent("running"),
new ToolCallContent("toolu_1", "bash", args),
new ToolCallContent("toolu_2", "read", VolcanicJson.EmptyObject)),
new ToolResultMessage { ToolCallId = "toolu_1", ToolName = "bash", Content = [new TextContent("file.txt")] },
new ToolResultMessage { ToolCallId = "toolu_2", ToolName = "read", Content = [new TextContent("boom")], IsError = true },
CustomMessage.FromText("note", "custom goes to user"),
UserMessage.FromText("thanks"),
};
var body = AnthropicApi.BuildRequest(Sonnet, new LlmContext(null, messages, []), StreamOptions.Default);
var wire = body["messages"]!.AsArray();
Assert.Equal(3, wire.Count);
Assert.Equal(["user", "assistant", "user"], wire.Select(m => m!["role"]!.GetValue<string>()).ToArray());
// First user message: text + image.
var first = wire[0]!["content"]!.AsArray();
Assert.Equal(2, first.Count);
Assert.Equal("text", first[0]!["type"]!.GetValue<string>());
Assert.Equal("image", first[1]!["type"]!.GetValue<string>());
Assert.Equal("base64", first[1]!["source"]!["type"]!.GetValue<string>());
Assert.Equal("image/png", first[1]!["source"]!["media_type"]!.GetValue<string>());
Assert.Equal("AAAA", first[1]!["source"]!["data"]!.GetValue<string>());
Assert.Null(first[1]!["cache_control"]);
// Assistant: signed thinking, redacted thinking, text, two tool_use blocks; unsigned thinking dropped.
var assistant = wire[1]!["content"]!.AsArray();
Assert.Equal(["thinking", "redacted_thinking", "text", "tool_use", "tool_use"], assistant.Select(b => b!["type"]!.GetValue<string>()).ToArray());
Assert.Equal("plan", assistant[0]!["thinking"]!.GetValue<string>());
Assert.Equal("sig-1", assistant[0]!["signature"]!.GetValue<string>());
Assert.Equal("redacted-blob", assistant[1]!["data"]!.GetValue<string>());
Assert.Equal("toolu_1", assistant[3]!["id"]!.GetValue<string>());
Assert.Equal("bash", assistant[3]!["name"]!.GetValue<string>());
Assert.Equal("ls", assistant[3]!["input"]!["command"]!.GetValue<string>());
Assert.Equal("{}", assistant[4]!["input"]!.ToJsonString());
// Both tool results, the custom message and the trailing user text are merged into ONE user message.
var last = wire[2]!["content"]!.AsArray();
Assert.Equal(["tool_result", "tool_result", "text", "text"], last.Select(b => b!["type"]!.GetValue<string>()).ToArray());
Assert.Equal("toolu_1", last[0]!["tool_use_id"]!.GetValue<string>());
Assert.False(last[0]!["is_error"]!.GetValue<bool>());
Assert.Equal("file.txt", last[0]!["content"]![0]!["text"]!.GetValue<string>());
Assert.Equal("toolu_2", last[1]!["tool_use_id"]!.GetValue<string>());
Assert.True(last[1]!["is_error"]!.GetValue<bool>());
Assert.Equal("custom goes to user", last[2]!["text"]!.GetValue<string>());
Assert.Equal("thanks", last[3]!["text"]!.GetValue<string>());
// cache_control only on the very last block of the last user message.
Assert.Equal("ephemeral", last[3]!["cache_control"]!["type"]!.GetValue<string>());
Assert.Null(last[2]!["cache_control"]);
Assert.Null(first[0]!["cache_control"]);
}
[Fact]
public void Tools_carry_name_description_and_input_schema()
{
var schema = VolcanicJson.Parse("""{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}""");
var context = new LlmContext(null, [UserMessage.FromText("hi")], [new ToolDescriptor("read", "Read a file", schema)]);
var body = AnthropicApi.BuildRequest(Sonnet, context, StreamOptions.Default);
var tool = Assert.Single(body["tools"]!.AsArray())!.AsObject();
Assert.Equal("read", tool["name"]!.GetValue<string>());
Assert.Equal("Read a file", tool["description"]!.GetValue<string>());
Assert.Equal("object", tool["input_schema"]!["type"]!.GetValue<string>());
Assert.Equal("string", tool["input_schema"]!["properties"]!["path"]!["type"]!.GetValue<string>());
Assert.Equal("path", tool["input_schema"]!["required"]![0]!.GetValue<string>());
}
[Fact]
public void Thinking_block_present_with_budget_for_medium_and_temperature_is_dropped()
{
var options = new StreamOptions { Reasoning = ThinkingLevel.Medium, Temperature = 0.7 };
var body = AnthropicApi.BuildRequest(Sonnet, LlmContext.Simple(null, "hi"), options);
Assert.Equal("enabled", body["thinking"]!["type"]!.GetValue<string>());
Assert.Equal(8192, body["thinking"]!["budget_tokens"]!.GetValue<int>());
Assert.Null(body["temperature"]);
Assert.Equal(64_000, body["max_tokens"]!.GetValue<int>());
}
[Theory]
[InlineData(null)]
[InlineData(ThinkingLevel.Off)]
public void Thinking_block_absent_when_reasoning_is_null_or_off(ThinkingLevel? level)
{
var body = AnthropicApi.BuildRequest(Sonnet, LlmContext.Simple(null, "hi"), new StreamOptions { Reasoning = level });
Assert.Null(body["thinking"]);
}
[Fact]
public void Thinking_absent_for_non_reasoning_model()
{
var model = Sonnet with { Reasoning = false };
var body = AnthropicApi.BuildRequest(model, LlmContext.Simple(null, "hi"), new StreamOptions { Reasoning = ThinkingLevel.High });
Assert.Null(body["thinking"]);
}
[Fact]
public void Max_tokens_is_raised_above_the_thinking_budget()
{
var options = new StreamOptions { Reasoning = ThinkingLevel.High, MaxTokens = 4000 };
var body = AnthropicApi.BuildRequest(Sonnet, LlmContext.Simple(null, "hi"), options);
Assert.Equal(16_384, body["thinking"]!["budget_tokens"]!.GetValue<int>());
Assert.Equal(16_384 + 4096, body["max_tokens"]!.GetValue<int>());
}
[Fact]
public void Unsupported_level_in_map_falls_back_to_the_next_supported_level()
{
// Catalogue models map XHigh/Max to null: expect the High budget.
var body = AnthropicApi.BuildRequest(Sonnet, LlmContext.Simple(null, "hi"), new StreamOptions { Reasoning = ThinkingLevel.Max });
Assert.Equal(16_384, body["thinking"]!["budget_tokens"]!.GetValue<int>());
}
[Fact]
public void Model_without_map_uses_default_budgets_including_xhigh()
{
var model = Sonnet with { ThinkingLevelMap = null };
var body = AnthropicApi.BuildRequest(model, LlmContext.Simple(null, "hi"), new StreamOptions { Reasoning = ThinkingLevel.XHigh });
Assert.Equal(32_768, body["thinking"]!["budget_tokens"]!.GetValue<int>());
}
[Fact]
public void Non_numeric_map_value_selects_adaptive_thinking_with_effort()
{
var model = Sonnet with
{
ThinkingLevelMap = new Dictionary<ThinkingLevel, string?> { [ThinkingLevel.High] = "high", [ThinkingLevel.Low] = "adaptive" },
};
var high = AnthropicApi.BuildRequest(model, LlmContext.Simple(null, "hi"), new StreamOptions { Reasoning = ThinkingLevel.High });
Assert.Equal("adaptive", high["thinking"]!["type"]!.GetValue<string>());
Assert.Null(high["thinking"]!["budget_tokens"]);
Assert.Equal("high", high["output_config"]!["effort"]!.GetValue<string>());
var low = AnthropicApi.BuildRequest(model, LlmContext.Simple(null, "hi"), new StreamOptions { Reasoning = ThinkingLevel.Low });
Assert.Equal("adaptive", low["thinking"]!["type"]!.GetValue<string>());
Assert.Null(low["output_config"]);
}
[Fact]
public void Empty_text_blocks_and_failed_empty_assistant_messages_are_skipped()
{
var messages = new List<Message>
{
UserMessage.FromText("hi"),
Assistant() with { StopReason = StopReason.Error, ErrorMessage = "boom" },
new UserMessage { Content = [new TextContent(" "), new TextContent("again")] },
};
var body = AnthropicApi.BuildRequest(Sonnet, new LlmContext(null, messages, []), StreamOptions.Default);
var wire = body["messages"]!.AsArray();
// The empty assistant message vanishes, so both user messages merge into one.
var only = Assert.Single(wire)!.AsObject();
Assert.Equal("user", only["role"]!.GetValue<string>());
Assert.Equal(["hi", "again"], only["content"]!.AsArray().Select(b => b!["text"]!.GetValue<string>()).ToArray());
}
[Fact]
public void Request_serialises_to_valid_json()
{
var body = AnthropicApi.BuildRequest(Sonnet, LlmContext.Simple("s", "h"), new StreamOptions { Reasoning = ThinkingLevel.Low });
using var doc = JsonDocument.Parse(body.ToJsonString());
Assert.Equal(JsonValueKind.Object, doc.RootElement.ValueKind);
Assert.IsType<JsonObject>(body);
}
[Fact]
public void Catalogue_models_use_adaptive_thinking_or_budgets_as_appropriate()
{
var opus = AnthropicApi.BuildRequest(AnthropicModels.Opus5, LlmContext.Simple("s", "hi"), new StreamOptions { Reasoning = ThinkingLevel.XHigh });
Assert.Equal("adaptive", opus["thinking"]!["type"]!.GetValue<string>());
Assert.Equal("summarized", opus["thinking"]!["display"]!.GetValue<string>());
Assert.Equal("xhigh", opus["output_config"]!["effort"]!.GetValue<string>());
Assert.Null(opus["thinking"]!["budget_tokens"]);
Assert.Equal(128_000, opus["max_tokens"]!.GetValue<int>());
var haiku = AnthropicApi.BuildRequest(AnthropicModels.Haiku45, LlmContext.Simple("s", "hi"), new StreamOptions { Reasoning = ThinkingLevel.Medium });
Assert.Equal("enabled", haiku["thinking"]!["type"]!.GetValue<string>());
Assert.Equal(8192, haiku["thinking"]!["budget_tokens"]!.GetValue<int>());
Assert.Null(haiku["output_config"]);
var off = AnthropicApi.BuildRequest(AnthropicModels.Sonnet5, LlmContext.Simple("s", "hi"), new StreamOptions { Reasoning = ThinkingLevel.Off });
Assert.Null(off["thinking"]);
}
}