273 lines
13 KiB
C#
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"]);
|
|
}
|
|
}
|