feat(anthropic): messages api provider extension
This commit is contained in:
committed by
VolcanicAgenticCore
parent
89623e14c2
commit
3c0b84b392
@@ -0,0 +1,613 @@
|
||||
using System.Net.ServerSentEvents;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Volcanic.AgenticCore.Abstractions.Json;
|
||||
using Volcanic.AgenticCore.Abstractions.Messages;
|
||||
using Volcanic.AgenticCore.Abstractions.Providers;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Anthropic;
|
||||
|
||||
/// <summary>
|
||||
/// Streams <c>POST /v1/messages</c> (Anthropic Messages API, SSE) and maps the wire events to
|
||||
/// <see cref="AssistantMessageEvent"/>s. Never throws from <see cref="StreamAsync"/>: every failure is an
|
||||
/// <see cref="AssistantMessageEvent.Error"/>.
|
||||
/// </summary>
|
||||
/// <param name="http">
|
||||
/// Optional client. When omitted a shared client with an infinite <see cref="HttpClient.Timeout"/> is used;
|
||||
/// a caller-supplied client keeps its own timeout, which also bounds the streamed body.
|
||||
/// </param>
|
||||
public sealed class AnthropicApi(HttpClient? http = null)
|
||||
{
|
||||
public const string ApiVersion = "2023-06-01";
|
||||
|
||||
/// <summary>Headroom added to <c>max_tokens</c> when the thinking budget would not fit under it.</summary>
|
||||
private const int ThinkingHeadroom = 4_096;
|
||||
private const int ErrorExcerptLength = 500;
|
||||
|
||||
private static readonly Lazy<HttpClient> SharedClient = new(() => new HttpClient { Timeout = Timeout.InfiniteTimeSpan });
|
||||
|
||||
private static readonly IReadOnlyDictionary<ThinkingLevel, int> DefaultBudgets = new Dictionary<ThinkingLevel, int>
|
||||
{
|
||||
[ThinkingLevel.Minimal] = 1_024,
|
||||
[ThinkingLevel.Low] = 2_048,
|
||||
[ThinkingLevel.Medium] = 8_192,
|
||||
[ThinkingLevel.High] = 16_384,
|
||||
[ThinkingLevel.XHigh] = 32_768,
|
||||
[ThinkingLevel.Max] = 32_768,
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> EffortNames = new(StringComparer.OrdinalIgnoreCase) { "low", "medium", "high", "xhigh", "max" };
|
||||
|
||||
private readonly HttpClient _http = http ?? SharedClient.Value;
|
||||
|
||||
public async IAsyncEnumerable<AssistantMessageEvent> StreamAsync(
|
||||
Model model, LlmContext context, StreamOptions options, [EnumeratorCancellation] CancellationToken ct)
|
||||
{
|
||||
var builder = new AssistantMessageBuilder(model);
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
yield return builder.Fail("aborted", aborted: true);
|
||||
yield break;
|
||||
}
|
||||
|
||||
CancellationTokenSource? timeoutCts = null;
|
||||
if (options.Timeout is { } timeout)
|
||||
{
|
||||
timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(timeout);
|
||||
}
|
||||
var token = timeoutCts?.Token ?? ct;
|
||||
|
||||
HttpResponseMessage? response = null;
|
||||
Stream? body = null;
|
||||
string? failure = null;
|
||||
var aborted = false;
|
||||
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
JsonNode payload = BuildRequest(model, context, options);
|
||||
if (options.OnPayload is { } onPayload) payload = await onPayload(payload).ConfigureAwait(false) ?? payload;
|
||||
|
||||
using var request = CreateRequest(model, options, payload);
|
||||
response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token).ConfigureAwait(false);
|
||||
|
||||
if (options.OnResponse is { } onResponse) await onResponse(Describe(response)).ConfigureAwait(false);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
body = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
var text = await response.Content.ReadAsStringAsync(token).ConfigureAwait(false);
|
||||
failure = FormatHttpError((int)response.StatusCode, text);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { aborted = true; }
|
||||
catch (OperationCanceledException) { failure = TimeoutMessage(options); }
|
||||
catch (Exception ex) { failure = ex.Message; }
|
||||
|
||||
if (aborted)
|
||||
{
|
||||
yield return builder.Fail("aborted", aborted: true);
|
||||
yield break;
|
||||
}
|
||||
if (failure is not null || body is null)
|
||||
{
|
||||
yield return builder.Fail(failure ?? "No response body.");
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return builder.Start();
|
||||
|
||||
var state = new StreamState(model, builder, options);
|
||||
var events = new List<AssistantMessageEvent>();
|
||||
await using var items = SseParser.Create(body).EnumerateAsync(token).GetAsyncEnumerator(token);
|
||||
|
||||
while (!state.Finished)
|
||||
{
|
||||
events.Clear();
|
||||
try
|
||||
{
|
||||
if (!await items.MoveNextAsync().ConfigureAwait(false)) break;
|
||||
await state.HandleAsync(items.Current, events).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { events.Add(state.Abort()); }
|
||||
catch (OperationCanceledException) { events.Add(state.Fail(TimeoutMessage(options))); }
|
||||
catch (Exception ex) { events.Add(state.Fail(ex.Message)); }
|
||||
|
||||
foreach (var e in events) yield return e;
|
||||
}
|
||||
|
||||
if (!state.Finished) yield return state.EndOfStream();
|
||||
}
|
||||
finally
|
||||
{
|
||||
response?.Dispose();
|
||||
timeoutCts?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- request building -------------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Builds the Messages API request body. Exposed for tests and payload hooks.</summary>
|
||||
public static JsonObject BuildRequest(Model model, LlmContext context, StreamOptions options)
|
||||
{
|
||||
var retention = options.CacheRetention;
|
||||
var body = new JsonObject
|
||||
{
|
||||
["model"] = model.Id,
|
||||
["max_tokens"] = options.MaxTokens ?? model.MaxTokens,
|
||||
["stream"] = true,
|
||||
};
|
||||
|
||||
var thinking = ResolveThinking(model, options);
|
||||
if (thinking is null && options.Temperature is { } temperature) body["temperature"] = temperature;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(context.SystemPrompt))
|
||||
{
|
||||
var system = new JsonObject { ["type"] = "text", ["text"] = context.SystemPrompt };
|
||||
if (retention != CacheRetention.None) system["cache_control"] = CacheControl(retention);
|
||||
body["system"] = new JsonArray(system);
|
||||
}
|
||||
|
||||
body["messages"] = BuildMessages(context.Messages, retention);
|
||||
|
||||
if (context.Tools.Count > 0) body["tools"] = BuildTools(context.Tools);
|
||||
|
||||
if (thinking is not null)
|
||||
{
|
||||
body["thinking"] = thinking.Node;
|
||||
if (thinking.Budget is { } budget && budget >= body["max_tokens"]!.GetValue<int>())
|
||||
body["max_tokens"] = budget + ThinkingHeadroom;
|
||||
if (thinking.Effort is not null)
|
||||
body["output_config"] = new JsonObject { ["effort"] = thinking.Effort };
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
private static JsonArray BuildMessages(IReadOnlyList<Message> messages, CacheRetention retention)
|
||||
{
|
||||
var result = new JsonArray();
|
||||
string? lastRole = null;
|
||||
JsonArray? lastContent = null;
|
||||
|
||||
void Append(string role, List<JsonObject> blocks)
|
||||
{
|
||||
if (blocks.Count == 0) return;
|
||||
if (role != lastRole || lastContent is null)
|
||||
{
|
||||
lastContent = new JsonArray();
|
||||
lastRole = role;
|
||||
result.Add(new JsonObject { ["role"] = role, ["content"] = lastContent });
|
||||
}
|
||||
foreach (var block in blocks) lastContent.Add(block);
|
||||
}
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
switch (message)
|
||||
{
|
||||
case UserMessage user: Append("user", UserBlocks(user.Content)); break;
|
||||
case CustomMessage custom: Append("user", UserBlocks(custom.Content)); break;
|
||||
case ToolResultMessage toolResult: Append("user", [ToolResultBlock(toolResult)]); break;
|
||||
case AssistantMessage assistant: Append("assistant", AssistantBlocks(assistant.Content)); break;
|
||||
}
|
||||
}
|
||||
|
||||
if (retention != CacheRetention.None)
|
||||
{
|
||||
for (var i = result.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var entry = result[i]!.AsObject();
|
||||
if (entry["role"]!.GetValue<string>() != "user") continue;
|
||||
var content = entry["content"]!.AsArray();
|
||||
if (content.Count > 0) content[content.Count - 1]!.AsObject()["cache_control"] = CacheControl(retention);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<JsonObject> UserBlocks(IReadOnlyList<ContentBlock> content)
|
||||
{
|
||||
var blocks = new List<JsonObject>();
|
||||
foreach (var block in content)
|
||||
{
|
||||
switch (block)
|
||||
{
|
||||
case TextContent text when !string.IsNullOrWhiteSpace(text.Text):
|
||||
blocks.Add(TextBlock(text.Text));
|
||||
break;
|
||||
case ImageContent image:
|
||||
blocks.Add(ImageBlock(image));
|
||||
break;
|
||||
}
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private static List<JsonObject> AssistantBlocks(IReadOnlyList<ContentBlock> content)
|
||||
{
|
||||
var blocks = new List<JsonObject>();
|
||||
foreach (var block in content)
|
||||
{
|
||||
switch (block)
|
||||
{
|
||||
case TextContent text when !string.IsNullOrWhiteSpace(text.Text):
|
||||
blocks.Add(TextBlock(text.Text));
|
||||
break;
|
||||
case ThinkingContent { Redacted: true, Signature: { } data }:
|
||||
blocks.Add(new JsonObject { ["type"] = "redacted_thinking", ["data"] = data });
|
||||
break;
|
||||
case ThinkingContent { Redacted: false, Signature: { } signature } thinking:
|
||||
blocks.Add(new JsonObject { ["type"] = "thinking", ["thinking"] = thinking.Thinking, ["signature"] = signature });
|
||||
break;
|
||||
case ToolCallContent call:
|
||||
blocks.Add(new JsonObject
|
||||
{
|
||||
["type"] = "tool_use",
|
||||
["id"] = call.Id,
|
||||
["name"] = call.Name,
|
||||
["input"] = call.Arguments.ValueKind == JsonValueKind.Object ? JsonNode.Parse(call.Arguments.GetRawText()) : new JsonObject(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private static JsonObject ToolResultBlock(ToolResultMessage result)
|
||||
{
|
||||
var block = new JsonObject { ["type"] = "tool_result", ["tool_use_id"] = result.ToolCallId };
|
||||
var content = UserBlocks(result.Content);
|
||||
if (content.Count > 0) block["content"] = new JsonArray(content.ToArray<JsonNode?>());
|
||||
block["is_error"] = result.IsError;
|
||||
return block;
|
||||
}
|
||||
|
||||
private static JsonArray BuildTools(IReadOnlyList<ToolDescriptor> tools)
|
||||
{
|
||||
var array = new JsonArray();
|
||||
foreach (var tool in tools)
|
||||
{
|
||||
array.Add(new JsonObject
|
||||
{
|
||||
["name"] = tool.Name,
|
||||
["description"] = tool.Description,
|
||||
["input_schema"] = tool.Parameters.ValueKind == JsonValueKind.Object
|
||||
? JsonNode.Parse(tool.Parameters.GetRawText())
|
||||
: new JsonObject { ["type"] = "object", ["properties"] = new JsonObject() },
|
||||
});
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
private sealed record ThinkingPlan(JsonObject Node, int? Budget, string? Effort);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the <c>thinking</c> parameter. A numeric <see cref="Model.ThinkingLevelMap"/> value (or the default
|
||||
/// table) becomes <c>{type:"enabled", budget_tokens}</c>; a non-numeric value (for example <c>adaptive</c> or an
|
||||
/// effort name) becomes <c>{type:"adaptive"}</c> for models that reject <c>budget_tokens</c>. A <c>null</c> entry
|
||||
/// marks the level unsupported and the next lower level is used.
|
||||
/// </summary>
|
||||
private static ThinkingPlan? ResolveThinking(Model model, StreamOptions options)
|
||||
{
|
||||
if (!model.Reasoning || options.Reasoning is null or ThinkingLevel.Off) return null;
|
||||
|
||||
for (var level = options.Reasoning.Value; level >= ThinkingLevel.Minimal; level--)
|
||||
{
|
||||
string? mapped;
|
||||
if (model.ThinkingLevelMap is null || !model.ThinkingLevelMap.TryGetValue(level, out mapped))
|
||||
mapped = DefaultBudgets[level].ToString();
|
||||
if (mapped is null) continue;
|
||||
|
||||
if (int.TryParse(mapped, out var budget))
|
||||
return new ThinkingPlan(new JsonObject { ["type"] = "enabled", ["budget_tokens"] = budget }, budget, null);
|
||||
|
||||
var effort = EffortNames.Contains(mapped) ? mapped.ToLowerInvariant() : null;
|
||||
return new ThinkingPlan(new JsonObject { ["type"] = "adaptive" }, null, effort);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static JsonObject CacheControl(CacheRetention retention)
|
||||
{
|
||||
var node = new JsonObject { ["type"] = "ephemeral" };
|
||||
if (retention == CacheRetention.Long) node["ttl"] = "1h";
|
||||
return node;
|
||||
}
|
||||
|
||||
private static JsonObject TextBlock(string text) => new() { ["type"] = "text", ["text"] = text };
|
||||
|
||||
private static JsonObject ImageBlock(ImageContent image) => new()
|
||||
{
|
||||
["type"] = "image",
|
||||
["source"] = new JsonObject { ["type"] = "base64", ["media_type"] = image.MimeType, ["data"] = image.Data },
|
||||
};
|
||||
|
||||
// ---- HTTP -------------------------------------------------------------------------------------------------
|
||||
|
||||
private static HttpRequestMessage CreateRequest(Model model, StreamOptions options, JsonNode payload)
|
||||
{
|
||||
var baseUrl = (model.BaseUrl ?? AnthropicModels.DefaultBaseUrl).TrimEnd('/');
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl}/v1/messages")
|
||||
{
|
||||
Content = new StringContent(payload.ToJsonString(VolcanicJson.Options), Encoding.UTF8, "application/json"),
|
||||
};
|
||||
|
||||
var headers = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["x-api-key"] = options.ApiKey,
|
||||
["anthropic-version"] = ApiVersion,
|
||||
["accept"] = "text/event-stream",
|
||||
};
|
||||
if (model.Headers is not null) foreach (var (name, value) in model.Headers) headers[name] = value;
|
||||
if (options.Headers is not null) foreach (var (name, value) in options.Headers) headers[name] = value;
|
||||
|
||||
foreach (var (name, value) in headers) ApplyHeader(request, name, value);
|
||||
return request;
|
||||
}
|
||||
|
||||
private static void ApplyHeader(HttpRequestMessage request, string name, string? value)
|
||||
{
|
||||
var isContentHeader = name.StartsWith("content-", StringComparison.OrdinalIgnoreCase);
|
||||
var target = isContentHeader ? request.Content!.Headers : (System.Net.Http.Headers.HttpHeaders)request.Headers;
|
||||
target.Remove(name);
|
||||
if (value is not null) target.TryAddWithoutValidation(name, value);
|
||||
}
|
||||
|
||||
private static ProviderResponseInfo Describe(HttpResponseMessage response)
|
||||
{
|
||||
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (name, values) in response.Headers) headers[name] = string.Join(", ", values);
|
||||
foreach (var (name, values) in response.Content.Headers) headers[name] = string.Join(", ", values);
|
||||
return new ProviderResponseInfo((int)response.StatusCode, headers);
|
||||
}
|
||||
|
||||
private static string FormatHttpError(int status, string body)
|
||||
{
|
||||
string? message = null;
|
||||
string? type = null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
if (TryGet(doc.RootElement, "error", out var error))
|
||||
{
|
||||
message = GetString(error, "message");
|
||||
type = GetString(error, "type");
|
||||
}
|
||||
}
|
||||
catch (JsonException) { }
|
||||
|
||||
var detail = message ?? Truncate(body.Trim(), ErrorExcerptLength);
|
||||
if (string.IsNullOrEmpty(detail)) return $"HTTP {status}";
|
||||
return type is null ? $"HTTP {status}: {detail}" : $"HTTP {status}: {detail} ({type})";
|
||||
}
|
||||
|
||||
private static string TimeoutMessage(StreamOptions options) =>
|
||||
options.Timeout is { } t ? $"Request timed out after {t.TotalSeconds:0.#}s." : "Request timed out.";
|
||||
|
||||
private static string Truncate(string text, int max) => text.Length <= max ? text : text[..max] + "…";
|
||||
|
||||
// ---- SSE mapping ------------------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Per-stream mapping state: which block kind is open and the stop reason seen so far.</summary>
|
||||
private sealed class StreamState(Model model, AssistantMessageBuilder builder, StreamOptions options)
|
||||
{
|
||||
private enum OpenKind { None, Text, Thinking, ToolCall, Other }
|
||||
|
||||
private OpenKind _open = OpenKind.None;
|
||||
private string? _stopReason;
|
||||
|
||||
public bool Finished { get; private set; }
|
||||
|
||||
public async ValueTask HandleAsync(SseItem<string> item, List<AssistantMessageEvent> events)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.Data)) return;
|
||||
|
||||
var root = VolcanicJson.Parse(item.Data);
|
||||
if (options.OnProviderStreamEvent is { } hook) await hook(root).ConfigureAwait(false);
|
||||
|
||||
var type = GetString(root, "type") ?? item.EventType;
|
||||
switch (type)
|
||||
{
|
||||
case "message_start":
|
||||
if (TryGet(root, "message", out var message))
|
||||
{
|
||||
builder.SetResponseId(GetString(message, "id"));
|
||||
builder.SetResponseModel(GetString(message, "model"));
|
||||
if (TryGet(message, "usage", out var usage)) builder.SetUsage(ReadUsage(usage, builder.Usage));
|
||||
}
|
||||
break;
|
||||
|
||||
case "content_block_start":
|
||||
if (TryGet(root, "content_block", out var block)) StartBlock(block, events);
|
||||
break;
|
||||
|
||||
case "content_block_delta":
|
||||
if (TryGet(root, "delta", out var delta)) ApplyDelta(delta, events);
|
||||
break;
|
||||
|
||||
case "content_block_stop":
|
||||
CloseOpen(events);
|
||||
break;
|
||||
|
||||
case "message_delta":
|
||||
if (TryGet(root, "delta", out var messageDelta)) _stopReason = GetString(messageDelta, "stop_reason") ?? _stopReason;
|
||||
if (TryGet(root, "usage", out var deltaUsage)) builder.SetUsage(ReadUsage(deltaUsage, builder.Usage));
|
||||
break;
|
||||
|
||||
case "message_stop":
|
||||
Finish(events);
|
||||
break;
|
||||
|
||||
case "error":
|
||||
var error = TryGet(root, "error", out var err) ? GetString(err, "message") ?? GetString(err, "type") : null;
|
||||
events.Add(Fail(error ?? "Unknown stream error."));
|
||||
break;
|
||||
|
||||
// "ping" and anything unknown are ignored.
|
||||
}
|
||||
}
|
||||
|
||||
public AssistantMessageEvent EndOfStream()
|
||||
{
|
||||
var events = new List<AssistantMessageEvent>(1);
|
||||
if (_stopReason is not null) Finish(events);
|
||||
else events.Add(Fail("Stream ended before message_stop."));
|
||||
return events[^1];
|
||||
}
|
||||
|
||||
public AssistantMessageEvent Fail(string message)
|
||||
{
|
||||
Finished = true;
|
||||
ApplyCost();
|
||||
return builder.Fail(message);
|
||||
}
|
||||
|
||||
public AssistantMessageEvent Abort()
|
||||
{
|
||||
Finished = true;
|
||||
ApplyCost();
|
||||
return builder.Fail("aborted", aborted: true);
|
||||
}
|
||||
|
||||
private void StartBlock(JsonElement block, List<AssistantMessageEvent> events)
|
||||
{
|
||||
CloseOpen(events);
|
||||
switch (GetString(block, "type"))
|
||||
{
|
||||
case "text":
|
||||
_open = OpenKind.Text;
|
||||
events.Add(builder.StartText());
|
||||
if (GetString(block, "text") is { Length: > 0 } text) events.Add(builder.AppendText(text));
|
||||
break;
|
||||
case "thinking":
|
||||
_open = OpenKind.Thinking;
|
||||
events.Add(builder.StartThinking());
|
||||
if (GetString(block, "thinking") is { Length: > 0 } thinking) events.Add(builder.AppendThinking(thinking));
|
||||
if (GetString(block, "signature") is { Length: > 0 } signature) builder.AppendThinkingSignature(signature);
|
||||
break;
|
||||
case "redacted_thinking":
|
||||
_open = OpenKind.Thinking;
|
||||
events.Add(builder.StartThinking(redacted: true));
|
||||
if (GetString(block, "data") is { Length: > 0 } data) builder.AppendThinkingSignature(data);
|
||||
break;
|
||||
case "tool_use":
|
||||
_open = OpenKind.ToolCall;
|
||||
events.Add(builder.StartToolCall(GetString(block, "id") ?? "", GetString(block, "name") ?? ""));
|
||||
break;
|
||||
default:
|
||||
_open = OpenKind.Other;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyDelta(JsonElement delta, List<AssistantMessageEvent> events)
|
||||
{
|
||||
switch (GetString(delta, "type"))
|
||||
{
|
||||
case "text_delta":
|
||||
if (GetString(delta, "text") is { } text)
|
||||
{
|
||||
EnsureOpen(OpenKind.Text, events);
|
||||
events.Add(builder.AppendText(text));
|
||||
}
|
||||
break;
|
||||
case "thinking_delta":
|
||||
if (GetString(delta, "thinking") is { } thinking)
|
||||
{
|
||||
EnsureOpen(OpenKind.Thinking, events);
|
||||
events.Add(builder.AppendThinking(thinking));
|
||||
}
|
||||
break;
|
||||
case "signature_delta":
|
||||
if (GetString(delta, "signature") is { } signature)
|
||||
{
|
||||
EnsureOpen(OpenKind.Thinking, events);
|
||||
builder.AppendThinkingSignature(signature);
|
||||
}
|
||||
break;
|
||||
case "input_json_delta":
|
||||
if (_open == OpenKind.ToolCall && GetString(delta, "partial_json") is { } json)
|
||||
events.Add(builder.AppendToolCallDelta(json));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Opens <paramref name="kind"/> (emitting its start event) if a different kind is open, so events stay balanced.</summary>
|
||||
private void EnsureOpen(OpenKind kind, List<AssistantMessageEvent> events)
|
||||
{
|
||||
if (_open == kind) return;
|
||||
CloseOpen(events);
|
||||
_open = kind;
|
||||
events.Add(kind == OpenKind.Text ? builder.StartText() : builder.StartThinking());
|
||||
}
|
||||
|
||||
private void CloseOpen(List<AssistantMessageEvent> events)
|
||||
{
|
||||
if (builder.HasOpenBlock)
|
||||
{
|
||||
switch (_open)
|
||||
{
|
||||
case OpenKind.Text: events.Add(builder.EndText()); break;
|
||||
case OpenKind.Thinking: events.Add(builder.EndThinking()); break;
|
||||
case OpenKind.ToolCall: events.Add(builder.EndToolCall()); break;
|
||||
}
|
||||
}
|
||||
_open = OpenKind.None;
|
||||
}
|
||||
|
||||
private void Finish(List<AssistantMessageEvent> events)
|
||||
{
|
||||
CloseOpen(events);
|
||||
Finished = true;
|
||||
ApplyCost();
|
||||
events.Add(_stopReason switch
|
||||
{
|
||||
"max_tokens" => builder.Done(StopReason.Length),
|
||||
"tool_use" => builder.Done(StopReason.ToolUse),
|
||||
"refusal" => builder.Fail("refusal"),
|
||||
// end_turn, stop_sequence, pause_turn, null and anything unknown.
|
||||
_ => builder.Done(StopReason.Stop),
|
||||
});
|
||||
}
|
||||
|
||||
private void ApplyCost()
|
||||
{
|
||||
var usage = builder.Usage;
|
||||
builder.SetUsage(usage with { Cost = model.CalculateCost(usage) });
|
||||
}
|
||||
|
||||
private static Usage ReadUsage(JsonElement usage, Usage current) => current with
|
||||
{
|
||||
Input = GetInt(usage, "input_tokens") ?? current.Input,
|
||||
Output = GetInt(usage, "output_tokens") ?? current.Output,
|
||||
CacheRead = GetInt(usage, "cache_read_input_tokens") ?? current.CacheRead,
|
||||
CacheWrite = GetInt(usage, "cache_creation_input_tokens") ?? current.CacheWrite,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- JSON helpers -----------------------------------------------------------------------------------------
|
||||
|
||||
private static bool TryGet(JsonElement element, string name, out JsonElement value)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out value)) return true;
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement element, string name) =>
|
||||
TryGet(element, name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null;
|
||||
|
||||
private static int? GetInt(JsonElement element, string name) =>
|
||||
TryGet(element, name, out var value) && value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var n) ? n : null;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Volcanic.AgenticCore.Abstractions.Providers;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Anthropic;
|
||||
|
||||
/// <summary>
|
||||
/// Static catalogue of Anthropic models served through the Messages API.
|
||||
/// Hosts can add more (or override these) by registering their own <see cref="ProviderRegistration"/>.
|
||||
/// </summary>
|
||||
public static class AnthropicModels
|
||||
{
|
||||
public const string ProviderId = "anthropic";
|
||||
public const string ApiId = "anthropic-messages";
|
||||
public const string DefaultBaseUrl = "https://api.anthropic.com";
|
||||
|
||||
/// <summary>
|
||||
/// Thinking budgets (in tokens, as strings) for the catalogue models. <see cref="ThinkingLevel.XHigh"/> and
|
||||
/// <see cref="ThinkingLevel.Max"/> are marked unsupported; the core clamps them to <see cref="ThinkingLevel.High"/>.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyDictionary<ThinkingLevel, string?> ThinkingBudgets = new Dictionary<ThinkingLevel, string?>
|
||||
{
|
||||
[ThinkingLevel.Minimal] = "1024",
|
||||
[ThinkingLevel.Low] = "2048",
|
||||
[ThinkingLevel.Medium] = "8192",
|
||||
[ThinkingLevel.High] = "16384",
|
||||
[ThinkingLevel.XHigh] = null,
|
||||
[ThinkingLevel.Max] = null,
|
||||
};
|
||||
|
||||
public static readonly Model Opus41 = Create("claude-opus-4-1", "Claude Opus 4.1", new ModelCost(15m, 75m, 1.5m, 18.75m), maxTokens: 32_000);
|
||||
public static readonly Model Sonnet45 = Create("claude-sonnet-4-5", "Claude Sonnet 4.5", new ModelCost(3m, 15m, 0.3m, 3.75m), maxTokens: 64_000);
|
||||
public static readonly Model Haiku45 = Create("claude-haiku-4-5", "Claude Haiku 4.5", new ModelCost(1m, 5m, 0.1m, 1.25m), maxTokens: 64_000);
|
||||
|
||||
public static readonly IReadOnlyList<Model> All = [Opus41, Sonnet45, Haiku45];
|
||||
|
||||
private static Model Create(string id, string name, ModelCost cost, int maxTokens) => new()
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Provider = ProviderId,
|
||||
Api = ApiId,
|
||||
BaseUrl = DefaultBaseUrl,
|
||||
Reasoning = true,
|
||||
Input = [ModelInput.Text, ModelInput.Image],
|
||||
Cost = cost,
|
||||
ContextWindow = 200_000,
|
||||
MaxTokens = maxTokens,
|
||||
ThinkingLevelMap = ThinkingBudgets,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions;
|
||||
using Volcanic.AgenticCore.Abstractions.Providers;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Anthropic;
|
||||
|
||||
/// <summary>Registers the <c>anthropic</c> provider (Messages API over SSE) with the session's model registry.</summary>
|
||||
/// <param name="http">Optional <see cref="HttpClient"/>; when omitted a shared client without a request timeout is used.</param>
|
||||
/// <param name="models">Model catalogue; defaults to <see cref="AnthropicModels.All"/>.</param>
|
||||
/// <param name="apiKeySpec">API key spec (literal, <c>$ENV</c> or <c>!command</c>); defaults to <c>$ANTHROPIC_API_KEY</c>.</param>
|
||||
public sealed class AnthropicProviderExtension(HttpClient? http = null, IReadOnlyList<Model>? models = null, string? apiKeySpec = null) : IExtension
|
||||
{
|
||||
public const string ExtensionName = "anthropic-provider";
|
||||
|
||||
public string Name => ExtensionName;
|
||||
|
||||
public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken)
|
||||
{
|
||||
api.RegisterProvider(new ProviderRegistration
|
||||
{
|
||||
Id = AnthropicModels.ProviderId,
|
||||
Name = "Anthropic",
|
||||
Api = AnthropicModels.ApiId,
|
||||
BaseUrl = AnthropicModels.DefaultBaseUrl,
|
||||
ApiKey = apiKeySpec ?? "$ANTHROPIC_API_KEY",
|
||||
Models = models ?? AnthropicModels.All,
|
||||
Stream = new AnthropicApi(http).StreamAsync,
|
||||
});
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
namespace Volcanic.AgenticCore.Extensions.Anthropic; internal static class AssemblyMarker { }
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions;
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
|
||||
using Volcanic.AgenticCore.Abstractions.Messages;
|
||||
using Volcanic.AgenticCore.Abstractions.Providers;
|
||||
using Volcanic.AgenticCore.Abstractions.Tools;
|
||||
using Volcanic.AgenticCore.Extensions.Anthropic;
|
||||
using Volcanic.AgenticCore.Features.Models;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.Anthropic;
|
||||
|
||||
public class AnthropicExtensionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Activate_registers_the_anthropic_provider_with_the_default_catalogue()
|
||||
{
|
||||
var api = new FakeExtensionApi();
|
||||
var extension = new AnthropicProviderExtension();
|
||||
|
||||
Assert.Equal("anthropic-provider", extension.Name);
|
||||
await extension.ActivateAsync(api, CancellationToken.None);
|
||||
|
||||
var registration = Assert.Single(api.Providers);
|
||||
Assert.Equal("anthropic", registration.Id);
|
||||
Assert.Equal("Anthropic", registration.Name);
|
||||
Assert.Equal("anthropic-messages", registration.Api);
|
||||
Assert.Equal("https://api.anthropic.com", registration.BaseUrl);
|
||||
Assert.Equal("$ANTHROPIC_API_KEY", registration.ApiKey);
|
||||
Assert.True(registration.RequiresApiKey);
|
||||
Assert.NotNull(registration.Stream);
|
||||
Assert.Equal(["claude-opus-4-1", "claude-sonnet-4-5", "claude-haiku-4-5"], registration.Models.Select(m => m.Id).ToArray());
|
||||
Assert.All(registration.Models, m =>
|
||||
{
|
||||
Assert.Equal("anthropic", m.Provider);
|
||||
Assert.Equal("anthropic-messages", m.Api);
|
||||
Assert.True(m.Reasoning);
|
||||
Assert.True(m.SupportsImages);
|
||||
Assert.Equal(200_000, m.ContextWindow);
|
||||
Assert.Null(m.ThinkingLevelMap![ThinkingLevel.XHigh]);
|
||||
Assert.Equal("8192", m.ThinkingLevelMap[ThinkingLevel.Medium]);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Activate_honours_custom_models_and_api_key_spec()
|
||||
{
|
||||
var api = new FakeExtensionApi();
|
||||
var custom = AnthropicModels.Haiku45 with { Id = "claude-custom", Name = "Custom" };
|
||||
var extension = new AnthropicProviderExtension(models: [custom], apiKeySpec: "!op read secret");
|
||||
|
||||
await extension.ActivateAsync(api, CancellationToken.None);
|
||||
|
||||
var registration = Assert.Single(api.Providers);
|
||||
Assert.Equal("!op read secret", registration.ApiKey);
|
||||
Assert.Equal("claude-custom", Assert.Single(registration.Models).Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Registration_resolves_through_the_model_registry()
|
||||
{
|
||||
var registry = new ModelRegistry(new ApiKeyResolver(null, _ => null));
|
||||
var api = new FakeExtensionApi();
|
||||
await new AnthropicProviderExtension().ActivateAsync(api, CancellationToken.None);
|
||||
registry.Register(api.Providers[0]);
|
||||
|
||||
var model = registry.Find("anthropic/claude-sonnet-4-5");
|
||||
Assert.NotNull(model);
|
||||
Assert.Equal("Claude Sonnet 4.5", model!.Name);
|
||||
Assert.Equal("anthropic-messages", model.Api);
|
||||
Assert.Equal("https://api.anthropic.com", model.BaseUrl);
|
||||
Assert.Equal(3, registry.ListModels().Count);
|
||||
}
|
||||
|
||||
/// <summary>Only <see cref="RegisterProvider"/> is supported; everything else throws.</summary>
|
||||
private sealed class FakeExtensionApi : IExtensionApi
|
||||
{
|
||||
public List<ProviderRegistration> Providers { get; } = [];
|
||||
|
||||
public string ExtensionName => "test";
|
||||
public ILogger Logger => throw new NotSupportedException();
|
||||
public IEventBus Events => throw new NotSupportedException();
|
||||
public IDisposable On<TEvent>(ExtensionHandler<TEvent> handler) where TEvent : ExtensionEvent => throw new NotSupportedException();
|
||||
public void RegisterTool(ITool tool) => throw new NotSupportedException();
|
||||
public void RegisterCommand(string name, CommandDefinition command) => throw new NotSupportedException();
|
||||
public void RegisterProvider(ProviderRegistration provider) => Providers.Add(provider);
|
||||
public void UnregisterProvider(string providerId) => throw new NotSupportedException();
|
||||
public void SendMessage(CustomMessage message, MessageDelivery delivery = MessageDelivery.Steer, bool triggerTurn = true) => throw new NotSupportedException();
|
||||
public void SendUserMessage(string text, IReadOnlyList<ImageContent>? images = null, MessageDelivery delivery = MessageDelivery.Steer) => throw new NotSupportedException();
|
||||
public void AppendEntry(string customType, JsonElement? data = null) => throw new NotSupportedException();
|
||||
public Task<bool> SetModelAsync(Model model, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
public ThinkingLevel GetThinkingLevel() => throw new NotSupportedException();
|
||||
public void SetThinkingLevel(ThinkingLevel level) => throw new NotSupportedException();
|
||||
public IReadOnlyList<string> GetActiveTools() => throw new NotSupportedException();
|
||||
public void SetActiveTools(IEnumerable<string> names) => throw new NotSupportedException();
|
||||
public IReadOnlyList<ToolInfo> GetAllTools() => throw new NotSupportedException();
|
||||
public IReadOnlyList<CommandInfo> GetCommands() => throw new NotSupportedException();
|
||||
public string? GetSessionName() => throw new NotSupportedException();
|
||||
public void SetSessionName(string? name) => throw new NotSupportedException();
|
||||
public Task<ExecResult> ExecAsync(string command, IReadOnlyList<string> args, ExecOptions? options = null, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Volcanic.AgenticCore.Abstractions.Messages;
|
||||
using Volcanic.AgenticCore.Abstractions.Providers;
|
||||
using Volcanic.AgenticCore.Extensions.Anthropic;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.Anthropic;
|
||||
|
||||
public class AnthropicStreamTests
|
||||
{
|
||||
private static readonly Model Sonnet = AnthropicModels.Sonnet45;
|
||||
|
||||
private const string ToolUseStream = """
|
||||
event: message_start
|
||||
data: {"type":"message_start","message":{"id":"msg_123","type":"message","role":"assistant","model":"claude-sonnet-4-5-20250929","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":25,"cache_creation_input_tokens":3,"cache_read_input_tokens":5,"output_tokens":1}}}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}
|
||||
|
||||
event: ping
|
||||
data: {"type":"ping"}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Let me think"}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig_abc"}}
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":0}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Hello"}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" world"}}
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":1}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":2,"content_block":{"type":"tool_use","id":"toolu_01","name":"bash","input":{}}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{\"comm"}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"and\":\"ls\"}"}}
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":2}
|
||||
|
||||
event: message_delta
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":12}}
|
||||
|
||||
event: message_stop
|
||||
data: {"type":"message_stop"}
|
||||
|
||||
""";
|
||||
|
||||
private const string MinimalStream = """
|
||||
event: message_start
|
||||
data: {"type":"message_start","message":{"id":"msg_min","type":"message","role":"assistant","model":"claude-sonnet-4-5","content":[],"stop_reason":null,"usage":{"input_tokens":1,"output_tokens":1}}}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":0}
|
||||
|
||||
event: message_delta
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":2}}
|
||||
|
||||
event: message_stop
|
||||
data: {"type":"message_stop"}
|
||||
|
||||
""";
|
||||
|
||||
/// <summary>SSE only dispatches an event after its trailing blank line (raw string literals drop the final newline).</summary>
|
||||
private static string Sse(string fixture) => fixture.TrimEnd('\r', '\n') + "\n\n";
|
||||
|
||||
private static async Task<List<AssistantMessageEvent>> CollectAsync(FakeHttpHandler handler, StreamOptions? options = null, CancellationToken ct = default, Model? model = null)
|
||||
{
|
||||
var api = new AnthropicApi(handler.CreateClient());
|
||||
var events = new List<AssistantMessageEvent>();
|
||||
await foreach (var e in api.StreamAsync(model ?? Sonnet, LlmContext.Simple("sys", "hi"), options ?? new StreamOptions { ApiKey = "sk-test" }, ct))
|
||||
events.Add(e);
|
||||
return events;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Maps_thinking_text_and_tool_use_stream_to_events_and_final_message()
|
||||
{
|
||||
var handler = new FakeHttpHandler { Body = Sse(ToolUseStream) };
|
||||
var streamEvents = new List<string>();
|
||||
var options = new StreamOptions
|
||||
{
|
||||
ApiKey = "sk-test",
|
||||
OnProviderStreamEvent = e => { streamEvents.Add(e.GetProperty("type").GetString()!); return ValueTask.CompletedTask; },
|
||||
};
|
||||
|
||||
var events = await CollectAsync(handler, options);
|
||||
|
||||
Type[] expected =
|
||||
[
|
||||
typeof(AssistantMessageEvent.Start),
|
||||
typeof(AssistantMessageEvent.ThinkingStart),
|
||||
typeof(AssistantMessageEvent.ThinkingDelta),
|
||||
typeof(AssistantMessageEvent.ThinkingEnd),
|
||||
typeof(AssistantMessageEvent.TextStart),
|
||||
typeof(AssistantMessageEvent.TextDelta),
|
||||
typeof(AssistantMessageEvent.TextDelta),
|
||||
typeof(AssistantMessageEvent.TextEnd),
|
||||
typeof(AssistantMessageEvent.ToolCallStart),
|
||||
typeof(AssistantMessageEvent.ToolCallDelta),
|
||||
typeof(AssistantMessageEvent.ToolCallDelta),
|
||||
typeof(AssistantMessageEvent.ToolCallEnd),
|
||||
typeof(AssistantMessageEvent.Done),
|
||||
];
|
||||
Assert.Equal(expected, events.Select(e => e.GetType()).ToArray());
|
||||
|
||||
var done = Assert.IsType<AssistantMessageEvent.Done>(events[^1]);
|
||||
Assert.Equal(StopReason.ToolUse, done.Reason);
|
||||
|
||||
var message = done.Message;
|
||||
Assert.Equal(StopReason.ToolUse, message.StopReason);
|
||||
Assert.Equal("msg_123", message.ResponseId);
|
||||
Assert.Equal("claude-sonnet-4-5-20250929", message.ResponseModel);
|
||||
Assert.Equal("anthropic", message.Provider);
|
||||
Assert.Equal("anthropic-messages", message.Api);
|
||||
Assert.Equal(3, message.Content.Count);
|
||||
|
||||
var thinking = Assert.IsType<ThinkingContent>(message.Content[0]);
|
||||
Assert.Equal("Let me think", thinking.Thinking);
|
||||
Assert.Equal("sig_abc", thinking.Signature);
|
||||
Assert.False(thinking.Redacted);
|
||||
|
||||
Assert.Equal("Hello world", Assert.IsType<TextContent>(message.Content[1]).Text);
|
||||
|
||||
var call = Assert.IsType<ToolCallContent>(message.Content[2]);
|
||||
Assert.Equal("toolu_01", call.Id);
|
||||
Assert.Equal("bash", call.Name);
|
||||
Assert.Equal("ls", call.Arguments.GetProperty("command").GetString());
|
||||
Assert.Same(call, Assert.IsType<AssistantMessageEvent.ToolCallEnd>(events[11]).ToolCall);
|
||||
|
||||
Assert.Equal(25, message.Usage.Input);
|
||||
Assert.Equal(12, message.Usage.Output);
|
||||
Assert.Equal(5, message.Usage.CacheRead);
|
||||
Assert.Equal(3, message.Usage.CacheWrite);
|
||||
Assert.True(message.Usage.Cost.Total > 0);
|
||||
Assert.Equal(25 * 3m / 1_000_000m, message.Usage.Cost.Input);
|
||||
|
||||
// Every raw SSE event reached the hook, in order, including ping.
|
||||
Assert.Equal(16, streamEvents.Count);
|
||||
Assert.Equal("message_start", streamEvents[0]);
|
||||
Assert.Contains("ping", streamEvents);
|
||||
Assert.Equal("message_stop", streamEvents[^1]);
|
||||
|
||||
// Request wire details.
|
||||
Assert.Equal("https://api.anthropic.com/v1/messages", handler.RequestUri!.ToString());
|
||||
Assert.Equal("sk-test", handler.RequestHeaders["x-api-key"]);
|
||||
Assert.Equal("2023-06-01", handler.RequestHeaders["anthropic-version"]);
|
||||
Assert.StartsWith("application/json", handler.RequestHeaders["content-type"]);
|
||||
Assert.Contains("\"stream\":true", handler.RequestBody);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Partial_snapshots_accumulate_content_while_streaming()
|
||||
{
|
||||
var events = await CollectAsync(new FakeHttpHandler { Body = Sse(ToolUseStream) });
|
||||
|
||||
var secondTextDelta = events.OfType<AssistantMessageEvent.TextDelta>().Last();
|
||||
Assert.Equal("Hello world", secondTextDelta.Partial.Text);
|
||||
Assert.Equal(StopReason.Pending, secondTextDelta.Partial.StopReason);
|
||||
Assert.Equal(25, secondTextDelta.Partial.Usage.Input);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Http_error_with_json_body_yields_a_single_error_event()
|
||||
{
|
||||
var handler = new FakeHttpHandler
|
||||
{
|
||||
Status = (HttpStatusCode)529,
|
||||
ContentType = "application/json",
|
||||
Body = """{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}""",
|
||||
};
|
||||
ProviderResponseInfo? info = null;
|
||||
var options = new StreamOptions { ApiKey = "sk-test", OnResponse = i => { info = i; return ValueTask.CompletedTask; } };
|
||||
|
||||
var events = await CollectAsync(handler, options);
|
||||
|
||||
var error = Assert.IsType<AssistantMessageEvent.Error>(Assert.Single(events));
|
||||
Assert.Equal(StopReason.Error, error.Reason);
|
||||
Assert.Contains("529", error.Message.ErrorMessage);
|
||||
Assert.Contains("Overloaded", error.Message.ErrorMessage);
|
||||
Assert.Equal(529, info!.Status);
|
||||
Assert.StartsWith("application/json", info.Headers["content-type"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Http_error_with_non_json_body_includes_an_excerpt()
|
||||
{
|
||||
var handler = new FakeHttpHandler { Status = HttpStatusCode.BadGateway, ContentType = "text/html", Body = "<html>upstream down</html>" };
|
||||
var events = await CollectAsync(handler);
|
||||
|
||||
var error = Assert.IsType<AssistantMessageEvent.Error>(Assert.Single(events));
|
||||
Assert.Contains("502", error.Message.ErrorMessage);
|
||||
Assert.Contains("upstream down", error.Message.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_before_the_response_yields_aborted()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
var handler = new FakeHttpHandler { Body = Sse(MinimalStream) };
|
||||
|
||||
var events = await CollectAsync(handler, ct: cts.Token);
|
||||
|
||||
var error = Assert.IsType<AssistantMessageEvent.Error>(Assert.Single(events));
|
||||
Assert.Equal(StopReason.Aborted, error.Reason);
|
||||
Assert.Equal("aborted", error.Message.ErrorMessage);
|
||||
Assert.Equal(0, handler.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_while_waiting_for_the_response_yields_aborted()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
var handler = new FakeHttpHandler
|
||||
{
|
||||
Body = Sse(MinimalStream),
|
||||
BeforeResponse = (_, _) => { cts.Cancel(); return Task.CompletedTask; },
|
||||
};
|
||||
|
||||
var events = await CollectAsync(handler, ct: cts.Token);
|
||||
|
||||
var error = Assert.IsType<AssistantMessageEvent.Error>(Assert.Single(events));
|
||||
Assert.Equal(StopReason.Aborted, error.Reason);
|
||||
Assert.Equal(1, handler.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnPayload_replacement_is_what_gets_sent()
|
||||
{
|
||||
var handler = new FakeHttpHandler { Body = Sse(MinimalStream) };
|
||||
JsonNode? seen = null;
|
||||
var options = new StreamOptions
|
||||
{
|
||||
ApiKey = "sk-test",
|
||||
OnPayload = node =>
|
||||
{
|
||||
seen = node;
|
||||
var replaced = new JsonObject { ["model"] = "replaced-model", ["max_tokens"] = 5, ["stream"] = true, ["messages"] = new JsonArray() };
|
||||
return ValueTask.FromResult<JsonNode?>(replaced);
|
||||
},
|
||||
};
|
||||
|
||||
var events = await CollectAsync(handler, options);
|
||||
|
||||
Assert.IsType<AssistantMessageEvent.Done>(events[^1]);
|
||||
Assert.NotNull(seen);
|
||||
Assert.Equal("claude-sonnet-4-5", seen!["model"]!.GetValue<string>());
|
||||
using var sent = JsonDocument.Parse(handler.RequestBody!);
|
||||
Assert.Equal("replaced-model", sent.RootElement.GetProperty("model").GetString());
|
||||
Assert.Equal(5, sent.RootElement.GetProperty("max_tokens").GetInt32());
|
||||
Assert.False(sent.RootElement.TryGetProperty("system", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Extra_headers_are_merged_and_null_removes_a_default()
|
||||
{
|
||||
var handler = new FakeHttpHandler { Body = Sse(MinimalStream) };
|
||||
var options = new StreamOptions
|
||||
{
|
||||
ApiKey = "sk-test",
|
||||
Headers = new Dictionary<string, string?> { ["anthropic-beta"] = "some-beta", ["x-api-key"] = null, ["authorization"] = "Bearer tok" },
|
||||
};
|
||||
var model = Sonnet with { BaseUrl = "https://proxy.example/", Headers = new Dictionary<string, string> { ["x-model-header"] = "m" } };
|
||||
|
||||
await CollectAsync(handler, options, model: model);
|
||||
|
||||
Assert.Equal("https://proxy.example/v1/messages", handler.RequestUri!.ToString());
|
||||
Assert.Equal("some-beta", handler.RequestHeaders["anthropic-beta"]);
|
||||
Assert.Equal("Bearer tok", handler.RequestHeaders["authorization"]);
|
||||
Assert.Equal("m", handler.RequestHeaders["x-model-header"]);
|
||||
Assert.False(handler.RequestHeaders.ContainsKey("x-api-key"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sse_error_event_fails_the_stream_once()
|
||||
{
|
||||
const string body = """
|
||||
event: message_start
|
||||
data: {"type":"message_start","message":{"id":"msg_e","type":"message","role":"assistant","model":"m","content":[],"usage":{"input_tokens":4,"output_tokens":0}}}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"partial"}}
|
||||
|
||||
event: error
|
||||
data: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded mid-stream"}}
|
||||
|
||||
""";
|
||||
|
||||
var events = await CollectAsync(new FakeHttpHandler { Body = Sse(body) });
|
||||
|
||||
Assert.IsType<AssistantMessageEvent.Start>(events[0]);
|
||||
var error = Assert.IsType<AssistantMessageEvent.Error>(events[^1]);
|
||||
Assert.Single(events, e => e.IsTerminal);
|
||||
Assert.Equal("Overloaded mid-stream", error.Message.ErrorMessage);
|
||||
Assert.Equal("partial", error.Message.Text);
|
||||
Assert.Equal(4, error.Message.Usage.Input);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stream_ending_without_message_stop_is_an_error()
|
||||
{
|
||||
const string body = """
|
||||
event: message_start
|
||||
data: {"type":"message_start","message":{"id":"msg_t","type":"message","role":"assistant","model":"m","content":[],"usage":{"input_tokens":1,"output_tokens":0}}}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
|
||||
|
||||
""";
|
||||
|
||||
var events = await CollectAsync(new FakeHttpHandler { Body = Sse(body) });
|
||||
|
||||
var error = Assert.IsType<AssistantMessageEvent.Error>(events[^1]);
|
||||
Assert.Equal(StopReason.Error, error.Reason);
|
||||
Assert.Single(events, e => e.IsTerminal);
|
||||
Assert.Equal([typeof(AssistantMessageEvent.Start), typeof(AssistantMessageEvent.TextStart), typeof(AssistantMessageEvent.Error)], events.Select(e => e.GetType()).ToArray());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("end_turn", StopReason.Stop)]
|
||||
[InlineData("stop_sequence", StopReason.Stop)]
|
||||
[InlineData("pause_turn", StopReason.Stop)]
|
||||
[InlineData("max_tokens", StopReason.Length)]
|
||||
public async Task Stop_reasons_map(string wire, StopReason expected)
|
||||
{
|
||||
var body = MinimalStream.Replace("end_turn", wire);
|
||||
var events = await CollectAsync(new FakeHttpHandler { Body = Sse(body) });
|
||||
|
||||
var done = Assert.IsType<AssistantMessageEvent.Done>(events[^1]);
|
||||
Assert.Equal(expected, done.Reason);
|
||||
Assert.Equal("ok", done.Message.Text);
|
||||
Assert.Equal(2, done.Message.Usage.Output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refusal_stop_reason_is_an_error()
|
||||
{
|
||||
var body = MinimalStream.Replace("end_turn", "refusal");
|
||||
var events = await CollectAsync(new FakeHttpHandler { Body = Sse(body) });
|
||||
|
||||
var error = Assert.IsType<AssistantMessageEvent.Error>(events[^1]);
|
||||
Assert.Equal("refusal", error.Message.ErrorMessage);
|
||||
Assert.Equal("ok", error.Message.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Redacted_thinking_is_preserved_as_signature()
|
||||
{
|
||||
const string body = """
|
||||
event: message_start
|
||||
data: {"type":"message_start","message":{"id":"msg_r","type":"message","role":"assistant","model":"m","content":[],"usage":{"input_tokens":1,"output_tokens":0}}}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"redacted_thinking","data":"EmwKAhgBEgy"}}
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":0}
|
||||
|
||||
event: message_delta
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":3}}
|
||||
|
||||
event: message_stop
|
||||
data: {"type":"message_stop"}
|
||||
|
||||
""";
|
||||
|
||||
var events = await CollectAsync(new FakeHttpHandler { Body = Sse(body) });
|
||||
|
||||
var done = Assert.IsType<AssistantMessageEvent.Done>(events[^1]);
|
||||
var thinking = Assert.IsType<ThinkingContent>(Assert.Single(done.Message.Content));
|
||||
Assert.True(thinking.Redacted);
|
||||
Assert.Equal("EmwKAhgBEgy", thinking.Signature);
|
||||
Assert.Equal("", thinking.Thinking);
|
||||
Assert.Equal([typeof(AssistantMessageEvent.Start), typeof(AssistantMessageEvent.ThinkingStart), typeof(AssistantMessageEvent.ThinkingEnd), typeof(AssistantMessageEvent.Done)], events.Select(e => e.GetType()).ToArray());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Tests.Anthropic;
|
||||
|
||||
/// <summary>Captures the outgoing request and answers with a scripted body (SSE by default).</summary>
|
||||
internal sealed class FakeHttpHandler : HttpMessageHandler
|
||||
{
|
||||
public HttpStatusCode Status { get; init; } = HttpStatusCode.OK;
|
||||
public string Body { get; init; } = "";
|
||||
public string ContentType { get; init; } = "text/event-stream";
|
||||
|
||||
/// <summary>Optional hook run before the response is produced (for example to cancel a token).</summary>
|
||||
public Func<HttpRequestMessage, CancellationToken, Task>? BeforeResponse { get; init; }
|
||||
|
||||
public Uri? RequestUri { get; private set; }
|
||||
public string? RequestBody { get; private set; }
|
||||
public Dictionary<string, string> RequestHeaders { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public int Calls { get; private set; }
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
Calls++;
|
||||
RequestUri = request.RequestUri;
|
||||
foreach (var (name, values) in request.Headers) RequestHeaders[name] = string.Join(", ", values);
|
||||
if (request.Content is not null)
|
||||
{
|
||||
foreach (var (name, values) in request.Content.Headers) RequestHeaders[name] = string.Join(", ", values);
|
||||
RequestBody = await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (BeforeResponse is not null) await BeforeResponse(request, cancellationToken);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
return new HttpResponseMessage(Status)
|
||||
{
|
||||
RequestMessage = request,
|
||||
Content = new StringContent(Body, Encoding.UTF8, ContentType),
|
||||
};
|
||||
}
|
||||
|
||||
public HttpClient CreateClient() => new(this, disposeHandler: false);
|
||||
}
|
||||
Reference in New Issue
Block a user